From 7369a596831d08e521822928a4a6ff54e6bdf889 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 4 Oct 2026 21:46:26 +0200 Subject: [PATCH 1/8] feat: import agentctl and the sandbox crates from altinn-studio Imported verbatim from Altinn/altinn-studio@a117f7c25a64d043619f4516f12af35138e31025 (src/experimental and the root Rust workspace files), without history: - src/experimental/agent and the agentctl docs, changelog and scripts -> agentctl/ - src/experimental/sandbox, sandbox-authorization and sandbox-microsandbox -> sandbox/ - Cargo.toml, Cargo.lock, rust-toolchain.toml, deny.toml, rustfmt.toml, clippy.toml, Makefile, .editorconfig and .cargo/config.toml -> the repository root The workspace does not build at this commit; the next commit adapts it. --- .cargo/config.toml | 2 + .editorconfig | 16 + Cargo.lock | 7733 +++++++++++++++++ Cargo.toml | 76 + Makefile | 89 + agentctl/AGENTS.md | 78 + agentctl/CHANGELOG.md | 247 + agentctl/Cargo.toml | 48 + agentctl/HARNESSES.md | 75 + agentctl/README.md | 220 + agentctl/changelog.sh | 434 + agentctl/changelog_test.sh | 735 ++ agentctl/examples/minimal/.dockerignore | 3 + agentctl/examples/minimal/Dockerfile | 76 + agentctl/examples/minimal/README.md | 39 + agentctl/examples/minimal/agent.yaml | 32 + agentctl/examples/minimal/claude-state.json | 9 + agentctl/examples/minimal/home/.gitkeep | 0 agentctl/examples/minimal/instructions.md | 11 + agentctl/examples/self-dev/.dockerignore | 9 + agentctl/examples/self-dev/.env.sample | 5 + agentctl/examples/self-dev/.gitignore | 6 + agentctl/examples/self-dev/Dockerfile | 199 + agentctl/examples/self-dev/README.md | 40 + agentctl/examples/self-dev/agent.nested.yaml | 15 + .../examples/self-dev/agent.worktree.yaml | 17 + agentctl/examples/self-dev/agent.yaml | 57 + .../self-dev/home/.claude/.claude.json | 9 + agentctl/examples/self-dev/home/.gitkeep | 0 agentctl/examples/self-dev/instructions.md | 41 + agentctl/examples/self-dev/nvim-sysinit.vim | 3 + .../self-dev/skills/pr-evidence/SKILL.md | 57 + agentctl/examples/self-dev/tmpfiles.conf | 2 + .../examples/self-dev/workspace-init.service | 19 + agentctl/examples/self-dev/workspace-init.sh | 26 + agentctl/high-level.excalidraw.svg | 2 + agentctl/install.ps1 | 127 + agentctl/install.sh | 132 + agentctl/installation-test.sh | 176 + agentctl/make-user-install.ps1 | 85 + agentctl/package.sh | 36 + agentctl/src/authorization/agent_policy.rs | 265 + agentctl/src/authorization/mod.rs | 5 + agentctl/src/bin/agentctl/format.rs | 409 + agentctl/src/bin/agentctl/launch.rs | 343 + agentctl/src/bin/agentctl/main.rs | 2298 +++++ agentctl/src/bin/agentctl/progress.rs | 870 ++ agentctl/src/bin/agentctl/self_update.rs | 526 ++ agentctl/src/bin/agentctl/tui/app.rs | 4964 +++++++++++ agentctl/src/bin/agentctl/tui/mod.rs | 1548 ++++ agentctl/src/bin/agentctl/tui/open.rs | 698 ++ agentctl/src/bin/agentctl/tui/provisioning.rs | 169 + agentctl/src/bin/agentctl/tui/terminal.rs | 265 + agentctl/src/bin/agentctl/tui/view.rs | 2539 ++++++ agentctl/src/bin/agentd.rs | 233 + agentctl/src/control_api/client.rs | 486 ++ agentctl/src/control_api/mod.rs | 13 + agentctl/src/control_api/protocol.rs | 290 + agentctl/src/control_api/server.rs | 932 ++ agentctl/src/control_api/socket.rs | 232 + agentctl/src/control_plane/controller.rs | 49 + agentctl/src/control_plane/convergence.rs | 128 + agentctl/src/control_plane/memory.rs | 181 + agentctl/src/control_plane/mod.rs | 48 + agentctl/src/control_plane/reconciler.rs | 527 ++ agentctl/src/control_plane/resource.rs | 99 + agentctl/src/control_plane/service.rs | 644 ++ agentctl/src/controller.rs | 263 + agentctl/src/environment.rs | 141 + .../src/harness/claude_code/authentication.rs | 238 + .../harness/claude_code/bootstrap/linux.rs | 93 + .../src/harness/claude_code/bootstrap/mod.rs | 12 + agentctl/src/harness/claude_code/hooks.rs | 82 + agentctl/src/harness/claude_code/mod.rs | 338 + .../src/harness/claude_code/status_line.mjs | 64 + .../src/harness/claude_code/status_line.rs | 12 + .../src/harness/claude_code/transcript.rs | 389 + agentctl/src/harness/codex/authentication.rs | 686 ++ agentctl/src/harness/codex/bootstrap/linux.rs | 185 + agentctl/src/harness/codex/bootstrap/mod.rs | 12 + agentctl/src/harness/codex/hooks.rs | 80 + agentctl/src/harness/codex/mod.rs | 400 + agentctl/src/harness/codex/transcript.rs | 533 ++ agentctl/src/harness/hook_script.rs | 259 + agentctl/src/harness/mod.rs | 597 ++ agentctl/src/harness/skills.rs | 74 + agentctl/src/lib.rs | 104 + agentctl/src/local/home.rs | 229 + agentctl/src/local/mod.rs | 4 + agentctl/src/local/process.rs | 69 + agentctl/src/manifest.rs | 1308 +++ agentctl/src/persistence/agents.rs | 242 + agentctl/src/persistence/mod.rs | 1104 +++ agentctl/src/persistence/schema.rs | 575 ++ agentctl/src/persistence/secrets.rs | 123 + agentctl/src/persistence/sessions.rs | 555 ++ agentctl/src/platform_api/mod.rs | 349 + agentctl/src/progress/mod.rs | 47 + agentctl/src/progress/observer.rs | 110 + agentctl/src/progress/state.rs | 247 + agentctl/src/resources.rs | 212 + agentctl/src/sandbox/execution.rs | 96 + agentctl/src/sandbox/forward.rs | 267 + .../src/sandbox/microsandbox/execution.rs | 29 + agentctl/src/sandbox/microsandbox/forward.rs | 72 + agentctl/src/sandbox/microsandbox/mod.rs | 218 + .../src/sandbox/microsandbox/preparation.rs | 204 + agentctl/src/sandbox/microsandbox/terminal.rs | 30 + agentctl/src/sandbox/mod.rs | 459 + agentctl/src/sandbox/platform/files.rs | 130 + agentctl/src/sandbox/platform/linux.rs | 625 ++ agentctl/src/sandbox/platform/linux_ssh.rs | 516 ++ agentctl/src/sandbox/platform/linux_vnc.rs | 349 + agentctl/src/sandbox/platform/mod.rs | 28 + agentctl/src/sandbox/responsiveness.rs | 193 + agentctl/src/sessions/activity.rs | 155 + agentctl/src/sessions/controller.rs | 101 + agentctl/src/sessions/mod.rs | 866 ++ agentctl/src/sessions/reconciler.rs | 468 + agentctl/src/sessions/runtime/deliver.sh | 8 + agentctl/src/sessions/runtime/mod.rs | 107 + agentctl/src/sessions/runtime/observe.sh | 16 + agentctl/src/sessions/runtime/stop.sh | 4 + agentctl/src/sessions/runtime/tmux.rs | 873 ++ agentctl/src/sessions/sandboxes.rs | 52 + agentctl/src/sessions/service.rs | 621 ++ agentctl/src/sessions/transcript.rs | 50 + agentctl/src/ssh/client_config.rs | 635 ++ agentctl/src/ssh/keys.rs | 74 + agentctl/src/ssh/memory.rs | 50 + agentctl/src/ssh/mod.rs | 516 ++ agentctl/src/upgrade.rs | 1128 +++ agentctl/src/vnc/mod.rs | 190 + agentctl/tests/agent_manifests.rs | 269 + agentctl/tests/architecture.rs | 169 + agentctl/tests/control_api.rs | 1221 +++ agentctl/tests/control_plane.rs | 2871 ++++++ agentctl/tests/database.rs | 1494 ++++ .../fixtures/claude-code-transcript.jsonl | 23 + agentctl/tests/fixtures/codex-rollout.jsonl | 22 + agentctl/tests/home.rs | 55 + agentctl/tests/manifest.rs | 845 ++ agentctl/tests/microsandbox_attach.rs | 18 + agentctl/tests/platform_api.rs | 373 + agentctl/tests/platform_linux.rs | 951 ++ agentctl/tests/policy.rs | 260 + agentctl/tests/session_controller.rs | 3437 ++++++++ agentctl/tests/sessions.rs | 22 + agentctl/tests/ssh_access.rs | 717 ++ agentctl/tests/support/mod.rs | 99 + agentctl/tests/tmux_delivery.mjs | 65 + agentctl/tests/tmux_scrollback.mjs | 108 + agentctl/tests/tmux_suspend.mjs | 100 + agentctl/tests/variants.rs | 283 + agentctl/tests/vnc_access.rs | 517 ++ clippy.toml | 5 + deny.toml | 26 + rust-toolchain.toml | 4 + rustfmt.toml | 1 + sandbox/MICROSANDBOX.md | 402 + sandbox/sandbox-authorization/Cargo.toml | 17 + sandbox/sandbox-authorization/src/lib.rs | 343 + .../tests/architecture.rs | 12 + sandbox/sandbox-authorization/tests/policy.rs | 23 + sandbox/sandbox-microsandbox/Cargo.toml | 31 + sandbox/sandbox-microsandbox/src/backend.rs | 1052 +++ sandbox/sandbox-microsandbox/src/client.rs | 467 + sandbox/sandbox-microsandbox/src/encoding.rs | 20 + sandbox/sandbox-microsandbox/src/error.rs | 21 + sandbox/sandbox-microsandbox/src/execution.rs | 301 + sandbox/sandbox-microsandbox/src/files.rs | 163 + sandbox/sandbox-microsandbox/src/guest_tcp.rs | 367 + sandbox/sandbox-microsandbox/src/heartbeat.rs | 140 + sandbox/sandbox-microsandbox/src/image.rs | 1202 +++ .../sandbox-microsandbox/src/image_cache.rs | 974 +++ sandbox/sandbox-microsandbox/src/lib.rs | 30 + .../src/network_backend.rs | 1258 +++ .../src/network_endpoint.rs | 151 + sandbox/sandbox-microsandbox/src/platform.rs | 55 + sandbox/sandbox-microsandbox/src/state.rs | 474 + sandbox/sandbox-microsandbox/src/volumes.rs | 55 + .../tests/architecture.rs | 13 + sandbox/sandbox-microsandbox/tests/backend.rs | 235 + .../tests/fixtures/runtime-image/Dockerfile | 8 + .../tests/network_runtime.rs | 314 + sandbox/sandbox-microsandbox/tests/runtime.rs | 735 ++ sandbox/sandbox/Cargo.toml | 25 + sandbox/sandbox/examples/worktree/Cargo.toml | 19 + sandbox/sandbox/examples/worktree/Dockerfile | 176 + sandbox/sandbox/examples/worktree/README.md | 25 + sandbox/sandbox/examples/worktree/src/main.rs | 297 + .../sandbox/examples/worktree/src/progress.rs | 320 + .../sandbox/examples/worktree/tmpfiles.conf | 2 + sandbox/sandbox/src/backend.rs | 293 + sandbox/sandbox/src/execution.rs | 270 + sandbox/sandbox/src/feature.rs | 170 + sandbox/sandbox/src/file_transfer.rs | 61 + sandbox/sandbox/src/image.rs | 365 + sandbox/sandbox/src/init.rs | 14 + sandbox/sandbox/src/lib.rs | 40 + sandbox/sandbox/src/memory.rs | 1108 +++ sandbox/sandbox/src/mount.rs | 78 + sandbox/sandbox/src/name.rs | 197 + sandbox/sandbox/src/network.rs | 1018 +++ sandbox/sandbox/src/path.rs | 22 + sandbox/sandbox/src/platform.rs | 120 + sandbox/sandbox/src/progress/fold.rs | 913 ++ sandbox/sandbox/src/progress/mod.rs | 651 ++ sandbox/sandbox/src/provider.rs | 16 + sandbox/sandbox/src/resource.rs | 461 + sandbox/sandbox/src/root_filesystem.rs | 93 + sandbox/sandbox/src/secret_store.rs | 56 + sandbox/sandbox/src/service.rs | 1278 +++ sandbox/sandbox/src/terminal.rs | 218 + sandbox/sandbox/src/volume.rs | 135 + sandbox/sandbox/tests/architecture.rs | 13 + sandbox/sandbox/tests/image.rs | 137 + sandbox/sandbox/tests/name.rs | 70 + sandbox/sandbox/tests/network.rs | 269 + sandbox/sandbox/tests/platform.rs | 28 + sandbox/sandbox/tests/secret_store.rs | 54 + sandbox/sandbox/tests/service.rs | 925 ++ 222 files changed, 81368 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .editorconfig create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 agentctl/AGENTS.md create mode 100644 agentctl/CHANGELOG.md create mode 100644 agentctl/Cargo.toml create mode 100644 agentctl/HARNESSES.md create mode 100644 agentctl/README.md create mode 100755 agentctl/changelog.sh create mode 100755 agentctl/changelog_test.sh create mode 100644 agentctl/examples/minimal/.dockerignore create mode 100644 agentctl/examples/minimal/Dockerfile create mode 100644 agentctl/examples/minimal/README.md create mode 100644 agentctl/examples/minimal/agent.yaml create mode 100644 agentctl/examples/minimal/claude-state.json create mode 100644 agentctl/examples/minimal/home/.gitkeep create mode 100644 agentctl/examples/minimal/instructions.md create mode 100644 agentctl/examples/self-dev/.dockerignore create mode 100644 agentctl/examples/self-dev/.env.sample create mode 100644 agentctl/examples/self-dev/.gitignore create mode 100644 agentctl/examples/self-dev/Dockerfile create mode 100644 agentctl/examples/self-dev/README.md create mode 100644 agentctl/examples/self-dev/agent.nested.yaml create mode 100644 agentctl/examples/self-dev/agent.worktree.yaml create mode 100644 agentctl/examples/self-dev/agent.yaml create mode 100644 agentctl/examples/self-dev/home/.claude/.claude.json create mode 100644 agentctl/examples/self-dev/home/.gitkeep create mode 100644 agentctl/examples/self-dev/instructions.md create mode 100644 agentctl/examples/self-dev/nvim-sysinit.vim create mode 100644 agentctl/examples/self-dev/skills/pr-evidence/SKILL.md create mode 100644 agentctl/examples/self-dev/tmpfiles.conf create mode 100644 agentctl/examples/self-dev/workspace-init.service create mode 100644 agentctl/examples/self-dev/workspace-init.sh create mode 100644 agentctl/high-level.excalidraw.svg create mode 100644 agentctl/install.ps1 create mode 100755 agentctl/install.sh create mode 100755 agentctl/installation-test.sh create mode 100644 agentctl/make-user-install.ps1 create mode 100755 agentctl/package.sh create mode 100644 agentctl/src/authorization/agent_policy.rs create mode 100644 agentctl/src/authorization/mod.rs create mode 100644 agentctl/src/bin/agentctl/format.rs create mode 100644 agentctl/src/bin/agentctl/launch.rs create mode 100644 agentctl/src/bin/agentctl/main.rs create mode 100644 agentctl/src/bin/agentctl/progress.rs create mode 100644 agentctl/src/bin/agentctl/self_update.rs create mode 100644 agentctl/src/bin/agentctl/tui/app.rs create mode 100644 agentctl/src/bin/agentctl/tui/mod.rs create mode 100644 agentctl/src/bin/agentctl/tui/open.rs create mode 100644 agentctl/src/bin/agentctl/tui/provisioning.rs create mode 100644 agentctl/src/bin/agentctl/tui/terminal.rs create mode 100644 agentctl/src/bin/agentctl/tui/view.rs create mode 100644 agentctl/src/bin/agentd.rs create mode 100644 agentctl/src/control_api/client.rs create mode 100644 agentctl/src/control_api/mod.rs create mode 100644 agentctl/src/control_api/protocol.rs create mode 100644 agentctl/src/control_api/server.rs create mode 100644 agentctl/src/control_api/socket.rs create mode 100644 agentctl/src/control_plane/controller.rs create mode 100644 agentctl/src/control_plane/convergence.rs create mode 100644 agentctl/src/control_plane/memory.rs create mode 100644 agentctl/src/control_plane/mod.rs create mode 100644 agentctl/src/control_plane/reconciler.rs create mode 100644 agentctl/src/control_plane/resource.rs create mode 100644 agentctl/src/control_plane/service.rs create mode 100644 agentctl/src/controller.rs create mode 100644 agentctl/src/environment.rs create mode 100644 agentctl/src/harness/claude_code/authentication.rs create mode 100644 agentctl/src/harness/claude_code/bootstrap/linux.rs create mode 100644 agentctl/src/harness/claude_code/bootstrap/mod.rs create mode 100644 agentctl/src/harness/claude_code/hooks.rs create mode 100644 agentctl/src/harness/claude_code/mod.rs create mode 100644 agentctl/src/harness/claude_code/status_line.mjs create mode 100644 agentctl/src/harness/claude_code/status_line.rs create mode 100644 agentctl/src/harness/claude_code/transcript.rs create mode 100644 agentctl/src/harness/codex/authentication.rs create mode 100644 agentctl/src/harness/codex/bootstrap/linux.rs create mode 100644 agentctl/src/harness/codex/bootstrap/mod.rs create mode 100644 agentctl/src/harness/codex/hooks.rs create mode 100644 agentctl/src/harness/codex/mod.rs create mode 100644 agentctl/src/harness/codex/transcript.rs create mode 100644 agentctl/src/harness/hook_script.rs create mode 100644 agentctl/src/harness/mod.rs create mode 100644 agentctl/src/harness/skills.rs create mode 100644 agentctl/src/lib.rs create mode 100644 agentctl/src/local/home.rs create mode 100644 agentctl/src/local/mod.rs create mode 100644 agentctl/src/local/process.rs create mode 100644 agentctl/src/manifest.rs create mode 100644 agentctl/src/persistence/agents.rs create mode 100644 agentctl/src/persistence/mod.rs create mode 100644 agentctl/src/persistence/schema.rs create mode 100644 agentctl/src/persistence/secrets.rs create mode 100644 agentctl/src/persistence/sessions.rs create mode 100644 agentctl/src/platform_api/mod.rs create mode 100644 agentctl/src/progress/mod.rs create mode 100644 agentctl/src/progress/observer.rs create mode 100644 agentctl/src/progress/state.rs create mode 100644 agentctl/src/resources.rs create mode 100644 agentctl/src/sandbox/execution.rs create mode 100644 agentctl/src/sandbox/forward.rs create mode 100644 agentctl/src/sandbox/microsandbox/execution.rs create mode 100644 agentctl/src/sandbox/microsandbox/forward.rs create mode 100644 agentctl/src/sandbox/microsandbox/mod.rs create mode 100644 agentctl/src/sandbox/microsandbox/preparation.rs create mode 100644 agentctl/src/sandbox/microsandbox/terminal.rs create mode 100644 agentctl/src/sandbox/mod.rs create mode 100644 agentctl/src/sandbox/platform/files.rs create mode 100644 agentctl/src/sandbox/platform/linux.rs create mode 100644 agentctl/src/sandbox/platform/linux_ssh.rs create mode 100644 agentctl/src/sandbox/platform/linux_vnc.rs create mode 100644 agentctl/src/sandbox/platform/mod.rs create mode 100644 agentctl/src/sandbox/responsiveness.rs create mode 100644 agentctl/src/sessions/activity.rs create mode 100644 agentctl/src/sessions/controller.rs create mode 100644 agentctl/src/sessions/mod.rs create mode 100644 agentctl/src/sessions/reconciler.rs create mode 100644 agentctl/src/sessions/runtime/deliver.sh create mode 100644 agentctl/src/sessions/runtime/mod.rs create mode 100644 agentctl/src/sessions/runtime/observe.sh create mode 100644 agentctl/src/sessions/runtime/stop.sh create mode 100644 agentctl/src/sessions/runtime/tmux.rs create mode 100644 agentctl/src/sessions/sandboxes.rs create mode 100644 agentctl/src/sessions/service.rs create mode 100644 agentctl/src/sessions/transcript.rs create mode 100644 agentctl/src/ssh/client_config.rs create mode 100644 agentctl/src/ssh/keys.rs create mode 100644 agentctl/src/ssh/memory.rs create mode 100644 agentctl/src/ssh/mod.rs create mode 100644 agentctl/src/upgrade.rs create mode 100644 agentctl/src/vnc/mod.rs create mode 100644 agentctl/tests/agent_manifests.rs create mode 100644 agentctl/tests/architecture.rs create mode 100644 agentctl/tests/control_api.rs create mode 100644 agentctl/tests/control_plane.rs create mode 100644 agentctl/tests/database.rs create mode 100644 agentctl/tests/fixtures/claude-code-transcript.jsonl create mode 100644 agentctl/tests/fixtures/codex-rollout.jsonl create mode 100644 agentctl/tests/home.rs create mode 100644 agentctl/tests/manifest.rs create mode 100644 agentctl/tests/microsandbox_attach.rs create mode 100644 agentctl/tests/platform_api.rs create mode 100644 agentctl/tests/platform_linux.rs create mode 100644 agentctl/tests/policy.rs create mode 100644 agentctl/tests/session_controller.rs create mode 100644 agentctl/tests/sessions.rs create mode 100644 agentctl/tests/ssh_access.rs create mode 100644 agentctl/tests/support/mod.rs create mode 100644 agentctl/tests/tmux_delivery.mjs create mode 100644 agentctl/tests/tmux_scrollback.mjs create mode 100644 agentctl/tests/tmux_suspend.mjs create mode 100644 agentctl/tests/variants.rs create mode 100644 agentctl/tests/vnc_access.rs create mode 100644 clippy.toml create mode 100644 deny.toml create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 sandbox/MICROSANDBOX.md create mode 100644 sandbox/sandbox-authorization/Cargo.toml create mode 100644 sandbox/sandbox-authorization/src/lib.rs create mode 100644 sandbox/sandbox-authorization/tests/architecture.rs create mode 100644 sandbox/sandbox-authorization/tests/policy.rs create mode 100644 sandbox/sandbox-microsandbox/Cargo.toml create mode 100644 sandbox/sandbox-microsandbox/src/backend.rs create mode 100644 sandbox/sandbox-microsandbox/src/client.rs create mode 100644 sandbox/sandbox-microsandbox/src/encoding.rs create mode 100644 sandbox/sandbox-microsandbox/src/error.rs create mode 100644 sandbox/sandbox-microsandbox/src/execution.rs create mode 100644 sandbox/sandbox-microsandbox/src/files.rs create mode 100644 sandbox/sandbox-microsandbox/src/guest_tcp.rs create mode 100644 sandbox/sandbox-microsandbox/src/heartbeat.rs create mode 100644 sandbox/sandbox-microsandbox/src/image.rs create mode 100644 sandbox/sandbox-microsandbox/src/image_cache.rs create mode 100644 sandbox/sandbox-microsandbox/src/lib.rs create mode 100644 sandbox/sandbox-microsandbox/src/network_backend.rs create mode 100644 sandbox/sandbox-microsandbox/src/network_endpoint.rs create mode 100644 sandbox/sandbox-microsandbox/src/platform.rs create mode 100644 sandbox/sandbox-microsandbox/src/state.rs create mode 100644 sandbox/sandbox-microsandbox/src/volumes.rs create mode 100644 sandbox/sandbox-microsandbox/tests/architecture.rs create mode 100644 sandbox/sandbox-microsandbox/tests/backend.rs create mode 100644 sandbox/sandbox-microsandbox/tests/fixtures/runtime-image/Dockerfile create mode 100644 sandbox/sandbox-microsandbox/tests/network_runtime.rs create mode 100644 sandbox/sandbox-microsandbox/tests/runtime.rs create mode 100644 sandbox/sandbox/Cargo.toml create mode 100644 sandbox/sandbox/examples/worktree/Cargo.toml create mode 100644 sandbox/sandbox/examples/worktree/Dockerfile create mode 100644 sandbox/sandbox/examples/worktree/README.md create mode 100644 sandbox/sandbox/examples/worktree/src/main.rs create mode 100644 sandbox/sandbox/examples/worktree/src/progress.rs create mode 100644 sandbox/sandbox/examples/worktree/tmpfiles.conf create mode 100644 sandbox/sandbox/src/backend.rs create mode 100644 sandbox/sandbox/src/execution.rs create mode 100644 sandbox/sandbox/src/feature.rs create mode 100644 sandbox/sandbox/src/file_transfer.rs create mode 100644 sandbox/sandbox/src/image.rs create mode 100644 sandbox/sandbox/src/init.rs create mode 100644 sandbox/sandbox/src/lib.rs create mode 100644 sandbox/sandbox/src/memory.rs create mode 100644 sandbox/sandbox/src/mount.rs create mode 100644 sandbox/sandbox/src/name.rs create mode 100644 sandbox/sandbox/src/network.rs create mode 100644 sandbox/sandbox/src/path.rs create mode 100644 sandbox/sandbox/src/platform.rs create mode 100644 sandbox/sandbox/src/progress/fold.rs create mode 100644 sandbox/sandbox/src/progress/mod.rs create mode 100644 sandbox/sandbox/src/provider.rs create mode 100644 sandbox/sandbox/src/resource.rs create mode 100644 sandbox/sandbox/src/root_filesystem.rs create mode 100644 sandbox/sandbox/src/secret_store.rs create mode 100644 sandbox/sandbox/src/service.rs create mode 100644 sandbox/sandbox/src/terminal.rs create mode 100644 sandbox/sandbox/src/volume.rs create mode 100644 sandbox/sandbox/tests/architecture.rs create mode 100644 sandbox/sandbox/tests/image.rs create mode 100644 sandbox/sandbox/tests/name.rs create mode 100644 sandbox/sandbox/tests/network.rs create mode 100644 sandbox/sandbox/tests/platform.rs create mode 100644 sandbox/sandbox/tests/secret_store.rs create mode 100644 sandbox/sandbox/tests/service.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bff29e6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg", "tokio_unstable"] diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d9e5871 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.rs] +indent_style = space +indent_size = 4 +tab_width = 4 + +[Makefile] +indent_style = tab diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..410173d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7733 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agent" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "clap", + "crossterm", + "flate2", + "futures-util", + "ignore", + "mimalloc", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "sandbox", + "sandbox-authorization", + "sandbox-microsandbox", + "semver", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2 0.11.0", + "ssh-key", + "tar", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", + "win_uds", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "arrow" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" + +[[package]] +name = "arrow-select" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bollard" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.1", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.10.2", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c97450e79c7c565302dd92e86b08823b47550fcb4fc5ce910194d1b087a1a3" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + +[[package]] +name = "capng" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a26766f93f07f7e8b8309ed2824fa2a68f5d12d219de855e24688e9fbe89e85" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "caps" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0" +dependencies = [ + "libc", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "subtle", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "github-runner-coordinator" +version = "0.1.0" +dependencies = [ + "clap", + "futures-util", + "jsonwebtoken", + "reqwest", + "sandbox", + "sandbox-microsandbox", + "serde", + "tempfile", + "tokio", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.2", + "rustls", + "thiserror 2.0.20", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "httlib-hpack" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40cf60e5e8567c6ff914a590f1452821de9377a560338a562e570a6ff052aae3" +dependencies = [ + "httlib-huffman", +] + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-auth" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +dependencies = [ + "memchr", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kvm-bindings" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11cf0ca75d59e9d298647c59cf6c5286fa048120caa77972a7a504a0824d234f" +dependencies = [ + "serde", + "vmm-sys-util", + "zerocopy", +] + +[[package]] +name = "kvm-ioctls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ac372c120eb893b086d1a12027669cf2b478d1f71204021ffa7adf57948d63" +dependencies = [ + "bitflags 2.13.1", + "kvm-bindings", + "libc", + "vmm-sys-util", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "serde", + "winapi", +] + +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "microsandbox" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "astral-tokio-tar", + "async-compression", + "base64 0.23.1", + "blake3", + "bytes", + "cap-primitives", + "cap-std", + "chrono", + "crossterm", + "docker_credential", + "flate2", + "futures", + "hex", + "libc", + "microsandbox-agent-client", + "microsandbox-control-client", + "microsandbox-db", + "microsandbox-filesystem", + "microsandbox-image", + "microsandbox-metrics", + "microsandbox-migration", + "microsandbox-network", + "microsandbox-protocol", + "microsandbox-protocol-client", + "microsandbox-runtime", + "microsandbox-types", + "microsandbox-utils", + "nix 0.31.3", + "notify", + "object", + "rand 0.10.2", + "rayon", + "reqwest", + "scopeguard", + "sea-orm", + "semver", + "serde", + "serde_ignored", + "serde_json", + "sha2 0.11.0", + "sqlx", + "tar", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "typed-path", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "microsandbox-agent-client" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "bytes", + "ciborium", + "libc", + "memmap2", + "microsandbox-protocol", + "microsandbox-protocol-client", + "nix 0.31.3", + "serde", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "microsandbox-control-client" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "microsandbox-protocol", + "microsandbox-protocol-client", + "microsandbox-utils", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "zeroize", +] + +[[package]] +name = "microsandbox-db" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "async-trait", + "microsandbox-types", + "sea-orm", + "semver", + "serde_json", + "sqlx", + "tokio", + "tracing", +] + +[[package]] +name = "microsandbox-filesystem" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "bincode", + "hex", + "libc", + "microsandbox-utils", + "msb_krun", + "scopeguard", + "serde", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "microsandbox-image" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "astral-tokio-tar", + "async-compression", + "blake3", + "chrono", + "futures", + "hex", + "libc", + "microsandbox-types", + "microsandbox-utils", + "msb-imago", + "oci-client", + "oci-spec", + "rand 0.10.2", + "rustls-pki-types", + "scopeguard", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "thiserror 2.0.20", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "microsandbox-metrics" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "chrono", + "libc", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "microsandbox-migration" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "microsandbox-db", + "sea-orm-migration", + "serde_json", +] + +[[package]] +name = "microsandbox-network" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "base64 0.22.1", + "bytes", + "crossbeam-queue", + "futures", + "hickory-net", + "hickory-proto", + "httlib-hpack", + "httparse", + "ipnetwork", + "libc", + "lru", + "microsandbox-protocol", + "microsandbox-types", + "microsandbox-utils", + "msb_krun", + "msb_krun_utils", + "parking_lot", + "pem", + "percent-encoding", + "rcgen", + "resolv-conf", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "smoltcp", + "socket2", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-rustls", + "tokio-socks", + "tracing", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "microsandbox-protocol" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "bytes", + "chrono", + "ciborium", + "microsandbox-types", + "serde", + "serde_bytes", + "strum 0.28.0", + "thiserror 2.0.20", + "tokio", + "zeroize", +] + +[[package]] +name = "microsandbox-protocol-client" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "microsandbox-protocol", + "serde", + "thiserror 2.0.20", + "tokio", + "zeroize", +] + +[[package]] +name = "microsandbox-runtime" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "base64 0.23.1", + "chrono", + "ciborium-ll", + "hex", + "libc", + "microsandbox-db", + "microsandbox-image", + "microsandbox-network", + "microsandbox-protocol", + "microsandbox-types", + "microsandbox-utils", + "msb_krun", + "nix 0.31.3", + "rand 0.10.2", + "sea-orm", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "microsandbox-types" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "chrono", + "hex", + "ipnetwork", + "microsandbox-types-macros", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "typed-path", + "zeroize", +] + +[[package]] +name = "microsandbox-types-macros" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "microsandbox-utils" +version = "0.7.4-digdir.2" +source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +dependencies = [ + "dirs", + "libc", + "reflink-copy", + "windows-sys 0.61.2", +] + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb-imago" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6feac477a4387d62fde89aad52900fa0ea7ccb4fec686f50b93a575cbf0ea5" +dependencies = [ + "async-trait", + "cfg-if", + "libc", + "miniz_oxide", + "msb-vm-memory", + "nix 0.30.1", + "page_size", + "rustc_version", + "tokio", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb-vm-memory" +version = "0.18.0-msb.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6646492f6bc51c4126c73854d377ba8ce4cebb1443cc9efe2517ab2215d87b49" +dependencies = [ + "libc", + "thiserror 2.0.20", + "winapi", +] + +[[package]] +name = "msb_krun" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0944407a6ae125935e64dcf9be666e56d2cca0907b9e34be6648784db453490c" +dependencies = [ + "crossbeam-channel", + "kvm-bindings", + "kvm-ioctls", + "libc", + "libloading", + "log", + "msb-vm-memory", + "msb_krun_devices", + "msb_krun_hvf", + "msb_krun_polly", + "msb_krun_utils", + "msb_krun_vmm", +] + +[[package]] +name = "msb_krun_arch" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0ef48fe491098f6ffefe0f00a8813ba88b9c79f339b85f5acd597e7cad95e9" +dependencies = [ + "kvm-bindings", + "kvm-ioctls", + "libc", + "msb-vm-memory", + "msb_krun_arch_gen", + "msb_krun_smbios", + "msb_krun_utils", +] + +[[package]] +name = "msb_krun_arch_gen" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c702db3ef885cba2c206e367889fdea4db6c7990b5e1cface8dfcab297a3262c" + +[[package]] +name = "msb_krun_cpuid" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defe5de4f6aeeeb521847db53a36f6d0e931e9a2f5eb7f7851610cafb707115b" +dependencies = [ + "kvm-bindings", + "kvm-ioctls", + "vmm-sys-util", +] + +[[package]] +name = "msb_krun_devices" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221db60cf824f4054eb84a1dd594081fe33d82e69ffcf752fbe1927aa99abd17" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "capng", + "caps", + "crossbeam-channel", + "kvm-bindings", + "kvm-ioctls", + "libc", + "libloading", + "log", + "lru", + "msb-imago", + "msb-vm-memory", + "msb_krun_arch", + "msb_krun_hvf", + "msb_krun_polly", + "msb_krun_utils", + "nix 0.30.1", + "rand 0.9.5", + "serde", + "tokio", + "virtio-bindings", + "vm-fdt", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb_krun_hvf" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb8f89a777a0ef25a2124132ef0a056c600930a5baa103694b65245f34b3e8" +dependencies = [ + "crossbeam-channel", + "libloading", + "log", + "msb_krun_arch", + "serde", +] + +[[package]] +name = "msb_krun_kernel" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40dd7265831b32e0addac577b8228a2661eeacaf81041ef8ed6e3a8d6482f11" +dependencies = [ + "msb-vm-memory", + "msb_krun_utils", +] + +[[package]] +name = "msb_krun_polly" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "855a130299a20fba964686040286e2ff6495852bc72f8d067730f33c842034e9" +dependencies = [ + "libc", + "msb_krun_utils", +] + +[[package]] +name = "msb_krun_smbios" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e3cf0714e1a1d36703b5e8597f67def7925105437cda14fc95da275e83445" +dependencies = [ + "msb-vm-memory", +] + +[[package]] +name = "msb_krun_utils" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5691e655d7ee9394d8c741dceee8e0422ce27c526f792e91d4cb0c5f60c951" +dependencies = [ + "bitflags 1.3.2", + "crossbeam-channel", + "kvm-bindings", + "libc", + "log", + "nix 0.30.1", + "vmm-sys-util", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb_krun_vmm" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e512641c4b2300886a0ee77d0d728a1080cf11a924a4c76b0c11f6817da014" +dependencies = [ + "bincode", + "bzip2", + "crossbeam-channel", + "flate2", + "kvm-bindings", + "kvm-ioctls", + "libc", + "libloading", + "log", + "msb-vm-memory", + "msb_krun_arch", + "msb_krun_arch_gen", + "msb_krun_cpuid", + "msb_krun_devices", + "msb_krun_hvf", + "msb_krun_kernel", + "msb_krun_polly", + "msb_krun_utils", + "nix 0.30.1", + "serde", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "memchr", +] + +[[package]] +name = "oci-client" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc7848d781053285280fcd35aab24c9c0a677e26db4aa4209bd5db57467de0f" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "futures-util", + "hex", + "http", + "http-auth", + "oci-spec", + "olpc-cjson", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "tracing", + "unicase", +] + +[[package]] +name = "oci-spec" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6f876ad774d6a676f7e968f5c3edacc32f90e65fe680a8b686235396556fb" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.20", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "olpc-cjson" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "pgvector" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" +dependencies = [ + "serde", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.7", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sandbox" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-core", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-util", + "uuid", + "zeroize", +] + +[[package]] +name = "sandbox-authorization" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "sandbox-microsandbox" +version = "0.1.0" +dependencies = [ + "bollard", + "bytes", + "futures-util", + "ignore", + "microsandbox", + "microsandbox-image", + "microsandbox-network", + "sandbox", + "sandbox-authorization", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "sandbox-worktree" +version = "0.1.0" +dependencies = [ + "clap", + "futures-util", + "sandbox", + "sandbox-microsandbox", + "sha2 0.11.0", + "tokio", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sea-orm" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334e83ced3ae3ee44db0f84d1fcf8d2087a1ad9bb9036f00f9f6067156ea197" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive-where", + "derive_more", + "futures-util", + "itertools", + "log", + "mac_address", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-arrow", + "sea-orm-macros", + "sea-query", + "sea-query-sqlx", + "sea-schema", + "serde", + "serde_json", + "sqlx", + "sqlx-core", + "strum 0.28.0", + "thiserror 2.0.20", + "time", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "sea-orm-arrow" +version = "2.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c800d9db902534d7d01728faf98e33d13c1d57bb8c57d8e4c518309172bddda" +dependencies = [ + "arrow", + "sea-query", + "thiserror 2.0.20", +] + +[[package]] +name = "sea-orm-cli" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a53505884d7c907bcf4f7b4ddb1b29425e62fef8b98aea9c99e17781cceb798" +dependencies = [ + "chrono", + "glob", + "indoc", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4039a86f9acc4d3b52747508b347dddc6fd725bbc429902ebeb6d26225fc2528" +dependencies = [ + "heck 0.5.0", + "itertools", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd09adbef87100d07131a60a8c5508b53d0cf2136521f654aae47af5e6a097fe" +dependencies = [ + "async-trait", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "chrono", + "ordered-float", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" +dependencies = [ + "darling 0.20.11", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query", + "sqlx", +] + +[[package]] +name = "sea-schema" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" +dependencies = [ + "async-trait", + "sea-query", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smoltcp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt", + "heapless", + "managed", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rust_decimal", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "time", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtio-bindings" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091f1f09cfbf2a78563b562e7a949465cce1aef63b6065645188d995162f8868" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "vm-fdt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e21282841a059bb62627ce8441c491f09603622cd5a21c43bfedc85a2952f23" + +[[package]] +name = "vmm-sys-util" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506c62fdf617a5176827c2f9afbcf1be155b03a9b4bf9617a60dbc07e3a1642f" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "win_uds" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff1222d47fad8fc0ce90a9d259c4b4b52995beeea984483323eacf56e113a074" +dependencies = [ + "async-io", + "futures-io", + "socket2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3ee0fac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,76 @@ +[workspace] +members = [ + "src/experimental/agent", + "src/experimental/sandbox-authorization", + "src/experimental/sandbox", + "src/experimental/sandbox/examples/worktree", + "src/experimental/sandbox-microsandbox", + "src/ci/github-runner/coordinator", +] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "MIT" +rust-version = "1.97.1" +version = "0.1.0" + +[workspace.dependencies] +base64 = "0.22.1" +bollard = { version = "0.21.1", features = ["buildkit", "time"] } +bytes = "1.12.1" +clap = { version = "4.6.6", features = ["derive"] } +crossterm = { version = "0.29.0", features = ["event-stream"] } +futures-core = "0.3.34" +futures-util = "0.3.34" +flate2 = "1.1.9" +ignore = "0.4.33" +jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } +# Microsandbox fork commit from `main-digdir`, tagged `digdir-v0.7.4-digdir.2`. +# Update all three revisions together with the runtime bundle digests. +microsandbox = { git = "https://github.com/martinothamar/microsandbox.git", rev = "c3a16753edba0ad4b21844ccd50ce7b615e43f84", default-features = false, features = ["local", "net"] } +microsandbox-image = { git = "https://github.com/martinothamar/microsandbox.git", rev = "c3a16753edba0ad4b21844ccd50ce7b615e43f84", package = "microsandbox-image" } +microsandbox-network = { git = "https://github.com/martinothamar/microsandbox.git", rev = "c3a16753edba0ad4b21844ccd50ce7b615e43f84", package = "microsandbox-network" } +mimalloc = "0.1.52" +ratatui = "0.30.2" +reqwest = { version = "0.13.4", features = ["json"] } +rand_core = { version = "0.6.4", features = ["getrandom"] } +rusqlite = "0.39.0" +sandbox = { path = "src/experimental/sandbox" } +sandbox-microsandbox = { path = "src/experimental/sandbox-microsandbox" } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serde_yaml_ng = "0.10.0" +semver = "1.0.27" +sha2 = "0.11.0" +ssh-key = { version = "0.6.7", default-features = false, features = ["ed25519", "rand_core", "std"] } +tar = "0.4.46" +tempfile = "3.27.0" +thiserror = "2.0.20" +time = { version = "0.3.55", features = ["formatting", "parsing", "serde"] } +tokio = { version = "1.53.1", features = ["fs", "io-util", "macros", "net", "process", "rt", "signal", "sync", "time"] } +tokio-util = { version = "0.7.19", features = ["io"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +uuid = { version = "1.24.1", features = ["serde", "v4"] } +zeroize = "1.9.0" + +[workspace.lints.rust] +unsafe_code = "deny" +warnings = "deny" +unreachable_pub = "deny" +unexpected_cfgs = { level = "deny", check-cfg = ["cfg(fuzzing)"] } + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +cargo = { level = "deny", priority = -1 } +cargo_common_metadata = "allow" +expect_used = "deny" +future_not_send = "allow" +iter_with_drain = "allow" +multiple_crate_versions = "allow" +panic = "deny" +redundant_pub_crate = "allow" +unwrap_used = "deny" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..38b2824 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +.PHONY: help build user-install clean fmt lint lint-fix test test-e2e check check-platforms deps deps-check \ + changelog-validate changelog-test +.DEFAULT_GOAL := help + +USER_INSTALL_VERSION := v0.0.1-dev.$(shell date -u +%Y%m%d%H%M%S) +USER_INSTALL_ARCHIVE := $(abspath build/user-install/agent-$(USER_INSTALL_VERSION).tar.gz) +RELEASE_BIN_DIR := $(abspath $(or $(CARGO_TARGET_DIR),../../target)/release) + +EXPERIMENTAL_PACKAGES := \ + -p agent \ + -p sandbox-authorization \ + -p sandbox \ + -p sandbox-worktree \ + -p sandbox-microsandbox +PORTABLE_PACKAGES := \ + -p sandbox \ + -p sandbox-authorization \ + -p agent \ + -p sandbox-microsandbox + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' + +build: ## Build all experimental Rust crates + @echo "Building experimental Rust crates..." + @cargo build $(EXPERIMENTAL_PACKAGES) --all-targets --locked + @echo "✓ Build successful" + +user-install: ## Build, package and install agentctl and agentd for the current user +ifeq ($(OS),Windows_NT) + @powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(abspath make-user-install.ps1)" +else + @echo "Building experimental Agent $(USER_INSTALL_VERSION)..." + @AGENT_VERSION=$(USER_INSTALL_VERSION) cargo build --release --locked -p agent --bins + @./agent/package.sh "$(USER_INSTALL_ARCHIVE)" "$(RELEASE_BIN_DIR)" + @AGENT_VERSION=$(USER_INSTALL_VERSION) AGENT_LOCAL_ARCHIVE="$(USER_INSTALL_ARCHIVE)" ./agent/install.sh +endif + +clean: ## Clean experimental build artifacts + @echo "Cleaning experimental build artifacts..." + @cargo clean $(EXPERIMENTAL_PACKAGES) + @echo "✓ Cleaned" + +fmt: ## Format Rust code + @echo "Formatting experimental Rust code..." + @cargo fmt $(EXPERIMENTAL_PACKAGES) + @echo "✓ Code formatted" + +lint: ## Run strict Clippy analysis + @echo "Linting experimental Rust crates..." + @cargo clippy $(EXPERIMENTAL_PACKAGES) --all-targets --all-features --locked + @echo "✓ Lint passed" + +lint-fix: ## Apply safe Clippy fixes + @echo "Applying Clippy fixes to experimental Rust crates..." + @cargo clippy $(EXPERIMENTAL_PACKAGES) --all-targets --all-features --fix --allow-dirty --locked + @echo "✓ Lint fixes applied" + +test: changelog-test ## Run all tests + @echo "Testing experimental Rust crates..." + @cargo test $(EXPERIMENTAL_PACKAGES) --all-targets --locked + @echo "✓ Tests passed" + +changelog-validate: ## Check that CHANGELOG.md has the expected structure + @./changelog.sh validate + +changelog-test: ## Run the changelog.sh tests + @./changelog_test.sh + +test-e2e: ## Run integration tests that require Docker, Internet access, and KVM + @echo "Running experimental end-to-end tests..." + @cargo test -p sandbox-microsandbox --tests --locked -- --ignored + @echo "✓ End-to-end tests passed" + +deps: ## Print the workspace dependency graph + @cargo tree $(EXPERIMENTAL_PACKAGES) --locked + +deps-check: ## Check for unused direct dependencies + @cargo machete --with-metadata . + @git diff --exit-code -- ':(top)Cargo.lock' + +check: fmt lint build test test-e2e deps-check changelog-validate ## Run all local checks + @echo "✓ All checks passed" + +check-platforms: ## Check portable code on the current native host (CI covers every supported host) + @cargo clippy $(PORTABLE_PACKAGES) --all-targets --all-features --locked diff --git a/agentctl/AGENTS.md b/agentctl/AGENTS.md new file mode 100644 index 0000000..f2b953d --- /dev/null +++ b/agentctl/AGENTS.md @@ -0,0 +1,78 @@ +# AGENTS.md + +This area contains the experimental agent platform described in `README.md`. + +## Architecture + +- `sandbox` is the generic Rust SDK for Sandbox lifecycle, features, images, storage, runtime file + transfer, execution, Network Backends and secret storage. It must not depend on Agent + automation or a concrete Sandbox implementation. +- `sandbox-microsandbox` contains the Microsandbox Backend and network enforcement implementation. It depends on + `sandbox`, never the reverse. +- `sandbox-authorization` contains context-aware authorization contracts and policy-engine interfaces for operations + originating inside Agents and Sandboxes. The name communicates its scope; it must not depend on enforcement + points, the Agent Control Plane or the Sandbox SDK. +- `agent` owns Agent resources, the host Control Plane, Agent Control API, host-side Harness Adapters, + `agentd` and `agentctl`. It builds on the lower crates above. The M0 agent has no sandbox-resident + Agent Runtime; host-side management drives tmux and the harness through Sandbox executions. +- The backend owns Sandbox lifecycle, execution, runtime file transfer, storage and mount behavior; do not split + those into speculative replaceable component traits. +- Sandbox progress is reported through phase spans and step tokens. A step belongs to the phase in progress and is + either unmeasured or measures one quantity in the unit it started with. Every phase and step ends exactly once. + Consumers read the folded `Progress` and a `ProgressCursor` instead of interpreting events, and the Agent layer + delivers progress to clients as state behind a revision, never as an event stream. +- A Provider pairs a Sandbox Backend with an Image Backend over one image materialization domain. Both expose + discovery-first, per-Platform capabilities; Backend trait operations are required and have no default behavior. +- A Network Backend is independently selectable from a Sandbox Backend. They negotiate an owned Network Endpoint: + raw Ethernet/IP packets, intercepted TCP streams and UDP datagrams, or a jointly implemented versioned control + protocol. The Network Backend consumes the endpoint and owns host authorization and endpoint driving; a trusted + Sandbox runtime may perform protocol-aware enforcement only through the negotiated control protocol. Packet, + datagram and control-message data planes use bounded batch polling rather than per-item futures. Packet endpoints + carry immutable interface configuration chosen and persisted by the Sandbox Backend. Microsandbox implements both + traits separately even when both dispatch to the same runtime. Concrete Backend SDKs own platform-specific local + IPC binding, framing and cleanup; the generic endpoint adapter handles complete bounded messages. +- The Network Backend identity and selected endpoint contract are recorded when a Sandbox is created and are + immutable for its lifetime. Network start, stop and reconnection follow Sandbox lifecycle without changing that + attachment. A Sandbox Backend must block traffic not represented by the selected endpoint; it must never become an + unobserved egress path. +- Do not add a `NetworkPolicy` to the Sandbox contract or manifest. Network implementations use + `sandbox-authorization` for live Sandbox-originated decisions. +- Do not use authorization intended for Sandbox-originated operations to authorize platform users calling the + Agent Control API. That is a separate concern. +- The host-to-Agent-Runtime connection mechanism is not selected. Do not add a generic Sandbox control channel + until a concrete Agent Runtime integration demonstrates the required contract. +- Agent-stack secrets remain on the trusted host and use mediated access. The independent Sandbox SDK may expose + caller-selected bind mounts for other products and trusted local workflows; it does not impose Agent policy. + +Use crate boundaries for architectural separation and modules for internal organization. Add another crate +only when a component needs an independent dependency, versioning or distribution boundary. Do not add +`sandboxd` or `sandboxctl` unless the generic Sandbox SDK gains an independent process boundary. + +Use Tokio's `LocalRuntime` for asynchronous work. Keep control-plane state single-threaded and use `Rc`, +`Cell`, or `RefCell` as appropriate. Do not use `Arc` or `Mutex`. + +## Development + +Run `make help` in this directory to list the available development targets. + +Follow [MICROSANDBOX.md](MICROSANDBOX.md) when synchronizing the Microsandbox or libkrunfw forks, +publishing a downstream runtime or updating this workspace's source and artifact pins. + +When adding or updating a harness installation or adapter, follow [HARNESSES.md](agent/HARNESSES.md). + +## Changelog and releases + +[CHANGELOG.md](CHANGELOG.md) is the release notes for the whole experimental Agent stack: `agentctl`, `agentd` and +the Agent images under `agents/`. There is one changelog because there is one release unit, the `agentctl` and +`agentd` binaries published by the `experimental-agent/v*` tag. The version in the changelog is that release +version; the Rust workspace version is a build detail and is not tracked there. + +Run `make changelog-validate` to check the file's structure, and `make changelog-test` for the tests covering +[changelog.sh](changelog.sh) itself. `make check` runs the validation, and `make test` runs the tests. + +Releasing is a promotion pull request that renames `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and adds a fresh +empty `## [Unreleased]` above it. That rename is itself a change to the Unreleased section, so the pull request +needs no `skip-changelog` label. Once it is merged, push the tag +`experimental-agent/v`; the release workflow extracts that section with `changelog.sh extract` and +publishes it as the GitHub release body, and fails before creating the release when the section is missing or has +no date. diff --git a/agentctl/CHANGELOG.md b/agentctl/CHANGELOG.md new file mode 100644 index 0000000..e07ad39 --- /dev/null +++ b/agentctl/CHANGELOG.md @@ -0,0 +1,247 @@ +# Changelog + +All notable changes to the experimental Agent platform will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Entries should describe only user-facing functionality in clear, user-friendly language; omit implementation details that do not affect how people use the product. +Section ordering: Added, Changed, Fixed, Removed, Security, Deprecated. + +The version is the Agent release published by the `experimental-agent/v*` tag, covering `agentctl`, `agentd` and the +Agent images they work with. The Rust workspace version is a build detail and is not tracked here. + +## [Unreleased] + +## [0.1.0-preview.9] - 2026-10-02 + +### Added + +- `agentctl tui` opens an Agent with `o`: + - in a shell, VS Code, Zed or SSH, offering to add the `Include` to `~/.ssh/config` first ([#20762](https://github.com/Altinn/altinn-studio/pull/20762)) + - on its desktop, in the browser or a VNC client ([#20763](https://github.com/Altinn/altinn-studio/pull/20763)) + - through a forward, from the forwards view ([#20763](https://github.com/Altinn/altinn-studio/pull/20763)) +- `agentctl ssh-info` reports the directory editors open, as `workingDirectory` in JSON. ([#20762](https://github.com/Altinn/altinn-studio/pull/20762)) +- `agentctl stop` and `agentctl start`, or `x` in `agentctl tui`, stop an Agent's VM and start it again on the same disk, also one that stopped responding. Its Sessions go Idle and resume when attached after the start, and re-applying keeps a stopped Agent stopped. ([#20807](https://github.com/Altinn/altinn-studio/issues/20807)) + +### Changed + +- Altinn, self-development, minimal and worktree Agents install Claude Code 2.1.286. ([#20886](https://github.com/Altinn/altinn-studio/pull/20886)) +- Altinn, self-development and worktree Agents install Codex CLI 0.159.3. ([#20886](https://github.com/Altinn/altinn-studio/pull/20886)) +- `agentctl tui` port forwards: ([#20763](https://github.com/Altinn/altinn-studio/pull/20763)) + - `q` asks before quitting would close them + - they close when their Agent is deleted or re-created + +### Fixed + +- Windows Agents remain responsive after startup, so commands, SSH and Sessions keep working. Restart running Agents to apply the runtime update. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#9](https://github.com/martinothamar/microsandbox/pull/9)) +- Windows Agents can mount host directories. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#10](https://github.com/martinothamar/microsandbox/pull/10)) +- On Windows, files in shared directories remain accessible after their parent directory is renamed. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#14](https://github.com/martinothamar/microsandbox/pull/14)) +- On macOS and Linux, stopping an Agent no longer risks corrupting Sandbox state. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#12](https://github.com/martinothamar/microsandbox/pull/12)) +- In Altinn, self-development and worktree Agents, Rust commands in an Altinn Studio checkout no longer fail with `Permission denied` or need a manual `rustup` update: they use the toolchain the checkout pins, installing it on first use when the image is older. ([#20909](https://github.com/Altinn/altinn-studio/pull/20909)) +- Old Agent images no longer fill the disk: `agentd` removes an image 3 days after its last Agent is deleted, so recreating an Agent does not download it again. Images from earlier releases are removed once every Agent has started. ([#20865](https://github.com/Altinn/altinn-studio/pull/20865)) +- An Agent whose first start failed no longer fails with `image manifest digest … is not present in this Microsandbox cache` after its image tag, such as `:latest`, moves to a newer version. ([#20865](https://github.com/Altinn/altinn-studio/pull/20865)) +- Interrupted commands that wait for an Agent, such as an editor retrying its SSH connection to an Agent that cannot start, no longer make `agentd` stop answering every other command. ([#20884](https://github.com/Altinn/altinn-studio/pull/20884)) +- An Agent whose Sandbox stops responding, for example after the host wakes from sleep, shows `SandboxUnresponsive` in `agentctl get agents` and `Unresponsive` in `agentctl tui`. `agentctl exec`, `attach`, `ssh`, `prompt`, `turns` and Session creation then fail instead of hanging, and `agentctl delete` completes. ([#20868](https://github.com/Altinn/altinn-studio/pull/20868)) + +## [0.1.0-preview.8] - 2026-09-30 + +### Changed + +- Agents run on a newer sandbox runtime, which `agentd` installs by itself; running Agents move to it when they restart. Once the new `agentd` has started, earlier releases cannot read Agent state, so you cannot downgrade. ([#20831](https://github.com/Altinn/altinn-studio/pull/20831)) +- On macOS, Agents resolve names through the host's system resolver, so VPN split DNS and `/etc/resolver` domains work inside an Agent as they do on the host. ([#20792](https://github.com/Altinn/altinn-studio/pull/20792)) +- `.local` names, reverse lookups of private network addresses and names with non-ASCII characters are no longer resolved through the host, so an Agent cannot discover devices on the host's local network. Names in the Agent's own `/etc/hosts` still resolve. ([#20792](https://github.com/Altinn/altinn-studio/pull/20792)) + +### Fixed + +- Agents resolve names again, without a restart, after the host changes networks or comes back online, for example when a laptop moves between Wi-Fi networks or an Agent was started while the host was offline. ([#20792](https://github.com/Altinn/altinn-studio/pull/20792)) +- `agentd` gives back the memory it used to prepare an Agent image once the image is ready. ([#20799](https://github.com/Altinn/altinn-studio/pull/20799)) +- Pressing Ctrl-Z in an attached Session no longer freezes it. ([#20830](https://github.com/Altinn/altinn-studio/pull/20830)) +- Agents with a direct root filesystem, such as the full Altinn Agent, start again after their VM stops, instead of failing with `VMDK missing` until the Agent is deleted. ([#20831](https://github.com/Altinn/altinn-studio/pull/20831)) + +### Security + +- Changing a running Agent's resources after an `agentd` restart no longer gives the Agent network access that bypasses network authorization and secret mediation. ([#20826](https://github.com/Altinn/altinn-studio/pull/20826)) +- An Agent can no longer tunnel non-HTTP traffic through an HTTPS connection to an allowed host to bypass network authorization and secret mediation. WebSocket connections keep working. ([#20831](https://github.com/Altinn/altinn-studio/pull/20831)) + +## [0.1.0-preview.7] - 2026-09-28 + +### Added + +- `agentctl describe agent` shows the provisioning in progress, or the one that failed with its failing step's output, + whether a failure is being retried, and how long each condition has held its state. +- Agent status in `agentctl get -o yaml` and `-o json` includes condition transition times, the failure class and + provisioning progress. +- Provisioning shows Agent setup and SSH access as phases of their own. +- The `agentctl` terminal UI is a live triage view: every Agent and Session with its state and how long it has been in + it. Sessions that need input are marked and counted, `tab` jumps to the next one, `/` filters, and `?` lists every + key. +- In the terminal UI, a side panel shows the selected Session's recent turns or the selected Agent's status. `p` follows + an Agent's provisioning, which also opens for an Agent created with `c`, or prompts a Session without attaching. +- Altinn Agents include `typos` and `hunspell`, so the repository spell check (`yarn spell:quick`, `yarn spell:check` + and the pre-commit hook) runs inside an Agent. +- The full Altinn Agent includes `cargo-machete`, so `make deps-check` and `make check` in the Rust workspaces run + inside an Agent. +- `agentctl delete session/` and `d` in the terminal UI delete one Session: its harness is stopped and its name + becomes free. The harness's own conversation files stay in the Sandbox. +- `agentctl archive session/` and `a` in the terminal UI archive a Session: its harness stops once any turn in + progress ends, and it is hidden until `agentctl unarchive`. `get sessions --archived` and `A` show archived Sessions. +- A new `desktop` Altinn Agent has a graphical screen it can see and use, driven by a `desktop` helper and a + `computer-use` skill: screenshot, zoom, point, scroll and type, including Norwegian text, and read what is showing + as an accessibility tree, including the browser's own controls and dialogs. A terminal opens with `Ctrl+Alt+T` + or the panel's launcher and has the Session's environment. +- Agents can declare `access: [{type: vnc}]`. Watch or take over the desktop with `agentctl vnc --web`, in a browser + with nothing installed, or `agentctl vnc` for a VNC client of your own. + +### Changed + +- Commands that wait for an Agent, such as `apply --wait`, pick up provisioning already in progress and no longer drop + output when they fall behind. +- Image pulls and imports show downloading, materializing and assembling as separate steps. +- The terminal UI updates as Agents and Sessions change instead of every two seconds, and keeps the last state on screen + while `agentd` is unreachable. +- Terminal UI forms share one layout with aligned fields, and `NO_COLOR` turns off color while every state keeps its + glyph. +- New full Altinn Agents finish setup faster. Chromium's trust in the certificate bundle is imported faster and in the + background, so Sessions no longer wait for it. + +### Fixed + +- The terminal UI keeps the selection on the same Agent or Session when rows move or an Agent is folded. +- A Claude Code Session left Idle for more than 30 days resumes its conversation instead of starting a new one. Claude + Code no longer deletes transcripts it considers old. +- The terminal UI's new-Session form rejects a name the Agent already uses instead of attaching to that Session. + +## [0.1.0-preview.6] - 2026-09-23 + +### Changed + +- Altinn, self-development, minimal and worktree Agents install Claude Code 2.1.280. +- Altinn, self-development and worktree Agents install Codex CLI 0.156.0, with workspace routing and activity hooks updated for its startup flow. + +## [0.1.0-preview.5] - 2026-09-22 + +### Added + +- Codex and Claude Code Sessions show their model, working directory, Git branch, context usage, usage limits, harness + version and Fast mode in a persistent status line. +- Altinn Agents can authenticate ordinary HTTPS Git commands to Azure DevOps with an optional host-mediated personal + access token, including cloning the `altinn-studio-infra` repository without exposing the token in the Sandbox. +- Altinn and self-development Agents include Neovim with line numbers, cursor highlighting, a filetype statusline, the + `habamax` theme and built-in syntax highlighting for C#, JavaScript, TypeScript, JSON and XML. +- Agent images include the `gh stack` extension for creating and managing stacked pull requests. +- The `agentctl` terminal UI supports mouse selection, scrolling, clickable controls and deliberate double-click + actions while retaining all keyboard controls. +- Agent Skill entries may declare an installed `name` separately from their source directory. +- A harness installation may be declared `optional`, so an Agent is created without it when its host login is + absent. Altinn Agents declare Codex this way, and signing in on the host installs it on the next convergence. +- Altinn Agents install the repository's text-review and Norwegian copy-editing Skills, so a Session has them as + well as a local checkout. +- Agent manifests may mark a mediated secret as optional, so an absent or empty value omits that binding instead of + blocking Agent provisioning. +- Altinn Agent images include `studioctl`, the Altinn Studio app-development skill and `/home/agent/code/apps` for + app checkouts. They log `studioctl` in to each configured production, staging or development Studio environment + with a host-mediated API key. Full images also prepare LocalTest hostnames for browser testing. + +### Changed + +- Pull request evidence guidance is shorter, with readable pacing and no fixed clip + duration. The GIF conversion helper now accepts files up to 10 MiB instead of 8 MiB. +- Altinn, self-development, minimal and worktree Agents install the latest stable Claude Code and Codex CLI + harnesses, and Codex command failures remain visible in `agentctl turns` with the new transcript format. + +### Fixed + +- Agent Sessions set `XDG_RUNTIME_DIR`, so `skopeo`, `buildah` and other tools that expect a user runtime + directory run instead of failing with a permission error on a path they cannot read. +- `podman run --init` works in full Agents; the `catatonit` binary the flag needs was missing from the image. +- Chromium in a full Altinn Agent trusts the same host-mediated certificate authorities as command-line tools, so + browser tests can load HTTPS dependencies without disabling certificate verification. + +## [0.1.0-preview.4] - 2026-09-18 + +### Added + +- The release installers accept `AGENT_INSTALL_MODE=standalone` to verify and copy only `agentctl` and `agentd` into + `AGENT_INSTALL_DIR`. This supports immutable images and CI jobs without creating self-update state, migrating Agent + data, starting the daemon, or changing `PATH`. +- Agent manifests support chained `AgentVariant` files named `agent..yaml`. Select them with `--variant` or the TUI, which also supports ignored local variants and an environment file. +- SSH access to Agents. Declare `spec.access: [{type: ssh}]`, then `agentctl ssh [-- command]` opens a shell or runs a command in the Sandbox as `agent`. `agentctl ssh-config install` lets plain `ssh`, `sftp` and editors reach the Agent as `agentctl-`, and `agentctl ssh-info -o json` prints the connection details. The Altinn Agent images and the examples declare it; an Agent created from an older image must be deleted and re-applied. +- Windows contributors can run `.\make-user-install.ps1` to build, package and install a local Agent without Make. + +### Changed + +- `agentctl apply` defaults to `./agent.yaml`. The self-development and Altinn Agents provide nested and worktree variants; Altinn also provides nested-build variants. + +### Fixed + +- SSH shells, remote commands and editor terminals now inherit the same Agent tool, configured environment and + mediated certificate settings as Sessions and `agentctl exec`. +- Concurrent network requests from an Agent no longer intermittently fail with DNS, HTTP or TLS errors, especially on Windows hosts. +- Deleting an Agent no longer logs a panic when its Sandbox has an active network-control connection. +- The self-development Agent examples build with their SSH configuration, so the checkout, worktree and nested variants can be applied. +- On Windows, detaching from a Session with `Ctrl-b d` returns control to the terminal UI without dropping the next key press. +- Attached Sessions support mouse-wheel scrolling through up to 50,000 lines of terminal history for new panes. Codex and Claude Code keep their conversations in that history; Claude Code no longer uses its fullscreen renderer, which could corrupt the display when scrolling in tmux. Reattaching enables mouse support for existing Sessions, but cannot recover discarded output. +- Agent setup now writes Sandbox files only when their contents changed, and replaces them atomically. Codex no longer reports missing skill frontmatter after each reconciliation pass. + +### Security + +- Applying an Agent rejects bind mounts containing `.env` files, case-insensitively and regardless of ignore rules. + +## [0.1.0-preview.3] - 2026-09-17 + +### Added + +- `agentctl create` and `agentctl attach` accept `--model` and `--effort`, and the terminal UI's new-session form has the same fields, to choose the model and effort level a Session's harness launches with. Values are the harness's own, for example `fable` and `high` for Claude Code. `spec.harnesses[].defaults` declares per-installation defaults. The choice is fixed for the Session, applied on every relaunch and resume, and shown by `agentctl get sessions`. + +### Changed + +- Claude Code Sessions launch on the `fable` alias only when the manifest declares it; the `agents/` manifests and the examples do, and your own manifests need `defaults: { model: fable }` on the Claude Code installation to keep it for new Sessions. Sessions created earlier keep launching on `fable`. +- The Sandbox runtime (microsandbox) was updated. Linux hosts with older system libraries, such as Ubuntu 22.04, can now install it, and a Sandbox that fails to start reports the runtime's own error instead of a bare timeout. +- Agent instructions now tell Claude Code and Codex not to add `Co-Authored-By` or similar AI-attribution trailers to commits and pull requests. +- The Altinn Agent images run on Norwegian local time (Europe/Oslo) instead of UTC, so `date`, file timestamps and log output inside an Agent match the clock where the work is reviewed. An existing Agent keeps the image it was created with; delete and re-apply it to pick this up. + +### Fixed + +- On Windows, starting a Sandbox with a large root filesystem could take an hour while its disk was copied. The copy now takes seconds. +- Linkerd could not start inside a kind cluster running in a Sandbox because the Sandbox kernel lacked the iptables owner match its proxy-init needs. The match is now built in. +- Building the Agent images, or the minimal and worktree examples, failed with a certificate error where the network inspects TLS, such as inside another Agent. The npm, Yarn, Corepack and Playwright downloads now trust the Agent's certificate bundle while the image is built. +- Test suites and dev servers inside an Agent could fail to start with `user limit (128) on inotify instances reached` before running anything, because the guest kept the kernel's desktop-sized file-watcher limits. The Agent images now raise them to the values the self-hosted CI runners already use. +- Logging a nested Agent into Claude failed with an empty credential. Claude Code hides `CLAUDE_CODE_OAUTH_TOKEN` from the commands it runs, so the documented `agentctl claude login --from-stdin` step had nothing to read. An Agent now also carries its Claude credential as `AGENT_CLAUDE_ACCESS_TOKEN`, matching `AGENT_CODEX_ACCESS_TOKEN`, and the self-development instructions use it. + +## [0.1.0-preview.2] - 2026-09-15 + +### Added + +- `agentctl self update` installs a newer release, and `--check` only reports whether one exists. It migrates your Agent state behind a backup, relaunches resumable Sessions, and refuses to run while work is in flight. ([#20397](https://github.com/Altinn/altinn-studio/pull/20397)) +- `agentctl apply --wait` stays attached and streams provisioning progress — image pull, build, Sandbox creation, harness setup — until the Agent is Ready. `wait`, `attach` and `exec` show the same progress. ([#20341](https://github.com/Altinn/altinn-studio/pull/20341)) +- `agentctl port-forward [ADDRESS:]LOCAL:GUEST...` forwards local TCP ports into a running Sandbox, and `:GUEST` picks a free local port. In the terminal UI, `f` adds a forward and `F` lists them. ([#20189](https://github.com/Altinn/altinn-studio/pull/20189), [#20245](https://github.com/Altinn/altinn-studio/pull/20245)) +- `agentctl create`, `prompt` (with `--wait`) and `turns` drive a Session from the command line without attaching. Prompt text comes from `--prompt`, `--file` or piped standard input. ([#20374](https://github.com/Altinn/altinn-studio/pull/20374)) +- Press `c` in the terminal UI to create an Agent, choosing from the manifests found by walking down from the repository root. ([#20242](https://github.com/Altinn/altinn-studio/pull/20242), [#20358](https://github.com/Altinn/altinn-studio/pull/20358)) +- `spec.environment` copies declared non-secret values from the manifest's `.env` into the Sandbox. `GIT_USER_NAME` and `GIT_USER_EMAIL` also set the Sandbox user's global Git identity. ([#20416](https://github.com/Altinn/altinn-studio/pull/20416)) +- `spec.skills` installs skill directories into the Sandbox and `spec.instructions` accepts several sources. Every image gains asciinema and agg, the full image ffmpeg and a Playwright CLI, and the manifests a `pr-evidence` skill. ([#20348](https://github.com/Altinn/altinn-studio/pull/20348)) +- `agentctl apply --env-file` names the file supplying the manifest's declared environment and secret values, instead of the `.env` beside the manifest. Keep files holding secrets outside bind-mounted directories. ([#20339](https://github.com/Altinn/altinn-studio/pull/20339)) +- `agentctl get` and `agentctl describe` accept `-o json`, so tooling can read Agent and Session state instead of parsing the table. ([#20374](https://github.com/Altinn/altinn-studio/pull/20374)) +- `agentctl claude login --from-stdin` and `agentctl codex login --from-stdin` take a credential from standard input, so an Agent can log in a nested Agent without ever holding a real one. ([#20339](https://github.com/Altinn/altinn-studio/pull/20339)) + +### Changed + +- **Upgrading from preview 1.** Stop the running `agentd` first — preview 1 cannot stop itself — then re-run `install.sh` or `install.ps1` once, which migrates your Agents and stored logins. Use `agentctl self update` from then on. ([#20397](https://github.com/Altinn/altinn-studio/pull/20397)) +- **A host release does not change your Sandbox image.** The `agents/` manifests reference `:latest`, and an existing Agent keeps the image it was created with. Delete and re-apply the Agent to pick up a newer one. +- `agentctl` explains failures instead of reporting them bare: the manifest setting at fault, the failing image build step, or the Agent's own reason when a wait times out. ([#20341](https://github.com/Altinn/altinn-studio/pull/20341)) +- The hostname inside a Sandbox is the Agent name, so prompts and logs read `agent@`. Existing Sandboxes keep their old hostname until they are recreated. ([#20363](https://github.com/Altinn/altinn-studio/pull/20363)) +- `spec.harnesses[].version` is optional: the image owns the harness version, so an image bump needs no manifest change. The `agents/` manifests no longer pin one. ([#20250](https://github.com/Altinn/altinn-studio/pull/20250)) +- A Session goes idle after 30 minutes rather than 5, and the timer restarts on activity. Codex no longer checks for its own updates at startup. ([#20374](https://github.com/Altinn/altinn-studio/pull/20374)) +- Git in the published images authenticates through the `gh` CLI, so `git push` works in a fresh Sandbox without `gh auth setup-git`. The token is also allowed against `gist.github.com`. ([#20151](https://github.com/Altinn/altinn-studio/pull/20151)) + +### Fixed + +- Sessions launch Claude Code on the `fable` alias. Fable never appeared in the `/model` picker, so it could not be selected at all, and a pinned generation would not follow new releases. ([#20147](https://github.com/Altinn/altinn-studio/pull/20147), [#20314](https://github.com/Altinn/altinn-studio/pull/20314)) +- Agents start on macOS hosts with long home directory paths, where the Microsandbox control socket could exceed the 104-byte limit on Unix socket paths. ([#20411](https://github.com/Altinn/altinn-studio/pull/20411)) +- Configuring Podman waits for the guest to finish booting, instead of failing once with `System has not been booted with systemd as init system`. ([#20346](https://github.com/Altinn/altinn-studio/pull/20346)) +- Installing on Windows no longer fails while `install.ps1` verifies the downloaded release archive. ([#20143](https://github.com/Altinn/altinn-studio/pull/20143)) + +## [0.1.0-preview.1] - 2026-08-26 + +### Added + +- First public preview: `agentctl` and `agentd`, declarative Agents with durable tmux-backed Sessions in isolated Sandboxes, mediated harness and GitHub authentication, and the published Agent images. diff --git a/agentctl/Cargo.toml b/agentctl/Cargo.toml new file mode 100644 index 0000000..e457a58 --- /dev/null +++ b/agentctl/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "agent" +description = "Agent automation and local control plane built on the sandbox crate" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +base64.workspace = true +sandbox = { path = "../sandbox" } +sandbox-authorization = { path = "../sandbox-authorization" } +sandbox-microsandbox = { path = "../sandbox-microsandbox" } +clap.workspace = true +crossterm.workspace = true +ignore.workspace = true +mimalloc.workspace = true +futures-util.workspace = true +flate2.workspace = true +reqwest.workspace = true +rand_core.workspace = true +ratatui.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true +semver.workspace = true +sha2.workspace = true +ssh-key.workspace = true +tar.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +tempfile.workspace = true +zeroize.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + +[target.'cfg(windows)'.dependencies] +tokio-util = { workspace = true, features = ["compat"] } +win_uds = { version = "0.2.4", features = ["async"] } + +[lints] +workspace = true diff --git a/agentctl/HARNESSES.md b/agentctl/HARNESSES.md new file mode 100644 index 0000000..a251725 --- /dev/null +++ b/agentctl/HARNESSES.md @@ -0,0 +1,75 @@ +# Harness compatibility + +Treat harness installation version bumps as adapter changes: existing unit tests and transcript fixtures do not +establish compatibility with a new binary. Terminal behavior, transcript formats, hook semantics, authentication +and resume behavior all need live verification. Version verification checks identity, not compatibility. + +An existing Agent keeps the image it was created with, but `agentctl self update` gives it the new `agentd` at once. +Adapter changes that accompany a bump must therefore also work with the previous pin. + +Implementation: [adapters](src/harness), [terminal runtime](src/sessions/runtime/tmux.rs), +[Session service](src/sessions/service.rs). Harness pins: [self-dev](examples/self-dev/Dockerfile), +[minimal](examples/minimal/Dockerfile) (Claude Code only) and [worktree](../sandbox/examples/worktree/Dockerfile); +update them together. + +## Upgrade test plan + +Install the current platform with `make user-install` from `src/experimental`; it replaces and restarts `agentd`, +and refuses while any Session reports `Working` (#20871, #20872), so archive or delete earlier test Sessions first. +Test the self-dev image built from the branch: from `agent/examples/self-dev`, `agentctl apply --variant nested +--env-file --wait` builds it with both harnesses and fits inside another Agent. Keep the env file outside the +checkout. Use fresh Session names and confirm `claude --version` and `codex --version` in the Sandbox; testing an +existing Sandbox does not prove the rebuilt image works. + +Run each check against both harnesses unless the table names one. A low-cost model is enough, but Sessions on each +installation's manifest `defaults` must work at least once. Before attributing a failure to the bump, repeat the check +on an Agent built with the previous pin. + +`agentctl` has no command for some of the steps: + +- Keys without attaching: `agentctl exec agent/ -- tmux send-keys -t '=agent-session-:' Escape`, with the + Session `id` from `agentctl get sessions -o json`. `tmux capture-pane -p -t '=agent-session-:'` prints the screen. +- Idle without waiting 30 minutes: `agentctl archive`, then `agentctl unarchive`; `agentctl create` or `attach` then + resumes the Session. Run the real idle-stop once, in the background. +- A long foreground tool call: ask for `timeout 90 tail -f /dev/null`. Claude Code refuses a bare `sleep` and may run a + command in the background instead. +- A Claude Code permission prompt: add `"permissions": {"ask": ["Bash(touch:*)"]}` to `~/.claude/settings.json` in the + Sandbox, start a new Session and ask it to run `touch`; ask rules prompt despite `--dangerously-skip-permissions`. + Restore the file afterwards. The `AskUserQuestion` tool blocks on the operator without any configuration. +- A daemon restart: `pkill -x agentd`; the next `agentctl` command starts it again. + +| Check | Harness | Expected result | +| -------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create with an initial prompt | Both | One submission and answer; a daemon restart does not replay the prompt. A failed launch may lose the initial prompt and recover with an empty conversation. | +| Create without a prompt, then immediately `prompt --wait` | Both | Input submits without manual Enter. Repeat several times to expose startup races. Codex readiness depends on its `› ` composer and the truncated session-ID pane title. | +| Short, long, multiline, XML-shaped and literal request-heading input | Both | `turns` preserves the complete operator input and shows no harness-injected text as operator input (#20870). | +| Prompt again after completion, including identical text | Both | Waits for one more completed turn, ignoring previous completions. | +| Prompt during an active tool call, including identical text | Both | Input appears in `turns`; waiting follows work observed during settling, but does not demand an extra turn when input is absorbed into the current one. | +| Prompt while a background command runs, until it finishes | Claude Code | The wait ends with the operator's turn; the completion notification's own turn does not end a later wait early. | +| Tool success and tool failure | Both | STATE reflects activity; tool completion alone does not complete a turn; `turns` marks only the failed call. | +| Permission prompt and `AskUserQuestion`, approved and denied | Claude Code | While blocked, STATE is `WaitingForInput` and `activity.turns` does not advance; answering or approving completes the turn. A denial is an interruption (#20869). Not tested for Codex, which launches without approvals. | +| Interruption during a tool call, then another prompt | Both | Codex reports `Interrupt`, which releases the wait. Known gap (#20869): Claude Code has no interrupt hook, so STATE stays `Working` and a wait times out. The next prompt works in both. | +| Model error reported through `StopFailure` | Claude Code | Create with an unknown `--model`. The completion report ends the wait, but does not imply a successful model response; inspect `turns`. | +| Model error without a completion report | Codex | Create with an unknown `--model`. Codex shows the API error without a turn ending, so the wait times out and STATE stays `Working` (#20872); inspect the Session and recover manually. | +| Short completion timeout | Both | Queuing, input readiness and delivery finish before the completion timeout starts. A timeout reports that the prompt was submitted; inspect turns before retrying. The next prompt contains no leftover draft. | +| Idle-stop and resume, before and after the first turn | Both | After 30 unattached, quiet minutes the Session is Idle. An untouched Session remains usable; an established conversation resumes with its history. | +| Archive mid-turn, then unarchive and resume | Both | The harness stops once the turn ends; `create` or `attach` after unarchive resumes the conversation. | +| Idle Session whose transcript is older than 30 days | Claude Code | After a prompt in another Session of the Agent, it still resumes with its history. Backdate the transcript with `touch -d`. | +| Codex rollout compression | Codex | `local_thread_store_compression` is still off by default. Otherwise, check that `turns` and resume work for an Idle Session whose rollout is older than 7 days. | +| Create with `--model`/`--effort`, and with only manifest `defaults` | Both | `get sessions` shows the resolved selection and the harness reports the same model and effort, also after idle-stop and resume; an unknown value fails visibly in the terminal. | +| Transcript location | Both | `get sessions -o json` reports a `harnessTranscriptPath` that exists and grows with each turn, also after resume. | +| Authentication and configuration | Both | Mediated login/inference works without unexpected onboarding or authentication dialogs; startup shows no new warnings; the status line renders; configured instructions and skills are available. | +| Nested Agent | Both | Inside a Session, `agentctl claude login --from-stdin` with `$AGENT_CLAUDE_ACCESS_TOKEN` and `agentctl codex login --from-stdin < ~/.codex/auth.json` let a nested Agent run both harnesses. | +| New `agentd` with the previous pin | Both | When the change touches adapter code, an Agent on the previous image still creates, prompts, reads turns and resumes. | + +Completion waits poll local database activity every 250 ms and require identical completed, waiting activity +in two consecutive polls. + +Inspect `get sessions` (including `-o json`) and `turns` alongside the terminal. Check user messages, assistant answers, +tool results and turn boundaries, including after compaction. A successful model response alone is insufficient. + +Run the normal formatting, lint and test checks, plus `cargo test -p agent --lib -- --ignored` on a host with Node.js +and tmux. Those tests drive tmux with a synthetic program, not the harnesses, so they do not replace a live check; the +scrollback and Ctrl-Z checks also require Linux and util-linux `script`. Record in the PR, for each check and harness, +the result or why it was not run, with the tested versions and commands. Update adapter fixtures when native output +changes, and add a changelog entry. Never publish credentials or authentication-bearing process arguments. diff --git a/agentctl/README.md b/agentctl/README.md new file mode 100644 index 0000000..cd48982 --- /dev/null +++ b/agentctl/README.md @@ -0,0 +1,220 @@ +# Agent platform + +This area explores an open agent platform built on a reusable Sandbox SDK. The platform is designed to run locally +or under later cloud orchestration without coupling Agent automation to one isolation backend, network implementation, +harness or model. + +The main goals are: + +- long-running Agents with multiple durable Sessions; +- strong isolation with mediated network access and no real secrets inside Sandboxes; +- backend-neutral Sandbox lifecycle, execution, storage, file-transfer and terminal APIs; +- harness-neutral Agent and Session concepts with harness-specific behavior kept in adapters; +- host APIs that work on Linux, macOS and Windows while initially materializing Linux Sandboxes; and +- a Sandbox layer reusable by CI runners and other isolated workloads that do not depend on Agent concepts. + +## Development + +Run `make help` from this directory for the supported development commands. `make user-install` builds, packages and +installs `agentctl` and `agentd` for the current user. On Windows without Make, run `.\make-user-install.ps1` for the +same build, package and installation flow. + +Maintainers updating the Microsandbox or libkrunfw forks should follow the +[downstream maintenance runbook](MICROSANDBOX.md). + +User-visible changes are recorded in [CHANGELOG.md](CHANGELOG.md), which covers the whole stack and provides the +release notes for each `experimental-agent/v*` release. + +The Agent database and local protocol are intentionally clean-slate while this code is experimental. Breaking schema +changes require stopping `agentd` and removing the configured Agent home rather than migrating old state. + +## Architecture + +```text +Host +├── agentctl local CLI, transient execution and Session attachment +└── agentd control plane, reconciliation, policy and SecretStore + └── Agent declarative durable resource + └── Sandbox isolated execution environment + ├── Session durable tmux-backed harness process + └── Session durable tmux-backed harness process +``` + +The implementation has two deliberately separate layers. + +### Sandbox layer + +`sandbox` is the generic Rust SDK. A Node hosts Sandboxes, and Sandboxes host Executions. Providers pair a Sandbox +Backend with an Image Backend and advertise platform capabilities before selection. Network Backends are selected +independently and consume a negotiated packet, intercepted-flow or versioned-control endpoint. + +`sandbox-microsandbox` implements the Sandbox, Image and Network contracts for Microsandbox. Network enforcement and +secret substitution happen on the trusted mediation path; the Sandbox Backend must not leave an unobserved egress +path. `sandbox-authorization` defines the context-aware authorization vocabulary without depending on the Agent +control plane or an enforcement implementation. + +The Sandbox crates do not depend on Agent automation. + +### Agent layer + +`agentd` owns the durable desired state and all lifecycle effects. `agentctl` starts the adjacent daemon on demand and +communicates through the versioned local control API. Its resource-oriented commands follow `verb resource [name]`; +Session scope is explicit through `--agent` or inferred from the closest unique persisted Agent source directory. +Transient `exec` commands similarly converge the Agent first, then target its exact materialized Sandbox without +creating durable Session state or taking Sandbox lifecycle ownership away from `agentd`. + +An Agent owns one retained Sandbox incarnation. The Agent controller is the sole owner of Sandbox selection, +materialization, setup, network mediation and release. A Session controller can only open the already-materialized +Sandbox and owns the in-Sandbox tmux and harness effects for that Session. Both use the same keyed reconciliation +scheduler, which serializes work per resource identity while allowing unrelated resources to progress concurrently. + +Desired state is persisted before reconciliation. Wakeups provide low-latency progress, while startup and periodic +scans ensure dropped notifications or daemon restarts do not lose work. Provider assignment is sticky for an Agent +incarnation, and a reused Agent name never inherits resources from a deleted incarnation. The Sandbox is named after the +incarnation, while its guest hostname is the Agent name so shell prompts and logs identify the Agent. + +A running Sandbox's guest can stall while its VM process keeps running. The Sandbox SDK reports the guest's heartbeat +without a round trip to the guest. While reconciliation works inside the guest, `agentd` inspects the heartbeat every 2 +seconds and records when it last advanced on the host clock. After 15 seconds without progress the work ends, and the +Agent reports `SandboxResponsive=False` and `Ready=False` with reason `SandboxUnresponsive`. A stalled guest therefore +cannot hold its Agent's reconciliation, and a command waiting for the Agent to become Ready fails once the stall is +recorded. The next pass after the heartbeat advances makes the Agent Ready again. A stalled guest is not restarted +automatically; `agentctl stop` and then `agentctl start` restart it. + +An Agent's `spec.runState` is `Running`, the default, or `Stopped`. `agentctl stop` and `agentctl start` set it as a +new generation; a manifest may set it, and `apply` of one that omits it keeps the current run state. For a stopped Agent +the reconciler stops the Sandbox VM and its Network, killing a VM that does not stop gracefully, and keeps its root +filesystem, Volumes, identity and Provider assignment. It reaches nothing in the guest and reports `Ready=False` and +`SandboxReady=False` with reason `Stopped`. Commands that need the Sandbox fail at once, and its Sessions go Idle, as +after inactivity. A start is an ordinary pass on the same root filesystem; it launches no harness, and the next attach +to a Session resumes its conversation. + +Provisioning progress is observed as state, not as a stream. The Sandbox SDK folds progress events into a `Progress` +value, so an observer that joins late or falls behind sees what one that saw every event would. `agentd` keeps each +Agent's latest provisioning pass in memory, and records a routine resync of a Ready Agent only when it fails; the +durable outcome is the Agent's conditions, with their transition times, and its failure class. Clients follow one Agent +with `agents.v1.progress` and every Agent and Session with `resources.v1.watch`, long-polls that return when the daemon +drains. Commands that wait for an Agent, such as `apply --wait`, `wait`, `start` and `stop`, call `agents.v1.converge` +while they follow its progress. It wakes the Agent and returns once a pass for its generation recorded its desired run +state, Ready or stopped, waiting through transient failures. + +`agentctl tui` builds on the same two calls: it follows `resources.v1.watch` for the fleet and `agents.v1.progress` for +one Agent's provisioning, and derives each Agent's state from its conditions and failure class. + +Sessions have platform-assigned identities independent of tmux and harness-native conversation IDs. Each Session binds +immutably to one of its Agent's declared harness installations and to a model selection (model and effort level) +resolved at creation: the caller's explicit choice, else the installation's manifest `defaults`, else nothing, leaving +the harness's own defaults. Both values are provider-owned identifiers the platform validates but does not interpret. +The selection is recorded with the Session, shown by `agentctl get sessions`, and applied on every launch including +resume, so a later manifest change affects only new Sessions and a model change made inside the harness lasts until +the next relaunch. Detaching leaves a Session running. An inactive, unattached Session becomes Idle and is relaunched +on the next ensure or attach, resuming the harness conversation when its native state still exists. Repeated +unexpected harness exits use bounded backoff. Deleting a Session hides it at once; the Session controller stops its +harness before removing it, so an unreachable Sandbox delays the deletion rather than leaving a harness untracked. +Archiving a Session stops its harness once any turn in progress ends, but not for a turn waiting for approval or quiet +for a minute, and keeps it stopped until the Session is unarchived; it keeps its name and conversation, and the next +attach resumes it. + +Tmux is the current Session runtime, not a security boundary or a permanent generic driver abstraction. A second +runtime must establish the common interface before one is introduced. + +## Images, home and harnesses + +See the [harness compatibility test plan](agent/HARNESSES.md) when updating harness installations. + +Agent images own installed tools and optional workspace initialization. Repository checkouts are persistent runtime +data beneath `/home/agent/code`; they are not declared, updated or deleted by the Agent controller. Sessions may clone +repositories they can access, and image init may make a simple best-effort checkout for convenience. +`spec.sandbox.mounts` can instead attach caller-owned host directories or temporary memory filesystems when the selected +Sandbox Provider supports them; these attachments are immutable for the Agent incarnation. + +`spec.home` is a continuously applied overlay onto `/home/agent`. It converges files supplied by the builder but does +not delete guest files that disappear from the source. Builders may use it to own harness configuration explicitly, +with the consequence that those files are reapplied on every Agent pass. + +`spec.harnesses` declares the harness installations available to Sessions and selects the default used for new Sessions. +A declared `version` is verified against the image at setup; omit it when the image owns the version, so image bumps need no manifest change. +Set `optional: true` when an absent host login should omit that installation instead of blocking Agent creation, so a +manifest can offer a harness that not everyone has signed in to. The check runs on every convergence, so signing in on +the host installs the harness on the next pass; until then a Session on it is refused, naming the login. The default +installation cannot usefully be optional, since it is what a Session selecting no harness gets. +Each installation may declare `defaults` with a `model` and an `effort` level for its new Sessions, in the harness's +own vocabulary. The published manifests select `model: fable` for Claude Code because a mediated token cannot list +Fable in the `/model` picker. +`spec.instructions` names one harness-neutral Agent instruction file. Every declared Harness Adapter installs that source +at its global instruction location: `~/.claude/CLAUDE.md` for Claude Code and `~/.codex/AGENTS.md` for Codex. +Repository-local instruction files continue to be discovered by the harness itself. + +Harness Adapters own authentication, version verification, managed configuration, hooks, native conversation IDs and +launch arguments. The current adapters support Claude Code and Codex CLI. Harness-owned mutable state is seeded by the +image or the user and is not used as a trusted bootstrap marker. + +## SSH access + +`spec.access: [{type: ssh}]` gives the Agent's user OpenSSH access to the Sandbox as the platform-owned user `agent`: +`agentctl ssh [-- command]` opens it, `agentctl ssh-config install` makes the alias `agentctl-` +available to plain `ssh`, `sftp` and editors that read OpenSSH configuration, and +`agentctl ssh-info -o json` describes the connection for other tools. The server listens only inside the +Sandbox and is reached through `agentctl ssh-proxy`; the image must provide OpenSSH, systemd and a usable `agent` +account, while `agentd` installs the isolated server policy and unit. `agent` has passwordless `sudo`, so an SSH +login shares the Sandbox's one trust boundary with Sessions. SSH shells, remote commands and editor servers inherit +the same image, Agent and mediated trust environment as Sandbox Executions; terminal- and Session-specific variables +remain local to their process. + +## Secrets and network policy + +A secret is any protected host-owned value. Credentials are the subset used for authentication. Generic storage and +mediation therefore use the `SecretStore` concept, while harness login remains an authentication concern. + +`spec.environment` explicitly selects non-secret values from the same `.env` file, with `name` as both the Sandbox +variable and default source name. An optional `source` selects a differently named entry. Only declared values are +copied, and they enter the Sandbox in plaintext, where image init and Sandbox Executions inherit them. Reapplying +after changing the file updates the Sandbox environment. Do not declare secrets here. + +Manifest secret bindings name a guest environment variable and the hosts where its value may be substituted. The +matching real value is loaded from the manifest directory's `.env` file, or the file named by +`agentctl apply --env-file`, and retained only in the owner-protected host database. A bind mount whose source +contains any active Agent's secret file is refused at apply time, because the Sandbox would otherwise read the real +values from the mounted directory. The Sandbox sees an inert placeholder in the named environment variable. The Network Backend substitutes +the current real value only for an authorized request to an allowed host; rotation does not require copying new +material into the Sandbox. A custom placeholder is optional for clients that validate token shape. +Set `optional: true` when a missing or empty environment-file value should omit that secret binding instead of +rejecting the Agent apply. Required secrets remain the default. + +Policy is evaluated for live Sandbox-originated operations and fails closed when the destination, authorization, +secret resolution or trusted mediation path is unavailable. Host-destined traffic is restricted to the registered +Platform API endpoint. This authorization is separate from authorization of users calling the host Agent API. +When an Agent image includes Podman, the platform makes the guest's mediated CA bundle available to containers and +build steps through standard trust paths. An OCI hook copies the bundle into the container root filesystem rather +than bind-mounting it, so package managers can still replace the bundle, and it adds the mediator CA as a trust +anchor so a regenerated bundle keeps trusting mediation. Docker and dockerd are not covered by this convenience +wiring. + +SQLite `secure_delete` and owner-only filesystem permissions provide local hygiene. They are not a cryptographic +erasure guarantee across WAL history, filesystem snapshots or backups. + +## Current scope and direction + +The current milestone provides persistent Agents and Sessions, real Microsandbox lifecycle, mediated harness and GitHub +authentication, image-owned workspace initialization, idle/resume behavior, local packaging and release-pinned runtime +downloads. + +Important current limitations are: + +- Codex uses a separate ChatGPT subscription login owned and refreshed by `agentd`; +- Sessions share one Sandbox user and tmux server and therefore one trust boundary; +- attachment is still a client-side Provider operation rather than a daemon-owned terminal capability; +- Session content, prompt steering and plugin APIs are not implemented; and +- global scheduling and Kubernetes orchestration are future work. + +The next planned slices are: + +1. expose harness-native Session content and prompt/steer/interrupt operations; +2. add an authorized Sandbox-facing Platform API for delegation and isolated host plugins; and +3. add global orchestration only after the local control-plane contracts are proven. + +## References + +- agentdp and nvt-agent: earlier agent-platform prototypes +- Microsandbox and smolvm: microVM and Sandbox implementations +- Herdr: harness multiplexing and native session-state exploration diff --git a/agentctl/changelog.sh b/agentctl/changelog.sh new file mode 100755 index 0000000..42d7a14 --- /dev/null +++ b/agentctl/changelog.sh @@ -0,0 +1,434 @@ +#!/bin/sh +# Changelog tooling for the experimental Agent platform. +# +# One changelog, src/experimental/CHANGELOG.md, covers the whole stack: the `agentctl` and `agentd` +# binaries published by the `experimental-agent/v*` tag, and the Agent images they work with. It +# follows Keep a Changelog 1.1.0 and Semantic Versioning 2.0.0. The script has no dependencies +# beyond a POSIX shell, coreutils, awk and git. +# +# Usage: +# changelog.sh validate [path] +# Check the changelog's structure. Fails unless the file starts with the Keep a Changelog +# header, has exactly one `## [Unreleased]` section as its first version heading, writes +# every released section as `## [X.Y.Z] - YYYY-MM-DD` in descending Semantic Versioning +# order, uses only the sections Added, Changed, Fixed, Removed, Security and Deprecated in +# that order within a version, leaves no `###` section empty, and writes every entry as a +# `- ` bullet (continuation lines are indented by at least two spaces). +# +# changelog.sh extract [path] +# Print the body of one released version's section, without its heading, to standard output. +# The version may be written with or without a leading `v`. Exits non-zero when the section +# is missing, has no release date, or has no content. Used by the release workflow to build +# the GitHub release notes. +# +# changelog.sh check-unreleased [path] +# Compare the `## [Unreleased]` section between two Git references and exit non-zero when it +# is unchanged. The comparison uses the commit where the two references diverged, not the tip +# of the base reference, so an entry another pull request added in the meantime cannot +# satisfy the check. A file missing at that commit counts as changed, but a reference that +# cannot be resolved is an error rather than a pass. Used by the pull request workflow; a +# pull request with no user-visible change carries the `skip-changelog` label instead. +# +# `path` defaults to CHANGELOG.md beside this script. + +set -eu + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIRECTORY="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +DEFAULT_CHANGELOG="${SCRIPT_DIRECTORY}/CHANGELOG.md" + +# Semantic Versioning 2.0.0, without build metadata: the core numbers carry no leading zeroes, and a +# prerelease is a dot-separated list of identifiers that are alphanumeric or numeric without leading +# zeroes. Written out because a version that cannot be compared must not be accepted as a heading. +SEMVER_NUMBER='(0|[1-9][0-9]*)' +SEMVER_IDENTIFIER='([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*|0|[1-9][0-9]*)' +SEMVER_PATTERN="${SEMVER_NUMBER}[.]${SEMVER_NUMBER}[.]${SEMVER_NUMBER}(-${SEMVER_IDENTIFIER}([.]${SEMVER_IDENTIFIER})*)?" + +fail() { + printf '%s: %s\n' "${SCRIPT_NAME}" "$1" >&2 + exit 1 +} + +usage() { + cat >&2 < [path] + ${SCRIPT_NAME} check-unreleased [path] +USAGE + exit 2 +} + +require_file() { + [ -f "$1" ] || fail "changelog not found: $1" +} + +# Print the path of a file as Git records it, so `git show :` can resolve it from +# anywhere inside the working tree. +repository_path() { + directory="$(CDPATH='' cd -- "$(dirname -- "$1")" && pwd)" + prefix="$(git -C "${directory}" rev-parse --show-prefix)" || + fail "not inside a Git repository: $1" + printf '%s%s\n' "${prefix}" "$(basename -- "$1")" +} + +# Compare two dot-separated prerelease strings. Prints -1, 0 or 1. +compare_prerelease() { + left_rest="$1" + right_rest="$2" + while :; do + # Prerelease identifiers are never empty, so an empty remainder means the string ended. + if [ -z "${left_rest}" ] && [ -z "${right_rest}" ]; then + printf '%s\n' 0 + return 0 + fi + if [ -z "${left_rest}" ]; then + printf '%s\n' -1 + return 0 + fi + if [ -z "${right_rest}" ]; then + printf '%s\n' 1 + return 0 + fi + left="${left_rest%%.*}" + right="${right_rest%%.*}" + case "${left_rest}" in *.*) left_rest="${left_rest#*.}" ;; *) left_rest='' ;; esac + case "${right_rest}" in *.*) right_rest="${right_rest#*.}" ;; *) right_rest='' ;; esac + left_numeric=no + right_numeric=no + case "${left}" in '' | *[!0-9]*) ;; *) left_numeric=yes ;; esac + case "${right}" in '' | *[!0-9]*) ;; *) right_numeric=yes ;; esac + if [ "${left_numeric}" = yes ] && [ "${right_numeric}" = yes ]; then + if [ "${left}" -gt "${right}" ]; then + printf '%s\n' 1 + return 0 + fi + if [ "${left}" -lt "${right}" ]; then + printf '%s\n' -1 + return 0 + fi + elif [ "${left_numeric}" = yes ]; then + # Numeric identifiers always have lower precedence than alphanumeric ones. + printf '%s\n' -1 + return 0 + elif [ "${right_numeric}" = yes ]; then + printf '%s\n' 1 + return 0 + elif [ "${left}" != "${right}" ]; then + lower="$(printf '%s\n%s\n' "${left}" "${right}" | LC_ALL=C sort | head -n 1)" + if [ "${lower}" = "${left}" ]; then + printf '%s\n' -1 + else + printf '%s\n' 1 + fi + return 0 + fi + done +} + +# Compare two Semantic Versioning 2.0.0 versions. Prints -1, 0 or 1. +compare_versions() { + left_core="${1%%-*}" + right_core="${2%%-*}" + case "$1" in *-*) left_prerelease="${1#*-}" ;; *) left_prerelease='' ;; esac + case "$2" in *-*) right_prerelease="${2#*-}" ;; *) right_prerelease='' ;; esac + field=1 + while [ "${field}" -le 3 ]; do + left="$(printf '%s' "${left_core}" | cut -d. -f"${field}")" + right="$(printf '%s' "${right_core}" | cut -d. -f"${field}")" + if [ "${left}" -gt "${right}" ]; then + printf '%s\n' 1 + return 0 + fi + if [ "${left}" -lt "${right}" ]; then + printf '%s\n' -1 + return 0 + fi + field=$((field + 1)) + done + if [ -z "${left_prerelease}" ] && [ -z "${right_prerelease}" ]; then + printf '%s\n' 0 + return 0 + fi + # A version with a prerelease has lower precedence than the same version without one. + if [ -z "${left_prerelease}" ]; then + printf '%s\n' 1 + return 0 + fi + if [ -z "${right_prerelease}" ]; then + printf '%s\n' -1 + return 0 + fi + compare_prerelease "${left_prerelease}" "${right_prerelease}" +} + +# Check everything that can be checked one line at a time, and print the released versions in the +# order they appear so the caller can check their ordering. +validate_structure() { + awk -v label="$2" -v semver="${SEMVER_PATTERN}" ' + BEGIN { + count = split("Added Changed Fixed Removed Security Deprecated", allowed, " ") + for (index_ = 1; index_ <= count; index_++) rank[allowed[index_]] = index_ + order = "Added, Changed, Fixed, Removed, Security, Deprecated" + date = "[0-9][0-9][0-9][0-9]-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])" + version_heading = "^## \\[" semver "\\] - " date "$" + } + + function days_in_month(year, month, lengths) { + split("31 28 31 30 31 30 31 31 30 31 30 31", lengths, " ") + if (month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) return 29 + return lengths[month] + 0 + } + + function problem(message) { + printf "%s:%d: %s\n", label, NR, message > "/dev/stderr" + failures++ + } + + function close_section() { + if (section != "" && entries == 0) { + printf "%s:%d: section \"### %s\" is empty\n", label, section_line, section > "/dev/stderr" + failures++ + } + section = "" + entries = 0 + } + + { if (sub(/\r$/, "")) carriage_returns++ } + + NR == 1 { + if ($0 != "# Changelog") problem("the first line must be \"# Changelog\"") + } + + # Only the introduction counts as the header, so the same links inside an entry cannot stand + # in for it. + headings == 0 && /keepachangelog\.com\/en\/1\.1\.0/ { keep_a_changelog = 1 } + headings == 0 && /semver\.org\/spec\/v2\.0\.0\.html/ { semantic_versioning = 1 } + + /^# / && NR > 1 { problem("only the first line may be a level 1 heading"); next } + + /^## / { + close_section() + if ($0 == "## [Unreleased]") { + unreleased++ + if (unreleased > 1) problem("there must be exactly one \"## [Unreleased]\" section") + if (headings > 0) problem("\"## [Unreleased]\" must be the first version section") + } else if ($0 ~ version_heading) { + if (unreleased == 0) problem("\"## [Unreleased]\" must be the first version section") + released = substr($0, length($0) - 9) + if (substr(released, 9, 2) + 0 > days_in_month(substr(released, 1, 4) + 0, substr(released, 6, 2) + 0)) { + problem(released " is not a date that exists") + } + version = $0 + sub(/^## \[/, "", version) + sub(/\].*$/, "", version) + if (version in seen) problem("duplicate section for version " version) + seen[version] = 1 + print version + } else { + problem("expected \"## [Unreleased]\" or \"## [X.Y.Z] - YYYY-MM-DD\", found: " $0) + } + headings++ + highest = 0 + next + } + + /^### / { + close_section() + if (headings == 0) { problem("\"" $0 "\" appears before any version section"); next } + name = substr($0, 5) + if (!(name in rank)) { + problem("unknown section \"### " name "\"; allowed sections are " order) + next + } + if (rank[name] <= highest) problem("\"### " name "\" is out of order; sections must appear as " order) + highest = rank[name] + section = name + section_line = NR + next + } + + /^#### / { problem("headings deeper than \"###\" are not used in this changelog"); next } + + { + if ($0 ~ /^[ \t]*$/) next + if ($0 ~ /^\[[^]]+\]: /) next # Keep a Changelog link reference definitions + if (headings == 0) next # introduction above the first version section + if (section == "") { problem("content must sit under a \"###\" section: " $0); next } + if ($0 ~ /^- ./) { entries++; next } + if ($0 ~ /^ +[^ ]/ && entries > 0) next # continuation of the preceding bullet + problem("every entry must be a \"- \" bullet: " $0) + } + + END { + close_section() + if (!keep_a_changelog) { + printf "%s: the header must link to Keep a Changelog 1.1.0\n", label > "/dev/stderr" + failures++ + } + if (!semantic_versioning) { + printf "%s: the header must link to Semantic Versioning 2.0.0\n", label > "/dev/stderr" + failures++ + } + if (unreleased == 0) { + printf "%s: an \"## [Unreleased]\" section is required\n", label > "/dev/stderr" + failures++ + } + if (carriage_returns > 0) { + printf "%s: the file uses CRLF line endings; write the changelog with LF\n", label > "/dev/stderr" + failures++ + } + if (failures > 0) exit 1 + } + ' "$1" +} + +command_validate() { + path="${1:-${DEFAULT_CHANGELOG}}" + require_file "${path}" + label="$(basename -- "${path}")" + versions="$(validate_structure "${path}" "${label}")" || + fail "${path} is not a valid changelog" + previous='' + while IFS= read -r version; do + [ -n "${version}" ] || continue + if [ -n "${previous}" ] && [ "$(compare_versions "${previous}" "${version}")" != 1 ]; then + fail "${path}: released sections must be in descending order, but ${previous} is listed above ${version}" + fi + previous="${version}" + done <= first && body[last] ~ /^[ \t]*$/) last-- + if (first > last) exit 5 + # Collapse runs of blank lines, which a skipped link reference can leave behind. + for (index_ = first; index_ <= last; index_++) { + if (body[index_] ~ /^[ \t]*$/) { + if (blank) continue + blank = 1 + } else { + blank = 0 + } + print body[index_] + } + } + ' "$1" +} + +command_extract() { + [ $# -ge 1 ] || usage + version="${1#v}" + path="${2:-${DEFAULT_CHANGELOG}}" + require_file "${path}" + status=0 + section_body "${path}" "${version}" yes || status=$? + case "${status}" in + 0) ;; + 3) fail "${path} has no section for version ${version}" ;; + 4) fail "${path}: the section for version ${version} has no release date; write it as \"## [${version}] - YYYY-MM-DD\"" ;; + 5) fail "${path}: the section for version ${version} is empty" ;; + *) fail "${path}: could not read the section for version ${version}" ;; + esac +} + +# Print the Unreleased section's body at one reference. Returns 1 when the file does not exist +# there, which the caller reads as "nothing to compare against". +unreleased_at_reference() { + directory="$1" + reference="$2" + tracked="$3" + content="$(mktemp)" + if ! git -C "${directory}" show "${reference}:${tracked}" >"${content}" 2>/dev/null; then + rm -f "${content}" + return 1 + fi + status=0 + section_body "${content}" Unreleased no || status=$? + rm -f "${content}" + # Exit 3 (missing) and 5 (empty) both mean "nothing recorded", which compares as empty. + case "${status}" in + 0 | 3 | 5) return 0 ;; + *) fail "could not read the \"## [Unreleased]\" section of ${tracked} at ${reference}" ;; + esac +} + +require_commit() { + git -C "$1" cat-file -e "$2^{commit}" 2>/dev/null || + fail "cannot resolve $3 reference: $2" +} + +command_check_unreleased() { + [ $# -ge 2 ] || usage + base="$1" + head="$2" + path="${3:-${DEFAULT_CHANGELOG}}" + # Every Git call runs inside the checkout that holds the changelog, so the subcommand works + # from any working directory. + directory="$(CDPATH='' cd -- "$(dirname -- "${path}")" && pwd)" || + fail "changelog directory not found: $(dirname -- "${path}")" + tracked="$(repository_path "${path}")" + # An unresolvable reference is a broken invocation, not a passing check. + require_commit "${directory}" "${base}" base + require_commit "${directory}" "${head}" head + # Compare against the commit the two references diverged from, not the tip of the base branch. + # Otherwise an entry another pull request added to Unreleased in the meantime makes the two + # sections differ, and a pull request that never touched the changelog passes. + fork_point="$(git -C "${directory}" merge-base "${base}" "${head}" 2>/dev/null)" || fork_point='' + [ -n "${fork_point}" ] || fork_point="${base}" + if ! base_body="$(unreleased_at_reference "${directory}" "${fork_point}" "${tracked}")"; then + printf 'No %s at %s; treating the Unreleased section as changed.\n' "${tracked}" "${fork_point}" + return 0 + fi + head_body="$(unreleased_at_reference "${directory}" "${head}" "${tracked}")" || + fail "${tracked} does not exist at ${head}" + if [ "${base_body}" = "${head_body}" ]; then + cat >&2 < +assert_status() { + name="$1" + expected="$2" + shift 2 + status=0 + "$@" >"${WORK}/stdout" 2>"${WORK}/stderr" || status=$? + if [ "${status}" -eq "${expected}" ]; then + report_pass "${name}" + else + report_failure "${name}" "expected status ${expected}, got ${status}" + sed 's/^/ /' "${WORK}/stderr" + fi +} + +# assert_output +assert_output() { + name="$1" + expected="$2" + shift 2 + status=0 + "$@" >"${WORK}/stdout" 2>"${WORK}/stderr" || status=$? + actual="$(cat "${WORK}/stdout")" + if [ "${status}" -ne 0 ]; then + report_failure "${name}" "command failed with status ${status}" + sed 's/^/ /' "${WORK}/stderr" + elif [ "${actual}" != "${expected}" ]; then + report_failure "${name}" "unexpected output" + printf ' expected: %s\n actual: %s\n' "${expected}" "${actual}" + else + report_pass "${name}" + fi +} + +# assert_message +# Asserting on the message as well as the status keeps two different rules from covering for +# each other when one of them is removed. +assert_message() { + name="$1" + expected="$2" + needle="$3" + shift 3 + status=0 + "$@" >"${WORK}/stdout" 2>"${WORK}/stderr" || status=$? + if [ "${status}" -ne "${expected}" ]; then + report_failure "${name}" "expected status ${expected}, got ${status}" + sed 's/^/ /' "${WORK}/stderr" + elif ! grep -qF "${needle}" "${WORK}/stderr"; then + report_failure "${name}" "stderr did not mention \"${needle}\"" + sed 's/^/ /' "${WORK}/stderr" + else + report_pass "${name}" + fi +} + +header() { + cat <<'HEADER' +# Changelog + +All notable changes to the experimental Agent platform will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +HEADER +} + +# fixture — reads the body from standard input and prints the fixture's path. +fixture() { + path="${WORK}/$1.md" + { + header + cat + } >"${path}" + printf '%s\n' "${path}" +} + +# ---------------------------------------------------------------- validate --- + +good="$(fixture good <<'BODY' + +## [Unreleased] + +### Added + +- `agentctl port-forward` forwards a local port into a running Agent. + +### Fixed + +- Installing on Windows no longer fails while verifying the archive. + +## [1.0.0] - 2026-09-01 + +### Changed + +- The hostname inside a Sandbox is the Agent name. + +## [1.0.0-rc.2] - 2026-08-20 + +### Added + +- Second release candidate. + +## [1.0.0-rc.1] - 2026-08-10 + +### Added + +- First release candidate. + +## [0.9.0] - 2026-08-01 + +### Added + +- First public preview. +BODY +)" + +assert_status 'validate accepts a well formed changelog' 0 "${CHANGELOG}" validate "${good}" + +empty_unreleased="$(fixture empty-unreleased <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. +BODY +)" +assert_status 'validate accepts an Unreleased section with no entries yet' 0 \ + "${CHANGELOG}" validate "${empty_unreleased}" + +no_header="${WORK}/no-header.md" +cat >"${no_header}" <<'BODY' +# Release notes + +## [Unreleased] + +### Added + +- Something. +BODY +assert_status 'validate rejects a missing header' 1 "${CHANGELOG}" validate "${no_header}" + +two_unreleased="$(fixture two-unreleased <<'BODY' + +## [Unreleased] + +### Added + +- Something. + +## [Unreleased] + +### Added + +- Something else. +BODY +)" +assert_status 'validate rejects two Unreleased sections' 1 "${CHANGELOG}" validate "${two_unreleased}" + +unreleased_late="$(fixture unreleased-late <<'BODY' + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. + +## [Unreleased] + +### Added + +- Something. +BODY +)" +assert_status 'validate rejects an Unreleased section that is not first' 1 \ + "${CHANGELOG}" validate "${unreleased_late}" + +undated="$(fixture undated <<'BODY' + +## [Unreleased] + +## [1.0.0] + +### Added + +- First release. +BODY +)" +assert_status 'validate rejects a released section with no date' 1 "${CHANGELOG}" validate "${undated}" + +bad_date="$(fixture bad-date <<'BODY' + +## [Unreleased] + +## [1.0.0] - 01.09.2026 + +### Added + +- First release. +BODY +)" +assert_status 'validate rejects a date that is not YYYY-MM-DD' 1 "${CHANGELOG}" validate "${bad_date}" + +out_of_order="$(fixture out-of-order <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-08-01 + +### Added + +- First release. + +## [1.1.0] - 2026-09-01 + +### Added + +- Second release. +BODY +)" +assert_status 'validate rejects released sections in ascending order' 1 \ + "${CHANGELOG}" validate "${out_of_order}" + +prerelease_order="$(fixture prerelease-order <<'BODY' + +## [Unreleased] + +## [1.0.0-rc.1] - 2026-08-10 + +### Added + +- Release candidate. + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. +BODY +)" +assert_status 'validate rejects a prerelease listed above its own release' 1 \ + "${CHANGELOG}" validate "${prerelease_order}" + +prerelease_numbers="$(fixture prerelease-numbers <<'BODY' + +## [Unreleased] + +## [1.0.0-preview.2] - 2026-09-01 + +### Added + +- Second preview. + +## [1.0.0-preview.10] - 2026-08-01 + +### Added + +- Tenth preview, released earlier by mistake. +BODY +)" +assert_status 'validate compares numeric prerelease identifiers numerically' 1 \ + "${CHANGELOG}" validate "${prerelease_numbers}" + +prerelease_lengths="$(fixture prerelease-lengths <<'BODY' + +## [Unreleased] + +## [1.0.0-alpha.1] - 2026-09-01 + +### Added + +- Second alpha. + +## [1.0.0-alpha] - 2026-08-01 + +### Added + +- First alpha. +BODY +)" +assert_status 'validate ranks a longer prerelease above a shorter prefix of it' 0 \ + "${CHANGELOG}" validate "${prerelease_lengths}" + +malformed_versions="$(fixture malformed-versions <<'BODY' + +## [Unreleased] + +## [01.0.0] - 2026-09-01 + +### Added + +- A core number with a leading zero. +BODY +)" +assert_status 'validate rejects a leading zero in a core version number' 1 \ + "${CHANGELOG}" validate "${malformed_versions}" + +empty_identifier="$(fixture empty-identifier <<'BODY' + +## [Unreleased] + +## [1.0.0-alpha..1] - 2026-09-01 + +### Added + +- An empty prerelease identifier. +BODY +)" +assert_status 'validate rejects an empty prerelease identifier' 1 \ + "${CHANGELOG}" validate "${empty_identifier}" + +numeric_leading_zero="$(fixture numeric-leading-zero <<'BODY' + +## [Unreleased] + +## [1.0.0-preview.01] - 2026-09-01 + +### Added + +- A numeric prerelease identifier with a leading zero. +BODY +)" +assert_status 'validate rejects a leading zero in a numeric prerelease identifier' 1 \ + "${CHANGELOG}" validate "${numeric_leading_zero}" + +hyphenated_prerelease="$(fixture hyphenated-prerelease <<'BODY' + +## [Unreleased] + +## [1.0.0-rc-1.2] - 2026-09-01 + +### Added + +- A hyphenated alphanumeric prerelease identifier. +BODY +)" +assert_status 'validate accepts hyphens inside a prerelease identifier' 0 \ + "${CHANGELOG}" validate "${hyphenated_prerelease}" + +section_order="$(fixture section-order <<'BODY' + +## [Unreleased] + +### Fixed + +- Something. + +### Added + +- Something else. +BODY +)" +assert_status 'validate rejects sections in the wrong order' 1 "${CHANGELOG}" validate "${section_order}" + +unknown_section="$(fixture unknown-section <<'BODY' + +## [Unreleased] + +### Improved + +- Something. +BODY +)" +assert_status 'validate rejects an unknown section' 1 "${CHANGELOG}" validate "${unknown_section}" + +empty_section="$(fixture empty-section <<'BODY' + +## [Unreleased] + +### Added + +### Fixed + +- Something. +BODY +)" +assert_status 'validate rejects an empty section' 1 "${CHANGELOG}" validate "${empty_section}" + +loose_text="$(fixture loose-text <<'BODY' + +## [Unreleased] + +### Added + +Something happened. +BODY +)" +assert_status 'validate rejects an entry that is not a bullet' 1 "${CHANGELOG}" validate "${loose_text}" + +continuation="$(fixture continuation <<'BODY' + +## [Unreleased] + +### Added + +- Something happened, and the explanation + continues on the next line. +BODY +)" +assert_status 'validate accepts an indented continuation line' 0 "${CHANGELOG}" validate "${continuation}" + +duplicate_version="$(fixture duplicate-version <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. + +## [1.0.0] - 2026-08-01 + +### Added + +- The same version again. +BODY +)" +assert_message 'validate rejects a duplicate released version' 1 'duplicate section for version 1.0.0' \ + "${CHANGELOG}" validate "${duplicate_version}" + +deep_heading="$(fixture deep-heading <<'BODY' + +## [Unreleased] + +### Added + +- Something. + +#### Details + +- More. +BODY +)" +assert_message 'validate rejects a heading deeper than "###"' 1 'deeper than' \ + "${CHANGELOG}" validate "${deep_heading}" + +outside_section="$(fixture outside-section <<'BODY' + +## [Unreleased] + +Some prose before any category. + +### Added + +- Something. +BODY +)" +assert_message 'validate rejects content outside a "###" section' 1 'must sit under' \ + "${CHANGELOG}" validate "${outside_section}" + +bad_month="$(fixture bad-month <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-13-45 + +### Added + +- An impossible date. +BODY +)" +assert_status 'validate rejects an impossible month and day' 1 "${CHANGELOG}" validate "${bad_month}" + +impossible_dates="$(fixture impossible-dates <<'BODY' + +## [Unreleased] + +## [1.0.2] - 2026-02-31 + +### Added + +- The 31st of February. +BODY +)" +assert_message 'validate rejects a day past the end of the month' 1 '2026-02-31 is not a date' \ + "${CHANGELOG}" validate "${impossible_dates}" + +short_month="$(fixture short-month <<'BODY' + +## [Unreleased] + +## [1.0.1] - 2026-04-31 + +### Added + +- The 31st of April. +BODY +)" +assert_message 'validate rejects the 31st of a 30-day month' 1 '2026-04-31 is not a date' \ + "${CHANGELOG}" validate "${short_month}" + +common_year="$(fixture common-year <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2025-02-29 + +### Added + +- The 29th of February in a common year. +BODY +)" +assert_message 'validate rejects 29 February outside a leap year' 1 '2025-02-29 is not a date' \ + "${CHANGELOG}" validate "${common_year}" + +leap_years="$(fixture leap-years <<'BODY' + +## [Unreleased] + +## [2.0.0] - 2024-02-29 + +### Added + +- A leap year divisible by four. + +## [1.0.0] - 2000-02-29 + +### Added + +- A leap year divisible by four hundred. +BODY +)" +assert_status 'validate accepts 29 February in a leap year' 0 "${CHANGELOG}" validate "${leap_years}" + +century="$(fixture century <<'BODY' + +## [Unreleased] + +## [1.0.0] - 1900-02-29 + +### Added + +- A century that is not a leap year. +BODY +)" +assert_message 'validate rejects 29 February in a non-leap century' 1 '1900-02-29 is not a date' \ + "${CHANGELOG}" validate "${century}" + +header_in_entry="${WORK}/header-in-entry.md" +cat >"${header_in_entry}" <<'BODY' +# Changelog + +## [Unreleased] + +### Added + +- A link to https://keepachangelog.com/en/1.1.0/ and https://semver.org/spec/v2.0.0.html in an entry. +BODY +assert_message 'validate does not accept header links found inside an entry' 1 'Keep a Changelog' \ + "${CHANGELOG}" validate "${header_in_entry}" + +link_references="$(fixture link-references <<'BODY' + +## [Unreleased] + +### Added + +- Something. + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. + +[Unreleased]: https://example.com/compare/1.0.0...HEAD +[1.0.0]: https://example.com/releases/1.0.0 +BODY +)" +assert_status 'validate accepts Keep a Changelog link reference definitions' 0 \ + "${CHANGELOG}" validate "${link_references}" +assert_output 'extract leaves link reference definitions out of the body' \ + '### Added + +- First release.' \ + "${CHANGELOG}" extract 1.0.0 "${link_references}" + +# A link reference between categories must not truncate the section. +interleaved="$(fixture interleaved-link <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +### Added + +- Something. + +[1.0.0]: https://example.com/releases/1.0.0 + +### Fixed + +- Something else. +BODY +)" +assert_output 'extract keeps categories that follow a link reference' \ + '### Added + +- Something. + +### Fixed + +- Something else.' \ + "${CHANGELOG}" extract 1.0.0 "${interleaved}" + +crlf="${WORK}/crlf.md" +sed 's/$/\r/' "${good}" >"${crlf}" +assert_message 'validate reports CRLF line endings' 1 'CRLF' "${CHANGELOG}" validate "${crlf}" + +assert_status 'validate reports a missing file' 1 "${CHANGELOG}" validate "${WORK}/absent.md" + +# ----------------------------------------------------------------- extract --- + +assert_output 'extract prints a released section without its heading' \ + '### Changed + +- The hostname inside a Sandbox is the Agent name.' \ + "${CHANGELOG}" extract 1.0.0 "${good}" + +assert_output 'extract accepts a leading v' \ + '### Added + +- First release candidate.' \ + "${CHANGELOG}" extract v1.0.0-rc.1 "${good}" + +assert_message 'extract fails for a missing version' 1 'no section for version 2.0.0' \ + "${CHANGELOG}" extract 2.0.0 "${good}" +assert_message 'extract fails for an undated version' 1 'has no release date' \ + "${CHANGELOG}" extract 1.0.0 "${undated}" +assert_message 'extract rejects an undated section by name' 1 'has no release date' \ + "${CHANGELOG}" extract Unreleased "${good}" + +dated_but_empty="$(fixture dated-but-empty <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +## [0.9.0] - 2026-08-01 + +### Added + +- First release. +BODY +)" +assert_message 'extract rejects a dated section with no content' 1 'is empty' \ + "${CHANGELOG}" extract 1.0.0 "${dated_but_empty}" + +# -------------------------------------------------------- check-unreleased --- + +REPOSITORY="${WORK}/repository" +mkdir -p "${REPOSITORY}/src/experimental" +git -C "${REPOSITORY}" init --quiet +git -C "${REPOSITORY}" config user.email changelog-test@example.com +git -C "${REPOSITORY}" config user.name 'Changelog Test' +tracked="${REPOSITORY}/src/experimental/CHANGELOG.md" + +git -C "${REPOSITORY}" commit --quiet --allow-empty -m 'empty' +empty_base="$(git -C "${REPOSITORY}" rev-parse HEAD)" + +cp "${good}" "${tracked}" +git -C "${REPOSITORY}" add -A +git -C "${REPOSITORY}" commit --quiet -m 'add changelog' +base="$(git -C "${REPOSITORY}" rev-parse HEAD)" + +assert_status 'check-unreleased treats a missing base file as changed' 0 \ + "${CHANGELOG}" check-unreleased "${empty_base}" "${base}" "${tracked}" + +# A change that leaves the Unreleased section alone. +printf '\n' >>"${tracked}" +git -C "${REPOSITORY}" commit --quiet -a -m 'unrelated change' +unchanged="$(git -C "${REPOSITORY}" rev-parse HEAD)" +assert_status 'check-unreleased fails when the Unreleased section is untouched' 1 \ + "${CHANGELOG}" check-unreleased "${base}" "${unchanged}" "${tracked}" + +# A change that adds an entry. +awk '{ print } /^## \[Unreleased\]$/ { print ""; print "### Changed"; print ""; print "- Another entry." }' \ + "${tracked}" >"${tracked}.next" +mv "${tracked}.next" "${tracked}" +git -C "${REPOSITORY}" commit --quiet -a -m 'add an entry' +changed="$(git -C "${REPOSITORY}" rev-parse HEAD)" +assert_status 'check-unreleased passes when an entry is added' 0 \ + "${CHANGELOG}" check-unreleased "${base}" "${changed}" "${tracked}" + +# An entry another pull request added to the base branch after this branch forked must not +# satisfy the check. +git -C "${REPOSITORY}" checkout --quiet -b feature "${base}" +printf 'code\n' >"${REPOSITORY}/code.txt" +git -C "${REPOSITORY}" add -A +git -C "${REPOSITORY}" commit --quiet -m 'change code only' +feature="$(git -C "${REPOSITORY}" rev-parse HEAD)" +git -C "${REPOSITORY}" checkout --quiet main 2>/dev/null || git -C "${REPOSITORY}" checkout --quiet master +moved_on="$(git -C "${REPOSITORY}" rev-parse HEAD)" +assert_status 'check-unreleased ignores entries the base branch gained after the fork' 1 \ + "${CHANGELOG}" check-unreleased "${moved_on}" "${feature}" "${tracked}" + +assert_message 'check-unreleased rejects an unresolvable base reference' 1 'cannot resolve base' \ + "${CHANGELOG}" check-unreleased no-such-ref "${feature}" "${tracked}" +assert_message 'check-unreleased rejects an unresolvable head reference' 1 'cannot resolve head' \ + "${CHANGELOG}" check-unreleased "${base}" no-such-ref "${tracked}" + +# -------------------------------------------------------------------------- # + +printf '\n%d checks, %d failures\n' "${checks}" "${failures}" +[ "${failures}" -eq 0 ] diff --git a/agentctl/examples/minimal/.dockerignore b/agentctl/examples/minimal/.dockerignore new file mode 100644 index 0000000..bdb2e3b --- /dev/null +++ b/agentctl/examples/minimal/.dockerignore @@ -0,0 +1,3 @@ +* +!Dockerfile +!claude-state.json diff --git a/agentctl/examples/minimal/Dockerfile b/agentctl/examples/minimal/Dockerfile new file mode 100644 index 0000000..960c96b --- /dev/null +++ b/agentctl/examples/minimal/Dockerfile @@ -0,0 +1,76 @@ +FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + curl \ + git \ + nodejs \ + npm \ + ripgrep \ + sudo \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +ARG TARGETARCH +ARG GH_VERSION=2.98.0 +ARG GH_STACK_VERSION=0.1.1 + +RUN case "${TARGETARCH}" in \ + amd64) GH_SHA256=3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de; \ + GH_STACK_SHA256=9ed103934fab0f90d3341fdfc4a342785396d39f5621fc7313a62602ce2b5462 ;; \ + arm64) GH_SHA256=cf689084f3a3618f7eae4a2420d335d74626d65f5e594b9828d125d69f800d86; \ + GH_STACK_SHA256=2da13f8c46f2770237c744b341ab6be9f07508585a6762634c4a88aa355460bc ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && GH_ARCHIVE="gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz" \ + && curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${GH_ARCHIVE}" -o /tmp/gh.tar.gz \ + && echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && install -m 0755 "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}/bin/gh" /usr/local/bin/gh \ + && curl -fsSL \ + "https://github.com/github/gh-stack/releases/download/v${GH_STACK_VERSION}/linux-${TARGETARCH}" \ + -o /tmp/gh-stack \ + && echo "${GH_STACK_SHA256} /tmp/gh-stack" | sha256sum -c - \ + && rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}" \ + && gh --version + +ARG CLAUDE_CODE_VERSION=2.1.286 + +# Agent Sandboxes mount their CA bundle here; this step's Node downloads trust it. +RUN if [ -r /run/agent/tls/ca-bundle.pem ]; then \ + export NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem; \ + fi; \ + npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + && usermod --login agent --home /home/agent --move-home ubuntu \ + && groupmod --new-name agent ubuntu \ + && printf 'agent ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/agent \ + && chmod 0440 /etc/sudoers.d/agent \ + && install -d -m 0755 -o agent -g agent \ + /home/agent/.claude /home/agent/.local/share/gh/extensions/gh-stack /home/agent/code \ + && install -m 0755 -o agent -g agent \ + /tmp/gh-stack /home/agent/.local/share/gh/extensions/gh-stack/gh-stack \ + && printf '%s\n' \ + 'owner: github' \ + 'name: gh-stack' \ + 'host: github.com' \ + "tag: v${GH_STACK_VERSION}" \ + 'ispinned: true' \ + 'path: /home/agent/.local/share/gh/extensions/gh-stack/gh-stack' \ + >/home/agent/.local/share/gh/extensions/gh-stack/manifest.yml \ + && chown -R agent:agent /home/agent/.local \ + && rm -f /tmp/gh-stack + +ENV DISABLE_AUTOUPDATER=1 +ENV GH_NO_UPDATE_NOTIFIER=1 +ENV GH_NO_EXTENSION_UPDATE_NOTIFIER=1 +ENV HOME=/home/agent +ENV PATH=/home/agent/.local/bin:${PATH} + +COPY --chown=agent:agent --chmod=0600 claude-state.json /home/agent/.claude/.claude.json + +USER agent +WORKDIR /home/agent/code diff --git a/agentctl/examples/minimal/README.md b/agentctl/examples/minimal/README.md new file mode 100644 index 0000000..553e4be --- /dev/null +++ b/agentctl/examples/minimal/README.md @@ -0,0 +1,39 @@ +# Minimal Agent + +This manifest-secret-free example exercises the core Agent lifecycle with mediated Claude Code authentication and one +or more persistent tmux sessions. It does not require a `.env` file or expose GitHub and +Altinn Studio secrets to the network mediator. Its small local Dockerfile contains only the tools needed for this +flow on the multi-platform Ubuntu 26.04 LTS base, and its layered root filesystem keeps the smoke-test sandbox +capacity-efficient. Sessions start in the platform's stable `/home/agent/code` workspace root; this example is +intentionally repository-free and uses the +Sandbox Provider's backend init instead of an image entrypoint. A builder that needs a boot-time checkout should use an +image init/entrypoint like the published `agents/` images; the self-development example offers both a boot-time clone and a +bind-mounted host checkout. Sessions can instead clone repositories on demand when their +Agent declares a suitable mediated secret. It is not intended for running Docker inside the Agent. + +```sh +agentctl claude login +agentctl apply --name agent-test --wait +agentctl get agent agent-test +agentctl describe agent/agent-test +agentctl wait --for=condition=Ready agent/agent-test --timeout=10m +agentctl exec agent/agent-test -- pwd +agentctl exec -it agent/agent-test -- bash +agentctl attach session/s1 --agent agent-test +agentctl get sessions --agent agent-test +``` + +`--wait` keeps `apply` attached and streams provisioning progress until the Agent is Ready; `wait` +does the same for an Agent that was applied earlier. Both stop with an error when desired state +is invalid and otherwise follow background retries until the timeout. + +When the current directory is inside the source directory of exactly one applied Agent, `agentctl exec -- pwd` and +Session commands infer the Agent; for example, `agentctl attach session/s1` works from this directory after applying +without another Agent name from the same source. + +Run these commands from this directory so paths in the manifest resolve against the intended example inputs. +The image seeds Claude's mutable `.claude.json` once for first-run prompts. Configuration intentionally placed in the +`home/` source is reapplied every reconciliation pass instead; that is appropriate for builder-owned declarative files +such as a Codex `config.toml`, and is also available when continuous ownership of Claude state is desired. +The builder-wide `instructions.md` payload is declared through `spec.instructions`; the Claude adapter installs it as +`~/.claude/CLAUDE.md`. diff --git a/agentctl/examples/minimal/agent.yaml b/agentctl/examples/minimal/agent.yaml new file mode 100644 index 0000000..26e2d45 --- /dev/null +++ b/agentctl/examples/minimal/agent.yaml @@ -0,0 +1,32 @@ +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: altinn-studio +spec: + sandbox: + image: + type: build + context: . + dockerfile: Dockerfile + platform: + os: linux + resources: + cpu: "4" + memory: "8Gi" + rootFilesystem: + capacity: "64Gi" + mode: layered + home: + source: home + instructions: + - source: instructions.md + harnesses: + - type: claudeCode + auth: mediated + # The mediated token cannot list Fable in the /model picker, so new Sessions select it at launch. + defaults: + model: fable + secrets: [] + network: + mode: mediated + allow: all diff --git a/agentctl/examples/minimal/claude-state.json b/agentctl/examples/minimal/claude-state.json new file mode 100644 index 0000000..e2ab4b2 --- /dev/null +++ b/agentctl/examples/minimal/claude-state.json @@ -0,0 +1,9 @@ +{ + "hasCompletedOnboarding": true, + "bypassPermissionsModeAccepted": true, + "projects": { + "/home/agent/code": { + "hasTrustDialogAccepted": true + } + } +} diff --git a/agentctl/examples/minimal/home/.gitkeep b/agentctl/examples/minimal/home/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agentctl/examples/minimal/instructions.md b/agentctl/examples/minimal/instructions.md new file mode 100644 index 0000000..d8a04a6 --- /dev/null +++ b/agentctl/examples/minimal/instructions.md @@ -0,0 +1,11 @@ +# Agent home + +Sessions start in `/home/agent/code`. Select or create a repository beneath that directory, follow its `AGENTS.md` +files before changing code, keep changes tied to the requested outcome, and run the closest relevant checks before +reporting completion. + +This example intentionally has no boot-time repository checkout. You may clone a repository on demand only when the +Agent is configured with GitHub access; use the installed `gh repo clone OWNER/REPOSITORY` command. Preserve existing +workspaces; never delete and reclone one as a retry strategy. + +Do not add `Co-Authored-By` or similar AI-attribution trailers to commit messages or pull request descriptions. diff --git a/agentctl/examples/self-dev/.dockerignore b/agentctl/examples/self-dev/.dockerignore new file mode 100644 index 0000000..e920afe --- /dev/null +++ b/agentctl/examples/self-dev/.dockerignore @@ -0,0 +1,9 @@ +* +!Dockerfile +!nvim-sysinit.vim +!ssh.service +!ssh-tmpfiles.conf +!sshd_config +!tmpfiles.conf +!workspace-init.service +!workspace-init.sh diff --git a/agentctl/examples/self-dev/.env.sample b/agentctl/examples/self-dev/.env.sample new file mode 100644 index 0000000..509b068 --- /dev/null +++ b/agentctl/examples/self-dev/.env.sample @@ -0,0 +1,5 @@ +# Git identity enters the Sandbox in plaintext. The GitHub token remains mediated. + +GIT_USER_NAME=Your Name +GIT_USER_EMAIL=you@example.com +GITHUB_TOKEN= diff --git a/agentctl/examples/self-dev/.gitignore b/agentctl/examples/self-dev/.gitignore new file mode 100644 index 0000000..b1c9d3a --- /dev/null +++ b/agentctl/examples/self-dev/.gitignore @@ -0,0 +1,6 @@ +# Checkout-local Agent variants are private by default. +agent.*.yaml + +# Repository-owned variants. +!agent.nested.yaml +!agent.worktree.yaml diff --git a/agentctl/examples/self-dev/Dockerfile b/agentctl/examples/self-dev/Dockerfile new file mode 100644 index 0000000..dbae420 --- /dev/null +++ b/agentctl/examples/self-dev/Dockerfile @@ -0,0 +1,199 @@ +FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b + +# The repository's rust-toolchain.toml selects the toolchain inside a checkout, and Renovate moves +# this ARG together with it. The agent user (the image's ubuntu user until it is renamed below) +# owns RUSTUP_HOME, so a checkout pinned ahead of this image installs its toolchain on first use +# instead of failing. +ARG RUST_VERSION=1.97.1 +ARG CARGO_MACHETE_VERSION=0.9.2 + +ENV DEBIAN_FRONTEND=noninteractive +ENV RUSTUP_HOME=/usr/local/rustup +ENV PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# The toolchain the agent platform's own Rust workspace needs (libcap-ng-dev links the Microsandbox +# runtime), plus Podman so that a nested agentd can build Agent images through the Docker Engine API. +# Podman's netavark backend invokes nft for rootful bridge networking and aardvark-dns for +# container-name resolution. +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + aardvark-dns \ + build-essential \ + ca-certificates \ + clang \ + curl \ + fonts-jetbrains-mono \ + fonts-liberation \ + git \ + iproute2 \ + jq \ + libcap-ng-dev \ + libssl-dev \ + lldb \ + linux-perf \ + neovim \ + nftables \ + openssh-server \ + pkg-config \ + podman \ + podman-docker \ + procps \ + ripgrep \ + rustup \ + strace \ + sudo \ + systemd \ + tmux \ + && rm -rf /var/lib/apt/lists/* \ + && nvim --version \ + && CARGO_HOME=/usr/local/cargo rustup set profile minimal \ + && CARGO_HOME=/usr/local/cargo rustup default "${RUST_VERSION}" \ + && CARGO_HOME=/usr/local/cargo rustup component add clippy rustfmt \ + && CARGO_HOME=/usr/local/cargo cargo install cargo-machete --version "${CARGO_MACHETE_VERSION}" --locked \ + && rm -rf /usr/local/cargo/registry /usr/local/cargo/git \ + && chmod -R a+rX /usr/local/cargo \ + && chown -R ubuntu:ubuntu /usr/local/rustup + +COPY nvim-sysinit.vim /etc/xdg/nvim/sysinit.vim + +RUN nvim --headless \ + "+lua assert(vim.g.colors_name == 'habamax'); assert(vim.o.number); assert(vim.o.cursorline); assert(vim.o.termguicolors)" \ + +quit + +ARG TARGETARCH +ARG GH_VERSION=2.100.0 +ARG GH_STACK_VERSION=0.1.1 +ARG ASCIINEMA_VERSION=3.2.1 +ARG AGG_VERSION=1.9.0 +ARG NODE_VERSION=22.23.2 + +RUN case "${TARGETARCH}" in \ + amd64) NODE_ARCH=x64; RUST_ARCH=x86_64; \ + GH_SHA256=e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be; \ + GH_STACK_SHA256=9ed103934fab0f90d3341fdfc4a342785396d39f5621fc7313a62602ce2b5462; \ + ASCIINEMA_SHA256=1b405bbda565b33c3c4718de67fedc3535580603c0694b1ff3fb04f363430a20; \ + AGG_SHA256=f111e315cd71056b116302342553dd765b7297579ed511f111d0cedb442aeda6 ;; \ + arm64) NODE_ARCH=arm64; RUST_ARCH=aarch64; \ + GH_SHA256=ea4e7a581a32ccad6cc7923cb1576ac5859ba4b9a16ab22eb8f8a96e78e2e961; \ + GH_STACK_SHA256=2da13f8c46f2770237c744b341ab6be9f07508585a6762634c4a88aa355460bc; \ + ASCIINEMA_SHA256=b516a6d896844c0ffbc96e0a55afe4cbcc79216abde0fc64fdda4e39bee421ea; \ + AGG_SHA256=2b4be407b97e00e1c313a41d154ced8fa3d02c560c8f47a0db4950a2576444c9 ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && NODE_ARCHIVE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + && NODE_SHA256="$(curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/SHASUMS256.txt" \ + | awk -v filename="${NODE_ARCHIVE}" '$2 == filename { print $1 }')" \ + && test -n "${NODE_SHA256}" \ + && curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/${NODE_ARCHIVE}" -o /tmp/node.tar.xz \ + && echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \ + && tar -xJf /tmp/node.tar.xz --strip-components=1 -C /usr/local \ + && GH_ARCHIVE="gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz" \ + && curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${GH_ARCHIVE}" -o /tmp/gh.tar.gz \ + && echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && install -m 0755 "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}/bin/gh" /usr/local/bin/gh \ + && curl -fsSL \ + "https://github.com/github/gh-stack/releases/download/v${GH_STACK_VERSION}/linux-${TARGETARCH}" \ + -o /tmp/gh-stack \ + && echo "${GH_STACK_SHA256} /tmp/gh-stack" | sha256sum -c - \ + && curl -fsSL \ + "https://github.com/asciinema/asciinema/releases/download/v${ASCIINEMA_VERSION}/asciinema-${RUST_ARCH}-unknown-linux-gnu" \ + -o /tmp/asciinema \ + && echo "${ASCIINEMA_SHA256} /tmp/asciinema" | sha256sum -c - \ + && install -m 0755 /tmp/asciinema /usr/local/bin/asciinema \ + && curl -fsSL \ + "https://github.com/asciinema/agg/releases/download/v${AGG_VERSION}/agg-${RUST_ARCH}-unknown-linux-gnu" \ + -o /tmp/agg \ + && echo "${AGG_SHA256} /tmp/agg" | sha256sum -c - \ + && install -m 0755 /tmp/agg /usr/local/bin/agg \ + && rm -rf /tmp/node.tar.xz /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}" /tmp/asciinema /tmp/agg \ + && node --version \ + && gh --version \ + && asciinema --version \ + && agg --version + +# Route GitHub credentials through the gh CLI so plain git commands authenticate with the +# host-mediated token. +RUN git config --system credential.https://github.com.helper '!/usr/local/bin/gh auth git-credential' + +ARG CODEX_VERSION=0.159.3 +ARG CLAUDE_CODE_VERSION=2.1.286 + +# Node ignores the system trust store, and Buildah drops default environment from build stages, +# so a build inside an Agent needs the system store selected per step for npm to trust mediation. +RUN NODE_OPTIONS=--use-openssl-ca npm install --global "@openai/codex@${CODEX_VERSION}" \ + && NODE_OPTIONS=--use-openssl-ca npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + && claude --version \ + && codex --version + +# A nested Microsandbox runtime needs /dev/kvm; tmpfiles.conf hands the device to the kvm group. +# OpenSSH rejects even public-key authentication for a locked account when PAM is disabled. +# Password and keyboard authentication remain disabled by the platform-owned SSH policy. +RUN { getent group kvm >/dev/null || groupadd --system kvm; } \ + && usermod --login agent --home /home/agent --move-home ubuntu \ + && passwd --delete agent \ + && groupmod --new-name agent ubuntu \ + && usermod --append --groups kvm agent \ + && printf 'agent ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/agent \ + && chmod 0440 /etc/sudoers.d/agent \ + && install -d -m 0755 -o agent -g agent \ + /home/agent/.local/share/gh/extensions/gh-stack /home/agent/code \ + && install -m 0755 -o agent -g agent \ + /tmp/gh-stack /home/agent/.local/share/gh/extensions/gh-stack/gh-stack \ + && printf '%s\n' \ + 'owner: github' \ + 'name: gh-stack' \ + 'host: github.com' \ + "tag: v${GH_STACK_VERSION}" \ + 'ispinned: true' \ + 'path: /home/agent/.local/share/gh/extensions/gh-stack/gh-stack' \ + >/home/agent/.local/share/gh/extensions/gh-stack/manifest.yml \ + && chown -R agent:agent /home/agent/.local \ + && rm -f /tmp/gh-stack \ + && rm -f /etc/machine-id /var/lib/dbus/machine-id \ + && touch /etc/machine-id + +COPY tmpfiles.conf /usr/lib/tmpfiles.d/agent-dev.conf +COPY workspace-init.service /etc/systemd/system/agent-workspace-init.service +COPY --chmod=0755 workspace-init.sh /usr/local/libexec/agent-workspace-init + +# agentd owns the loopback SSH server policy, unit and per-Agent state. The distribution's own +# ssh.service and socket are masked so the image cannot expose its package-generated host keys. +RUN systemctl mask ssh.service ssh.socket \ + && rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub + +# All guest Executions inherit init.scope, whose default task limit is too low +# for concurrent harnesses. The kernel pid limit remains the Sandbox boundary. +# The guest also has no udev to activate hvc0's device unit, so its generated +# serial login would otherwise wait 90 seconds at boot. +# +# The kernel's inotify defaults suit one interactive desktop, not a guest running +# concurrent harnesses, nested Agents and build tooling, each with its own file +# watchers. These values match the ones the self-hosted CI runners raise in +# src/ci/github-runner/run.sh. +RUN install -d /etc/systemd/system/init.scope.d \ + && printf '[Scope]\nTasksMax=infinity\n' >/etc/systemd/system/init.scope.d/50-agent-tasks.conf \ + && install -d /usr/lib/sysctl.d \ + && printf 'fs.inotify.max_user_instances = 1024\nfs.inotify.max_user_watches = 1048576\nfs.inotify.max_queued_events = 32768\n' \ + >/usr/lib/sysctl.d/50-agent-inotify.conf \ + && systemctl mask serial-getty@hvc0.service \ + && systemctl enable agent-workspace-init.service + +# Keep Cargo's registry and build output on the Sandbox root filesystem so a worktree mount does +# not share target state with the host and a fresh checkout keeps reusable artifacts outside Git. +ENV CARGO_HOME=/home/agent/.cargo +ENV CARGO_TARGET_DIR=/home/agent/.cache/cargo-target +# The Sandbox Image Backend speaks the Docker Engine API; point it at the Podman socket the +# platform enables and grants to the agent user. +ENV DOCKER_HOST=unix:///run/podman/podman.sock +ENV DISABLE_AUTOUPDATER=1 +ENV GH_NO_UPDATE_NOTIFIER=1 +ENV GH_NO_EXTENSION_UPDATE_NOTIFIER=1 +ENV HOME=/home/agent +ENV PATH=/home/agent/.local/bin:/home/agent/.cargo/bin:${PATH} + +USER agent +WORKDIR /home/agent/code + +ENTRYPOINT ["/usr/lib/systemd/systemd"] diff --git a/agentctl/examples/self-dev/README.md b/agentctl/examples/self-dev/README.md new file mode 100644 index 0000000..1bece4c --- /dev/null +++ b/agentctl/examples/self-dev/README.md @@ -0,0 +1,40 @@ +# Agent platform self-development Agent + +This Agent develops the Agent platform itself, under `src/experimental`. + +| Variant | Checkout | Resources | +| --- | --- | --- | +| default (`agent.yaml`) | Fresh `Altinn/altinn-studio` clone made at boot | Normal | +| `nested` | Fresh clone | Reduced to fit inside the default Agent | +| `worktree` | Current host checkout mounted read-write | Normal | + +Every variant builds the directory's `Dockerfile` locally. Self-development images are not published to GHCR. + +```sh +make -C src/experimental user-install +agentctl claude login +cd src/experimental/agent/examples/self-dev +mkdir -p ~/.agent +cp .env.sample ~/.agent/self-dev.env +agentctl apply --env-file ~/.agent/self-dev.env --wait +agentctl attach session/s1 +``` + +For the reduced nested variant: + +```sh +agentctl apply --variant nested --env-file ~/.agent/self-dev.env --wait +agentctl create session/s1 --variant nested --harness codex +``` + +The worktree variant requires an environment file outside the mounted checkout: + +```sh +agentctl apply --variant worktree --env-file ~/.agent/self-dev.env +``` + +Ignored local variants such as `agent.mine.yaml` may extend another sibling variant. Keep their credentials outside +the mounted checkout. + +Inside a running Agent, `instructions.md` tells the harness how to build, test and run the platform nested, and the +`pr-evidence` skill how to record `agentctl` demonstrations and attach them to pull requests. diff --git a/agentctl/examples/self-dev/agent.nested.yaml b/agentctl/examples/self-dev/agent.nested.yaml new file mode 100644 index 0000000..4326575 --- /dev/null +++ b/agentctl/examples/self-dev/agent.nested.yaml @@ -0,0 +1,15 @@ +apiVersion: agents.platform/v1alpha1 +kind: AgentVariant +extends: agent.yaml + +metadata: + name: agent-dev-nested + +spec: + sandbox: + resources: + cpu: "2" + memory: "3Gi" + rootFilesystem: + capacity: "16Gi" + mode: direct diff --git a/agentctl/examples/self-dev/agent.worktree.yaml b/agentctl/examples/self-dev/agent.worktree.yaml new file mode 100644 index 0000000..3850774 --- /dev/null +++ b/agentctl/examples/self-dev/agent.worktree.yaml @@ -0,0 +1,17 @@ +apiVersion: agents.platform/v1alpha1 +kind: AgentVariant +extends: agent.yaml + +metadata: + name: agent-dev-worktree + +spec: + sandbox: + mounts: + - type: bind + source: ../../../../.. + target: /home/agent/code/altinn-studio + readOnly: false + - type: tmpfs + target: /tmp + capacity: "2Gi" diff --git a/agentctl/examples/self-dev/agent.yaml b/agentctl/examples/self-dev/agent.yaml new file mode 100644 index 0000000..0642fee --- /dev/null +++ b/agentctl/examples/self-dev/agent.yaml @@ -0,0 +1,57 @@ +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: agent-dev +spec: + sandbox: + image: + type: build + context: . + dockerfile: Dockerfile + platform: + os: linux + resources: + cpu: "4" + memory: "8Gi" + rootFilesystem: + capacity: "64Gi" + mode: direct + initSystem: image + mounts: + - type: tmpfs + target: /tmp + capacity: "2Gi" + home: + source: home + instructions: + - source: instructions.md + skills: + - source: skills/pr-evidence + harnesses: + - type: claudeCode + auth: mediated + default: true + # The mediated token cannot list Fable in the /model picker, so new Sessions select it at launch. + defaults: + model: fable + - type: codex + auth: mediated + environment: + - name: GIT_USER_NAME + - name: GIT_USER_EMAIL + secrets: + - environment: GITHUB_TOKEN + # gh only attaches files with a token it classifies by prefix; the inert placeholder + # therefore carries the fine-grained PAT prefix and is far shorter than a real token. + placeholder: github_pat_AGENT_MEDIATED_GITHUB_TOKEN + allowedHosts: + - github.com + - api.github.com + - uploads.github.com + access: + - type: ssh + network: + mode: mediated + allow: all + deny: + - metadata.google.internal diff --git a/agentctl/examples/self-dev/home/.claude/.claude.json b/agentctl/examples/self-dev/home/.claude/.claude.json new file mode 100644 index 0000000..e2ab4b2 --- /dev/null +++ b/agentctl/examples/self-dev/home/.claude/.claude.json @@ -0,0 +1,9 @@ +{ + "hasCompletedOnboarding": true, + "bypassPermissionsModeAccepted": true, + "projects": { + "/home/agent/code": { + "hasTrustDialogAccepted": true + } + } +} diff --git a/agentctl/examples/self-dev/home/.gitkeep b/agentctl/examples/self-dev/home/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agentctl/examples/self-dev/instructions.md b/agentctl/examples/self-dev/instructions.md new file mode 100644 index 0000000..82f8beb --- /dev/null +++ b/agentctl/examples/self-dev/instructions.md @@ -0,0 +1,41 @@ +# Agent platform self-development Agent + +You develop the experimental agent platform under `src/experimental` in the checkout at +`/home/agent/code/altinn-studio`. Never delete, reset or reclone that directory. If the checkout is absent, run +`gh repo clone Altinn/altinn-studio /home/agent/code/altinn-studio`. + +Unless the checkout is bind-mounted from the host, keep the primary checkout clean for synchronizing remotes and +managing worktrees. Do each task in its own Git worktree under `/home/agent/code/.worktrees/`, starting new work from +the current `origin/main`. Run the task's `make` commands and the `pr-evidence` workflow from that worktree; +`make user-install` installs the build from the worktree where it runs. + +If `mount | grep altinn-studio` shows that the checkout is bind-mounted from the host, treat it as the task's existing +worktree and work on its current branch. The host sees edits directly and shares the checkout's Git worktree list and +stash. Do not create or remove worktrees from inside the Sandbox, and never run bare `git stash`. + +Read `src/experimental/AGENTS.md` first. Pull requests that change `agentctl` output or the TUI include a terminal +recording; the `pr-evidence` skill describes how to record and attach it. `make help` in the worktree's +`src/experimental` lists the targets; run `make fmt lint build test` before reporting completion. `make test-e2e` and +`make user-install` work here too: the Sandbox has `/dev/kvm` and Podman. + +Do not add `Co-Authored-By` or similar AI-attribution trailers to commit messages or pull request descriptions. + +To run a nested Agent, log the nested `agentd` in with the placeholders this Sandbox already holds, then apply the +`nested` variant with its secret file outside any bind-mounted directory: + +```sh +printf '%s\n' "$AGENT_CLAUDE_ACCESS_TOKEN" | agentctl claude login --from-stdin +agentctl codex login --from-stdin < ~/.codex/auth.json +printf 'GITHUB_TOKEN=%s\nGIT_USER_NAME=%s\nGIT_USER_EMAIL=%s\n' \ + "$GITHUB_TOKEN" "$GIT_USER_NAME" "$GIT_USER_EMAIL" > ~/nested.env +cd altinn-studio/src/experimental/agent/examples/self-dev +agentctl apply --variant nested --env-file ~/nested.env +``` + +Real secrets are host-mediated: never search for, print, copy or persist their values. The credential placeholder +above is inert. Git identity is explicitly selected non-secret data and enters both Sandboxes in plaintext. + +Build steps inside Podman trust the mediated CA through the system store and `/run/agent/tls/ca-bundle.pem`. Buildah +drops default environment from build stages, so a `RUN` that downloads through Node exports +`NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem` when that file is readable. Do not persist that with Dockerfile +`ENV`. diff --git a/agentctl/examples/self-dev/nvim-sysinit.vim b/agentctl/examples/self-dev/nvim-sysinit.vim new file mode 100644 index 0000000..47d19c1 --- /dev/null +++ b/agentctl/examples/self-dev/nvim-sysinit.vim @@ -0,0 +1,3 @@ +set number cursorline termguicolors laststatus=2 +set statusline=%t\ \ [%{&filetype}]%=%l:%c +colorscheme habamax diff --git a/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md b/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md new file mode 100644 index 0000000..ea1bf35 --- /dev/null +++ b/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md @@ -0,0 +1,57 @@ +--- +name: pr-evidence +description: Help pull request reviewers understand changes to the agentctl and agentd developer experience through terminal recordings. Use when a change affects CLI output, provisioning progress or TUI workflows. +--- + +# Show the change to reviewers + +Demonstrate the scenario, the relevant change and its result so a reviewer can understand the experience without +running it locally. Explain the premise and starting state in the recording or PR caption. + +- Keep artifacts under `/home/agent/code/.artifacts///`, outside the checkout. No capture report is required. +- Keep each attachment within 10 MiB. There is no fixed duration limit, but GIFs should be brief enough to follow + without seeking. Split longer demonstrations into focused clips. +- Capture actual behavior from the tested revision, using test data without secrets. + +## Record + +Prepare incidental setup before recording. Prefer familiar command names on `PATH` and a sensible working directory; +avoid cluttering the demonstration with full binary paths, custom environment variables or a custom `HOME`. If such +configuration is part of the behavior being demonstrated, show it and explain why it matters. + +Set terminal capabilities on the recorder so the demonstrated program inherits them. The prefix below removes +`NO_COLOR` and replaces an inherited `TERM=dumb`; it runs before capture, keeping setup out of the demonstration. +Omit the override when demonstrating behavior under those settings. From the artifact directory: + +```sh +env -u NO_COLOR TERM=xterm-256color COLORTERM=truecolor \ + asciinema rec --window-size 120x36 --command 'agentctl tui' terminal.cast +agg --font-size 14 terminal.cast terminal.gif +agg --select 50% terminal.cast frame.gif +``` + +For a scripted CLI demonstration, replace `agentctl tui` with `bash demo.sh` and have the script display the commands +it runs. Pause before execution and after output so a human can follow along. For a TUI, pause on relevant states +before moving on. `--idle-time-limit` can compress long waits, but preserve enough time to read. + +For containerized programs, forward the capabilities with `podman run -e TERM -e COLORTERM ...` and ensure +`NO_COLOR` is unset inside the container. For missing picker glyphs, try `agg --font-family 'JetBrains Mono'`. +Keep the application's presentation faithful to the tested revision. Inspect representative frames with the image +viewer, using `agg --select` at relevant positions to check readability beyond the GIF's first frame. + +## Attach + +From the artifact directory, use local image references in the PR body; `gh --attach` uploads files and rewrites +those references to hosted URLs. Pass one `--attach` per file. For example, after pushing the branch: + +```sh +gh pr create --repo Altinn/altinn-studio --base main --head \ + --title 'fix: ...' --body-file pr-body.md --attach ./result.gif +gh pr edit --attach ./result.gif +``` + +If an upload fails, inspect the PR before retrying: partial success can create or update the PR despite a nonzero +exit. Retry missing attachments with `gh pr edit`, rather than repeating creation. Uploads need repository write +access and a token recognized by `gh` as a personal access or OAuth token. + +Read the body back with `gh pr view --json body -q .body` and confirm attachments have hosted URLs. diff --git a/agentctl/examples/self-dev/tmpfiles.conf b/agentctl/examples/self-dev/tmpfiles.conf new file mode 100644 index 0000000..1111441 --- /dev/null +++ b/agentctl/examples/self-dev/tmpfiles.conf @@ -0,0 +1,2 @@ +d /home/agent 0755 agent agent - +z /dev/kvm 0660 root kvm - diff --git a/agentctl/examples/self-dev/workspace-init.service b/agentctl/examples/self-dev/workspace-init.service new file mode 100644 index 0000000..5d25f32 --- /dev/null +++ b/agentctl/examples/self-dev/workspace-init.service @@ -0,0 +1,19 @@ +[Unit] +Description=Initialize the Agent workspace checkout +Wants=network-online.target +After=network-online.target + +[Service] +# One best-effort clone started at boot for the checkout variants; a bind-mounted checkout already +# has `.git`, so the script exits without touching it. `Type=exec` completes the start job once the +# script has been started, keeping the clone out of the boot transaction so that +# `systemctl is-system-running --wait` does not wait for it. +Type=exec +User=agent +Group=agent +Environment=HOME=/home/agent +PassEnvironment=GITHUB_TOKEN +ExecStart=/usr/local/libexec/agent-workspace-init + +[Install] +WantedBy=multi-user.target diff --git a/agentctl/examples/self-dev/workspace-init.sh b/agentctl/examples/self-dev/workspace-init.sh new file mode 100644 index 0000000..78aadc3 --- /dev/null +++ b/agentctl/examples/self-dev/workspace-init.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +destination=${AGENT_WORKSPACE_DESTINATION:-/home/agent/code/altinn-studio} + +if [ -d "$destination/.git" ]; then + exit 0 +fi + +repository=${AGENT_WORKSPACE_REPOSITORY:-Altinn/altinn-studio} +parent=${destination%/*} +mkdir -p "$parent" + +# Guest boot can race the host-mediated network handshake. Wait for DNS, +# while leaving the repository operation itself as one best-effort attempt. +remaining=30 +while ! /usr/bin/getent ahosts github.com >/dev/null 2>&1; do + if [ "$remaining" -eq 0 ]; then + echo "github.com did not become resolvable within 30 seconds" >&2 + exit 1 + fi + remaining=$((remaining - 1)) + /usr/bin/sleep 1 +done + +/usr/local/bin/gh repo clone "$repository" "$destination" diff --git a/agentctl/high-level.excalidraw.svg b/agentctl/high-level.excalidraw.svg new file mode 100644 index 0000000..dae64ea --- /dev/null +++ b/agentctl/high-level.excalidraw.svg @@ -0,0 +1,2 @@ +eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO2953LrWLIl/L+fglHzpyemiN7edMRcdTAwMTdcdTAwMTPyhjKUl/idXHRcdTAwMDVFJ0o0XHUwMDEynczEfffJ5DklXHUwMDAyxFx1MDAwNlxikDiSqi9Y96qrRFx02iD2yrUyd5r/+49C4Y/R21Pjj39cdTAwMTf+aLzWqp12fVB9+eNP/P6kMVx1MDAxOLb7PXiLTf972Fx1MDAxZlx1MDAwZmrTn7xcdTAwMWaNnob//te/utXBY2P01KnWXHUwMDFh3qQ9XHUwMDFjVzvD0bje7nu1fvdf7VGjO/zf+PWo2m38f0/9bn008GZ/pNiot0f9wc+/1eg0uo3eaFxiV///4b9cdTAwMGKF/zv9XG7vtOv4XHUwMDE3zy53ZHFQpjevumVPTb/S7293pr86/aG/bmHQqI2qvVanMXvrXHUwMDE1vk9cdTAwMTnhXHUwMDFlV0IxQqU2nMqPt9/w9lx1MDAwNONcdTAwMWWRjFx1MDAxOS0pfFx1MDAxMbO3X9r10T1egVx1MDAxOOop34t//Mh9o926XHUwMDFmTX9cdTAwMDYuY2YvPbvMzzX9u0A+vjNcdTAwMWNccvqPjY1+XHUwMDA3Plx1MDAwMVj4/6Bccvxntuy7au2xNeiPe/WPn1x1MDAxOVxyqr3hU3VcdTAwMDCf0+znmu1O52z0Nr06PCH4aP+Y+1x1MDAxYle/boHNfT/qt+CPtu57jSE+XHUwMDBi+vHd/lO11lx1MDAxZb1Nb3N2XHUwMDE3uMKnvfr0sf2f2ZpcdTAwMDbwwPfwufXGnc7Ht9u9elx1MDAwM5/GXHUwMDFmlUngr/Xqv/7aX8989kD5r+/812ztjVx1MDAwNl5YaMNcdTAwMThT1ny84dutVM1/96jfm+5cXM2sosbI2a+1h5uw90bTizZh/zZmT1x1MDAwMFe2Nb8v/XszsPVGjdfZY/Ht3Mv7K9qpdFx1MDAxYrx9u7td3W2f07Mr9sfHz/3Xr3+bfXrjp3r153qoNpoxroyUZLbfOu3e4/xH2+nXXHUwMDFlZ7fwXHUwMDBm30c2hyP3akI4XG7czF9cdTAwMTAy8Vx1MDAxMDJcdTAwMGIgxKj1qOGWXG5qhNTEgSCeIyZcbjGXbshcdTAwMDR+/Fx1MDAxNzasXHUwMDE11kiiXdigkkZhgyowYIRpK5ZcdTAwMDFHzP41UkhOU+zf2XbEbVxit1+otuDv1Fx1MDAwYk+Dflxy73v2ufV7o7P2Oy6fmcB3t6vddlx1MDAwNz98XHUwMDE5uNpap93Cz+GPTqPp21x1MDAxM/BRjNpATFx1MDAxZm+P+k+zd2twvWq711x1MDAxOOwl4aL+oN1q96qd80WLr45H/dPG8OfyR4Nxw/9cdTAwMTE1dj9oxWMyXHUwMDA20faucUNvNva3L05cdTAwMGWq18/XL7XysJ2cXHUwMDE5jbWepoBGXHUwMDA2RoZcdTAwMTgx2/M/d66FP2/B1lx1MDAxMkCtXHUwMDE2NFxma0W1p5WEnWNcdTAwMTmRYFx1MDAxZsKwlsp6lkhp4Vx1MDAxZqVyXoxE+cvKvGiURbJQIVx1MDAwNsRfopHY50CM3Fi9XHUwMDE09JfjxebN/u0lN+KQN7afdfe2dve8R1x1MDAxMvOissAzyCXSXHUwMDBmmuV50b2aJLxoiYhcdTAwMDeQWlx1MDAwMCAtPMHBZlNBXHUwMDE0k2H0sFx1MDAxYy5cdTAwMTFweU3OiVRb0FHCcOJcdTAwMDBcdTAwMDbT0cBgisCjoWRcdTAwMTlgZLl3w5xY64yHo8bATYYk8N3fSIZcdTAwMGLoJ0SGoVVnxIKMTa6uq9eStPY2XnmndrFuy4lZXHUwMDEw8OdcdTAwMTEmXHUwMDE1XHUwMDEzSitOxExKTHeqUp6APaCVIPCFhSEspfJcZlx1MDAxOF5cdPpcdTAwMThUlJ5Z31x1MDAwZlx1MDAxNFx1MDAwYoA5ZVxcg1x1MDAwMmZaWsNnpjZHdVx1MDAwMNVvK5Mg5dRcdTAwMWEhwFtxgZ1Hglx1MDAxZDWMsETJT6TB3eenwYa5XHUwMDFkX1x1MDAxZWyW9ivXL5fH763TNDQoLGwmorOhQfdqXHUwMDEy0KDi3GOEXHUwMDBieFGwmfNcYtJ0XHUwMDAxgqgynlx1MDAwNFx1MDAxNqTKWkb9XHUwMDEyMefBhYh5T8GDloCPTv1/1OdcdTAwMWOyXHUwMDEwYD6cQ025XHUwMDE2nNuMiTD97lxyXHUwMDEx4e7x2Xnhn0+1P1x1MDAwYpPun1x1MDAwNc/z/mdcdTAwMTaUWIO1+2kqXHUwMDFkKcaz0Twpxt1BNvRYfb3q1C9OXt+aTbHfKTdcdTAwMTiI3avk9GilJ6nQRCpcdTAwMDJfXGKdhW6msVx1MDAxZqlcdTAwMTegWzBcdTAwMTDBXHUwMDBlSFObY9qN6SpbmVx1MDAwNVx1MDAxNZBcdTAwMTlcdTAwMTPa5Vx0goGNXHUwMDA0ulx1MDAwMlxu1MTvx/92XHUwMDBlXHUwMDFjNjb1UJXa1eb6PX1ovD2+nt/IL+NA92pcdTAwMTJwILh/sTBRbFx1MDAwMUxcdTAwMTg3XHUwMDFl8J9hllx1MDAxMiqdziCVJEeMXHUwMDFiMTxcdTAwMDVcdTAwMGJcdTAwMWFBtWRWM1x1MDAwNzi4ilx1MDAwMVx1MDAwN9VaXHUwMDEzKpZcdTAwMDLHb2XBX1HGXHUwMDFmvWJcdTAwMDF+stopICVccvqdTmOA31sr71x1MDAxNZr9XHUwMDAxuF9tXFyO/1v9p8agOupPf2xcYp9cdTAwMTncL743/c9qr37Xfy2cbZbwesNx98v9zVx1MDAwNUxcdTAwMTZcdTAwMTF8/cyPJVx1MDAxYsa+bZ6sXHUwMDFkrdWeN+snpdPu5eGTUOc0XHUwMDA1Y1tPMiOtXHUwMDAw7rag2oKnNfDdZVx1MDAxOZtcdTAwMDS+n5uggFx0siuTNmfSXHUwMDAye4RPMKc6K2SuPlxmXHUwMDEzXHUwMDAxyjCW0qVcdTAwMGU2P4lYr2/eqmeqvf/2VCyuXHUwMDBmr65cdTAwMWJccsVcdTAwMDdJiFVw4Vx0cOi10mCyXHUwMDE1XHLs5VwiXHUwMDA1z9NcdTAwMTLczpIoXHUwMDA1XHUwMDFmRGgzK0adm1nkXHUwMDFiOWIjryfnUsk5XHUwMDEwKXxx6Uwpo3ashM1FXHUwMDE1NyrbXHJcdTAwMGJcdTAwMWGMXHRjUmzYXHUwMDEwk1x1MDAxZfdcbvXGpFDeKPxzSlx1MDAxOZ23XHUwMDAy0EG7V1x1MDAxON03gCj647rbweQq8N3fx4GBm5knvFSrXHUwMDBmUFXw00jDVfpq/eit2GP1w6065Yds+2hjYz1cZmy85NzhibSekZxqoVxifpXBsJFcdTAwMTHcXHUwMDAzWyiAyKZfZ1x1MDAxZvBH2IjDXHUwMDE1hLJcXFHJNZdkdoFcdTAwMGaUa1x1MDAwZiN6qPZcdTAwMDQnXFwymaM+XHUwMDAy9Vx1MDAxYknpi0XRXHUwMDE3k5yBtXZcdTAwMDaXfClToeBcdTAwMTLXsFxyTPbBJWBcZsP8ezqpMXjqt+dd2tm/XHUwMDE1Zlx1MDAxYmb6XHUwMDFmXHUwMDFm//5//nT+dPQuxVcxvEFnXHUwMDE3/Mfchf/oVIejjX6321x1MDAxZcGdlnGVIbs8qlx1MDAwZUbr8FTbvdb8e41ePeKd6W+tXHJcdTAwMDb9l/tGNbRH4Pfm34uxXHUwMDA3g1x1MDAxYl1cdTAwMTmd8HNVXHUwMDFk8M396+dDO1H9RETPtGeZRUJcdTAwMDE3WviEXHUwMDA37nDLiadcdTAwMWRes+SeK16cs3tcdTAwMTTOt9PEiy1sSFx1MDAwNeLLXHUwMDA1aVx1MDAxMZloR62wXFxcdTAwMThuMvaUgd9cdTAwMDVsj1x1MDAxNfn90bhziL5ccoVcdTAwMDdcdTAwMTaYXHUwMDExS/efNmW1RdqT8at8PVx1MDAxZlx1MDAxOHtyvP9cdTAwMWVGZYRHyYCePUalXHUwMDEwQnIhhe+jwk9IXHTlUVx1MDAwNtJcdTAwMGKUuFSahGHKXHUwMDE58ThFejBWXG5rZs/wXHUwMDAzsVx1MDAwNq6hLeeEXHUwMDEww+FcdTAwMWEzMs9cdTAwMDFcdTAwMWNcdTAwMDDwzepHpNZQo4T/XHUwMDE4eobrXHUwMDE4ppbEMsblZ56Q3uvDzlulerd2cbJJ+y+VvaONUvJcdTAwMDRalFx1MDAwMdKAXHUwMDE3m8ZmxKDIvZpcdTAwMDTcxphCL5ZRXCJcdTAwMTm3XG40T1x1MDAxMEBcbuGFXHUwMDAwI5i96dv7M61rPa2tRZRJLql0ZFx1MDAxOeTR4SjIVJJznlaGXHUwMDEy8EhcXDFcdTAwMThcdTAwMWRcdFxyzVxmPFeqs9awqTdviPBKjUGv0fnRK11cdTAwMWX+WahNPz88ZfyUU9Juu173U8lcXDR3XHUwMDAxJ80z48I7ySb2urlcdTAwMDVcdTAwMWN1PKqU9njl+Ppyr9h4fX9Kw5TSU0JcdTAwMWJijGKUXHUwMDA2w1VSXHUwMDExIDlQVJSCeyvYXGZcIjOipJ5kXHUwMDE0s84sXFxEW1x1MDAwN85zpvy1pkWwr67OlJoq1LJOc2BDnu5cdTAwMDdTwrPHWlx1MDAxM/aJ56j2+WCXXHUwMDFjvLZJrXx0aC93xyN+PUzJlFrSNNGzXHUwMDE4XHUwMDE0uVeTiCmZXHUwMDAwqWjw+Fx1MDAwMuwprCuIIG08pkCETit5mMsjhN9cdTAwMDd4XHUwMDE4XHUwMDAx6NCgSJ056TlTRkDmLjlTXHUwMDFhLE5cdTAwMDJD5oz9isjYr5VAlLDSjGO/6XdviCpPtlx1MDAwZS/+LGziXHUwMDBmXHL+LFx1MDAxY7TvXHUwMDFlXHUwMDA3496fP3qH7dqg/+vY7zvw5lx1MDAwMoaa5033bVx1MDAxNdx3lVxyh66fjU/X7ePk4v5m3LxqXHJHxZunx+RcdTAwMWNKiPZcZlx1MDAwM/CCXHUwMDEyM9bO5Vx1MDAxM2IyrVKKcI1GVlxuX93CzFx1MDAwNmhcdTAwMGZ8TSmnZ/ZCMkdkOGfRX2taZFx1MDAxMlx1MDAxYauzKLNSaWPcRWkmpiiNXHUwMDE4qixhS9mK5WhUXHJcdTAwMWGqcdjtvIr66GD7dVuMq/fdVDSqqZU0o4pN92qS0Cjh05xaXHJUXG5QISaIIUkkIFhq+Gg5R6rkIVxmUSU9Qy3ckoX7odpBo3kuQFx1MDAxNGaaKYKsfFrhS93VzDLS5WTwnlx1MDAwNHch60P/6fZcdTAwMTUptm+IR89+ZcjgXHUwMDEzbvTq34EyXHUwMDE3XHUwMDEw0jxlRt5BNvT43H4oVqv7leP75stcdTAwMTHprb2cjU5VcnpcdTAwMTSaXHUwMDAwu4FzKCTnWuu59Fx1MDAxZc2wnFx1MDAwNTjNgEJjOoxskNieXHUwMDAwcaxcdTAwMTh8XHUwMDA1aM9cdTAwMWV2To5pgd7KIFx1MDAxOKs4I0pJJ/7D6vqjOE1qXHUwMDE5oNTfTo2blcudId1+Wmtccl6qrHlwuXHwtJ6SXHUwMDFhueJpjmRjQOReTVx1MDAxMmpcdTAwMTRcdTAwMTabXHUwMDE5MGxVQKQlXCJcdTAwMTiLXHUwMDE1lniUgklmxij4XHUwMDE5R96BmqJcdTAwMDM7SSjBpHaIy9zBjFx1MDAwMsx9cmZUsF1cdTAwMDR4XHUwMDAwzjxdbaOAgYXoXHUwMDE2n0/Webqpd28kM/ZcdTAwMWGjl/7g8Ufvn/2nXHUwMDExLL3a+ZyylVx1MDAwNSy5gJeiWDL2bjIqYWm1brZ23o+eXHUwMDBmts77XdVaO+iMXHUwMDFkaflxXHUwMDBlpaVcdTAwMWPsJcdCa1x1MDAxYVx1MDAxNMNcdTAwMWPYjlx1MDAwYmWYJNifRIRcdTAwMTGvjPGM5VQqrsAr9XksOWOmNVx1MDAwMO3VXHUwMDE5k8OTZEh/LsNAXCIjT4xpKVx1MDAwNV8u8rRcdTAwMWNlnlxmWW3bXFxcdTAwMWRuVC5udieTtetcdTAwMDNZTVfgqbWVPjOwXHUwMDEyZbpXk8ibpCxcdTAwMGVAli5cdTAwMDBcdTAwMTC84XFrQeSAY0pd6bh5X5AowDwkZ0zOlVx1MDAwMsakrnN9SaMrW4hVXHUwMDE4cF/uYD9m81x1MDAxYUrAQ12FMbHMXHUwMDAyqy7OXHUwMDFhtUFjVFx1MDAxOI76g1x1MDAwNlZiXGZcdTAwMGK+V7GwNlx1MDAxZd1cdTAwMDM9vVeRf1xujVx1MDAxZfBUY/prvyiqWa1V640sOHaV6pV4XHUwMDEyXHUwMDBiVa/8nlvPhpCbT7LRvry/bVx1MDAxY2329Lh6dfdcIktr6VximVx1MDAxYlx1MDAwZZ4sXHUwMDE4XGZcdTAwMDHWJWhPXGLxXHUwMDA0s1Yry4zm1jhOeVxi9yy20VCEK1x1MDAwYlYyZ+SlXHLM4+qMjGVEXHUwMDEyu184XGaPiJbqVFx0XHUwMDEw8lJ8Zku+1sZ+pfIuKtpcdTAwMTbPzlx1MDAxZveK3f37/mE6SoZcckszyihyryYpJcdBSC6CXHUwMDEwXHUwMDE11iPwjsbEI+TlnJSTY6aTnJSNXHUwMDExnFPKXWpV8ehsO3i2XHUwMDAwKmYzXHUwMDBm8KbdvmFSntZVXCLJTP+lOFx1MDAxOPdG7e5P1plcdTAwMTVLfjHbLmAod61o8nvKhkZ748r63qRe27+g6vzweqheX7arSeCPRcxcdTAwMWU8TqWFMFZSXHUwMDE1XGZcdTAwMDJcdTAwMWKPXHUwMDEzw1x1MDAxNFx1MDAxOFx1MDAwN8A2UyZ8RIo1nkJL4M6fL+6IXHUwMDAy50n0UfDvJ4c/SFx1MDAxNEExT8LdiyGy6Vxu2GVcdTAwMTDl3Fx1MDAxN0nMqCxcdTAwMDa8N7lSnsT6uN3BKpLCXHUwMDFk/uCw8K9Cp/pcdTAwMDaLd1x1MDAwMv57ZNUvXnJGefYvZnPwXFw/2Vt7ei6eXHUwMDFmb1+OdPvqXCKNLlx1MDAxNp4hQNpSgZ5cIiaIakapR6UmXHUwMDFhME2pcuTZM/h9Llx1MDAxOVGYXHUwMDFmJSnPz3ZcbsuD/GllXaw5YZYyV1x1MDAwMFvYyPJcdTAwMTl4iOjIL5dMvJwoLt3sTSaXLzfl85ctM3w9KrY3682UothcdTAwMTCVxtWPXHUwMDAxkXs1iUQxsbH4UVx1MDAwYvBDhfastCDZXHUwMDE0plx1MDAwZlx0h1uZa+IovDynIUWC6V1OTlx1MDAwNM1cdTAwMTKNXGZwZ4jJXlx1MDAxMistfVx1MDAwN33LSOJAx5DafaM+hqu08L/6XHUwMDAz+E94ONNcdTAwMDDNXHUwMDE3i+JcdTAwMDX0XHUwMDE0XHUwMDEyxanvKlx1MDAxYlksr3dHe+PB4Ob+4fB8Y3Otd7F94shcdTAwMWWOS5AgROFcdTAwMTGu5EzOt2KyWI9cdTAwMGIuXHUwMDEwm1x1MDAwNqRtOFrNmPFgP1x1MDAxMEpcdTAwMTRcdTAwMTBpoFx1MDAwN0bOommtwmBlXHUwMDE2NdhJXlHtjGrL6CpUPOyxxH5misS9fFLm5qlW6Vx1MDAxY1xmOuq0Lm7kxXk6XHUwMDFlJXhElVxyj7pXk4RHhdWAUCunh952XHUwMDBlQJxqT0ogV1x1MDAwM5tfg0dcdTAwMTGmUS08grmD4F5yLi3PUyRSIGaYnEclluNq90moXHUwMDE2kTxKKeFaZJ2Bn3rrhlh0Z7ewdrpcdTAwMDHeWbWGtDL80Vx1MDAxYox7QF6F4WjcbGbBnaumSCxgpnlcdTAwMDKdv6GC+36yYU1x//LIj59cdTAwMWHDxy5/2X1s2f7Zdlxu1mRCejg9hOOMXHUwMDE3q+ycdrbAiVx1MDAxMsu7QSNcdTAwMGLwamac+IF66zjXNblcXI6A+WhlYlx1MDAwNFx1MDAxNVxyKlx1MDAwNs/AXHUwMDFj8Gc69N2PalVs1CzUZ566XHUwMDE02+WKJbfXQ8H2XiqPbKfNK+lcdTAwMWNMZS1RXHUwMDE5Vae5V5OEXHUwMDE4mcTyTlx0vGe1XHUwMDA2/Vx1MDAxN6xO4+Bgklx1MDAwNSDB+lD7s0M5yVx1MDAwYrlTYmacwsVkVIOjL1TKvHqusFx1MDAwN7nNnFx1MDAxZFPv31x1MDAxMDtuXHUwMDAyQ7SQPaYnXHUwMDE0w8K/fvTWOqN2XHUwMDBmP91vQI1cdTAwMGLoZ55cdTAwMWFDd1NcYt9MNrx4cjQ4uz186781bss9eX5ap91dh1xmdnQoU1R6nMBXrYhgZm7sWVx1MDAxMVx1MDAwN1x1MDAwN+LMM1x1MDAwNmKKmMBcdTAwMTnKLCDrge/CXHJcdTAwMDBcdTAwMWWwzoxDXGJTI8BqXHUwMDEwg8dcdTAwMDR4XHUwMDFhk3uSXHUwMDEx4E88ODCyQ1x1MDAxOZhmqvBROUxcdTAwMDJccovoXHUwMDBmuVxmXHUwMDBmWHNBl5uQXHUwMDE0d1x1MDAxNiNcdTAwMTnziaavalFcdTAwMTa5SfFcdTAwMTXanrOrhUj779Cf7Gjrdv2SrjfK43s2aL713nffn4qJrIGmxrOSM4JHq4r45iVMj1xcOY5CM7BRNI57olx1MDAwZa+YSFx1MDAwZVx1MDAxNyBMXHUwMDFiXHUwMDAzwk07miBpXHUwMDBmVFx1MDAwMbNcdTAwMDLQXCJcdTAwMDSRavYjuS1cYtiCxMPSYrpcdTAwMTUyo4hcZlx1MDAxZsPgs4xpXHUwMDAyLjSDXHUwMDA3mHn5ujVMizRVo7/HXHUwMDE0RO5RfFx1MDAxNUPb829uXHUwMDBiZOf9cUM9yb6xe+R6c7DProujJH5cdTAwMDB+MmBcdTAwMTRcdTAwMTnWXHUwMDBmXHUwMDE5Y+ZcdTAwMDJkVnPP3VU7z7hYXHUwMDE28CnGvTHCNbaEXHUwMDE27nLzSJrHXHUwMDAxN1x1MDAxMkxCpnVD4Fx0TvtcdTAwMTCslHB10Jg0Ot85v2J+gVx1MDAxOWVT7G6Z09N1WVx1MDAxZbRrunGxWWmtXHUwMDFmXHUwMDFmlFJkU1DhXHRcdTAwMDW2SmosWlxigpRiSzWGIzu45pRqX/fgXHUwMDBmxHLpoULjzEjYUEw4UiSFwVx0kFIw0IhcXDHio45cdTAwMWPBflx1MDAwNN+RNZqUtKMjXoqjL1x1MDAwNi66XHUwMDAz2jycfvxcdTAwMTe04fEonMG5XGayP1aXKuDVbV2cXd69ymr/5Xir8Xg5PDCDVGnGsDMpyWrCqXs1XHSIXHUwMDBlMGTjMCTEXHUwMDAyXGaBnODSWk2IZehEOUgvbyNcdTAwMTFccpmISVDOsyBtXHUwMDE4bHFnLDia9KhWXHUwMDFhpydk3os/9fZccrFeodf/8pqdXHUwMDA1XGZcdTAwMTRKmFxiLjmb+NXb/mGr07Gj+qW8PqX7JfV4PmmkYEHFPGUoXHUwMDFl51I8+JubUYzpXHUwMDEw4ONcdTAwMTNtXHLhRMpwv1xiXHUwMDAwrYedf7VijFx1MDAxOf+MjVx1MDAwZlxiY39fYTXOkKfgIYCHlEM6XG7SYnVcdTAwMTaEh4g5S+GJjXiv0SxcYtpcdTAwMTe2XHUwMDAxU8tccjRejlx1MDAwNzeb8v1OXHUwMDFjlm+aXHUwMDE310dXXGb+uVx1MDAxYWtcdTAwMWZcdTAwMGb+6b7sz1/ePlbPdPK6W9q4PntcdTAwMWJcZndP27ul0+Bf+evvV9HrS8evWvLMmlE47zJcdTAwMTG/Klx1MDAxZIdOTsRcdTAwMDJ0XHUwMDFh7UlqreAoQIlQjqhSTrDRaDTJXHRWW8Gkm19ZTMCIgYugrcqcX5lOl3BcdTAwMWLm11xme1x1MDAxYa5AsVx1MDAwYugtRLG/qWfh0dr28brudFx1MDAwN5WauS5Wtu+f7DD5zDVGOShhXHKmVTAspZ7LnqA4XHUwMDFlUlx0XHUwMDAxXG7ZSi6VcKfuM204nihxY/wtZ/OkwyBcdTAwMDH9cajP1676xfXh0aR5W++8nVx1MDAwYtJs/WX8U8F/J4PsRK1sxEhlXHUwMDE1WfkqpJ0moGZcXN2T3irEIGJn9/WSXHUwMDBmlDiq7Fx1MDAxNWvV94udycOmSIFcYrFcdTAwMDBcdTAwMTHWY1x1MDAxNDxKLZmw1nl2miPi81x1MDAxMXG5ujylUlx1MDAxYcy8dkFcIrrsXHUwMDA15CxcdTAwMGVA+Mx03cnZYfVsYzxcdTAwMTk/XHUwMDFmbNzXt99cdTAwMWHdzrCYTkRmiDf3apKISOxIhkO0KFx1MDAwNWtkzDzSmPZAO1x1MDAxYVx1MDAwMtrfwrpcdTAwMWRJSVx1MDAxMkOh1lx1MDAxOKyccfdn+W+Wk/RcdTAwMWKQlWKsotHSMiGIe+5SpH9nJTPc2KxcdTAwMDdcdTAwMTRnIDV/zdBcdTAwMWT+6P2z1qmO641CrV9vzI+7/7rcpVx1MDAwNVQ3r0GT3U42uvRqOCF3azdrg4PT6mbv8OTu8LmbXCL6XHUwMDAzttjDie5aaor/zJlcdTAwMDaqwXBoiaNTlWA0J+FvYSpcdTAwMTLPYozpyMJcdTAwMDXmqHB31XlkONjgXHUwMDE4Xc7Ul5uQXHUwMDE4RPDHg4vmaK+6+TBcXLvYeay3XHUwMDFlStu1NLqUe1x1MDAwNotVp+PM/Fx0uD8jLpJ6wJVKcqJcdTAwMTk88/CMXHUwMDE2alx1MDAwMVx1MDAxMr+mkk6/OlpjXHUwMDFi4ylcdTAwMDU2nFiNk1x1MDAxZn3dWf47QFwizV7fW3mvY+M5XHUwMDAx8sc9hDi6uzzYRos271x1MDAxM/tcdTAwMDGO61x1MDAxZLOhhTy2451hvXvRYoRcdTAwMWUmjYbWXHUwMDA2rFZ/Zc1bMa5fn9r70/EhWVx1MDAwYv6VqGho7HXv30u995fR1o59LpUt3Tl7p5OXZNdNIJBBdtKMgO/+9Fx1MDAxMlx0ZGHiMa9cdTAwMTZg3lx1MDAxMmyGb6nUXHUwMDAweCNcXFx1MDAwNW15YXgkxvdTpO3DY1x1MDAxMJJcdTAwMTHmXHUwMDBlqESWtMFzVeD9ZN1cdTAwMGU/kzDrT7VYx7Lp8vlccv7PfXUw/VxmMpC+K0ReXHUwMDE3XHUwMDEwaTjymuBGslx1MDAxMb3xpm7OXHUwMDFjXHUwMDA1oc449SxQL9eC4VCgINLBqfKUIZisIDWYgjDSpUdcdTAwMDR4wFxuxzRa7k9cIpv5wsbjVGtsJIGN13yhq1x1MDAxY/hB4Fx1MDAxZiYl98g0XSFxxIHVTnJXkeRucaj4kvPXYs9cZinlX5+lXHUwMDFiuUenb87vztnVQnSdOknX9+RcdTAwMWE/P7IkpmRqympjvH3igUW1llhcdIRrXHUwMDE091xyocV9Vn2aXHUwMDFhXHUwMDA3wlx1MDAxNOXKYMjKXGJLaWh3ZJRcdTAwMWY8b0FcdTAwMWGdu/5LXCItcipcdTAwMDZa1a/K98PaXfOc0POztZ5N7IRIyj3NXHUwMDE4bFBlpKK+mdbTXHUwMDExXHUwMDA0Sniw1fBUWDKBjcBCVlxuk4ldYTpKXHUwMDAy38/tUdBcdTAwMWVcdTAwMWSt7GzAXHUwMDAzYZQzZ4tlplwij4FccrNcdTAwMDRnMWU9k8DaQHuSleR1u9jqyub6VW3nprJ9ZFrbR+et5CegXHUwMDE4ZSbUTOvHcbzf3FRcci09sFbEXHUwMDE4pjlTXFzmWzqjLV1ePVY0ndxcdTAwMGU869rT/t1cdTAwMTWuhcEqXHUwMDA29pmNyuTp1eZrdVKcXHUwMDFjdU/HO2us3Hja66SqI89cdTAwMTIx7tUkcEglZ3FgMWRcdTAwMTFYuNJcdTAwMWXF9r5cdTAwMDZ7XHUwMDE0XHUwMDExlbfUT1x1MDAwNZqTXHUwMDE0XHUwMDBlqdBCaiZcXIFUXHUwMDExbu03iy5h61x1MDAxYm4y76mfev86XHUwMDFj0l+t4Unhn5Pun4VcdTAwMGav8M9CXHUwMDFinlx1MDAwNfy5nydcdTAwMTnTpl+/XFy+P//6t9qo88v9K1Q78Iem/b/+/MtcdTAwMGac/uJcdTAwMTd7tVx1MDAwYmgsKp/o939cdTAwMWHZuMZnvVx1MDAxMn2ovtrS5sn7e3FULovT56PELM0l8DB4xdg2RCj/Uc1PaEmwS1ZiYzRqKVx1MDAxNzP8fbjHUnlcdTAwMDbEu1RcdTAwMDL7vWvHcZBQ0lOKXHUwMDE5rlx1MDAxOaVcdTAwMDJcdTAwMGaNcjtcdTAwMTRhh05XJ29hqGDE0TRx+rSjo9+Yl6OV5p+YbvF0MXlhpduz9c7zy+7ZaP9UV0snSaPUnSPBr8915Xifv9Dn88lDr/F8XHUwMDEy/CvLR6lcdDH+XFyslUSB+y5cdTAwMTOIXHUwMDAygKOnsS1cdTAwMGZcdTAwMTHC4GCLeWzqRdhUXHUwMDFlRVFgjTRcdTAwMWNbzISxmVx1MDAwYulILJ6l0Fx1MDAwNIqBblx1MDAwM/nm9Fx1MDAwMmNAx1x1MDAxNUZsMpZcdTAwMDTpd29IXHUwMDEyPPXrXHUwMDA18imJXHUwMDE4ccy9gNrmmXtu0Vx1MDAxOaVbXHUwMDFj6c2153tx+rpWutnce5mMm+e7ielcdTAwMTVcdTAwMDQhttJcdTAwMDCjrOFBs7nRrNNyuWmOPjfIv8xdJO50gm1cdTAwMGXcKOCer06iXFxcdTAwMTIptWYukc99bX9CeOaKWfaphaX8uKYmrTo1T3trXHUwMDFiXHUwMDBmpH17fDG+TkqipzdcdTAwMGZ6r9i9rfNn8fg82a7K/dvxdyRR910mIFFcdTAwMTA18VxitFx1MDAwYlx1MDAxMMi4mTZoY5ZcdTAwMTIqnY41/W+WXGaZXHUwMDA2jFx1MDAxNylYlFx1MDAxMI09mLWrT4ODW2c5SporKbNuwZJcdTAwMDGN/uxlNj1cdTAwMWZFZ7AzdSZcdTAwMDf9Tqcx7aC9Vt4rNPuDQq3TxuX4v1x1MDAxNWi0PZs3M/3PX/7p2WZcdK83XHUwMDFjd/1cdTAwMDT7JS72XHUwMDAyknTP1vnUjyWjznH7pV1zfTN5njyMXHUwMDBlTlx1MDAwZeTa1Vx1MDAxMamnXHUwMDEwXHUwMDAzXG70PCFcXCsjXHTOOlx1MDAwYtpcIiCPZdVAXHUwMDFlXHUwMDEyj7NBq5cwMCOZXHUwMDE2jlx1MDAxNqp/xI6eNlx1MDAxYTxubpZrrPpJzNpcdTAwMWK+nD1enlxcbW+XXHUwMDBmhoevT1x1MDAwN7tcdTAwMDPWT76nXHUwMDA18SyHfSlccra0JGyOXzF7klx1MDAxYspx9Fx1MDAxY6NKu1x1MDAwN0/lezr1nr5aXeRKZjBcdTAwMWVnXXTLo+l22rGK0+VcdTAwMDJFn7SpR1x1MDAwN+Vma3f39YSPbWNLXHUwMDE0ZfN03TFHLWpTS+Ex2LHYv1x1MDAwN1PY1Vxc0lx1MDAxMHxcdTAwMWbLY1x1MDAxNHY8w7C+o1NQvqmX2dTXq29q8M6M4cZcdTAwMTX99HdYXHUwMDBiXHUwMDFkx0vMXHUwMDAzXS4/6GN1qfy2fnNyN1x1MDAxONZ2b0c71dF6hzyNzX37y/wr92qS+FdYKlx1MDAxNotcdTAwMTW9XHUwMDAwK/nR5fS7y2LmJrmDhVx1MDAxM1x1MDAwNLmi1qVipIhUMYxgp1eq+PeLU/6HXHUwMDFmXS6gsb/70eVreSS2j3pic692+fZwd3HwfMxcdTAwMWZcdTAwMTKztFx1MDAwNZbGQUBSwDZcboxcdTAwMDP6XHQtzT0mMEmfXHUwMDEz7NJcdTAwMWLulJLg6JKjdZtOs1NcdTAwMDQjQLlcdTAwMTmKMkOVlalcdTAwMWLcYiG0didWxJyhaEpcZlx1MDAxNlV9XCJ3X19cdTAwMWXd11tnW6PLnc77281mv7JW735cdTAwMTl3u1eTgLux4smz2LtcdTAwMTdWxSkjIVximVx1MDAwNVx1MDAxMOJcdTAwMDBBbq2BXHUwMDFm/Vx1MDAxOVx1MDAxZVxyQyiXu5GYqSanbsqxdkEpd05ezNRwwuDZUPv13lqIu5/69SzodaVcdTAwMTPGXHUwMDA1XGbkOGHMmlx1MDAwM1x1MDAxZvrt6laPyNpW5fquz/V5R/aSXHUwMDE3r8LjZZ6izDBQ15qB51x1MDAxM0QwVntzq8A6Yv68NuHylvyAMT1u71bPXHUwMDFhx1x1MDAxMlx1MDAxNmGdWeNcXEeimcFjxNlcdTAwMTOfeLyonu/McUf3XHUwMDBlXHUwMDA3o/Le1kSw09bWQ9LjxXLv6rTcXHUwMDFllt42XHUwMDFlL2m336F77VIx+FeWqlD921x1MDAxY1u6P70k1EyYjlx1MDAwNbZcIlx1MDAwYoDNXHUwMDA08eS0TSCoW22Va9RM7lZHYryWnJtx8lx1MDAwMbdu4apIpFtNXHSHpyNM5p1cdTAwMDAzcKtcdTAwMWRzflx1MDAxYoN/zWb8/nyHXHUwMDE2MOVcdTAwMDZ+tNCt9tpNeOt/f7HDvIBNk8wzTnuf2ciAvdFcdTAwMWQ7e37bXHUwMDEwj8dcdTAwMWKsvW5cdTAwMWaPXlx1MDAxZd+TXHUwMDA3rLFAzFx1MDAxOCtcZpHYVNDO1bPDe1x1MDAxZZBccmxcdTAwMGY1XHUwMDFkglx1MDAxYtbxSmKNnTRcdTAwMTK83cDJ5ExcdTAwMTDAj0jwgDlcdTAwMTFUMeVrJJKbjqDpqK8sXHUwMDBmLFh/Lbgzh5dGn8xQcOi4wt72n6dcdTAwMGZcdTAwMDbjrbfihL+cicOj9a0j81jvydF/XFxcdTAwMGWv+y5cdTAwMTPwuFA2XHUwMDFlmZbHI5NcdTAwMWHrXHUwMDE5aY02XHUwMDEyU1x1MDAwN6yr31x1MDAxMs197EgwNpLzOFx1MDAwM/goJmy4qcRcdTAwMTR2kT2SXHUwMDE4wdJcdLXk/NTfS+SFR/gs/l3Y6Pea7dZh9elHr1BcdTAwMTg+NWr/xn8pfPCZ91btdv6NMd6f39/Ey1x1MDAwZeCJN3zf/Gd/UKhcdTAwMTbuYP2dRiZd2lYg+lx1MDAwNXxcdTAwMTlcIvpP+Fx1MDAxY7JcdTAwMTFcdTAwMDLxJjGu04XEZGLFcLRcdTAwMDZRhs+P5pCGYlx1MDAxNY5cIsxcYoqxpLDHXHUwMDAwXHUwMDBlXHUwMDAz9k1cdTAwMTKUY7dB7ohcbihcdTAwMDbGiDFpNSPYciB3IFwiXHJPM6lcbohsdUG5MUpqpVxcjlx1MDAwNePRjoVkXHUwMDE0M49+g2NhwcotY48y7XZRjNyo03fDe3R2wZBqyKzhRbxBKsxcdTAwMWFeXHUwMDE0wXFcdTAwMTd4pKRcZohcdTAwMDJcdTAwMWOkOfPtXHUwMDBif3W8wDNzeIiYOWUwg8zI0CZcdHS8iFpVfFx1MDAwNYVvVfCJMmlcdTAwMTSsTFx1MDAxOcuYnjWoK/i6cEjs0lxmXHUwMDFhxGpcdTAwMGWfv1x1MDAwZW/cz+m1cXH1cvpyeVVeeym93K5cdTAwMWLW6XV6L4mdJUpcdTAwMDTH0fXYcVx1MDAwNDtcIs6VPEpcdTAwMGJmVOLGgrs1TDlcdTAwMGU9cmcpOzPZWj03k1xuxS04S04zKaKPRiRYNM7Mcmlccm63ZpG39PLe5vS+NFx1MDAxOPavXHUwMDBlT/T7+KC8s3fxXHTR1JDd+63ekvsuXHUwMDEzeEvTclxmXHRcdTAwMDaRWHSG9FxcXHUwMDFmXHUwMDA0qpTw1NREaeA/XHUwMDE5ziVcdTAwMDLq86xcdTAwMDVvSVtcdTAwMDY/plx1MDAxZFx1MDAxMib3lVwisXif4jxcdTAwMTJcdTAwMDedgv10g05FV0gx8GGxg8L385VK47vGoFx1MDAwN1x1MDAwYlx1MDAxOWJJwadcdTAwMWNNLuhCvYDn5p2cqFx1MDAxYsjGMYm3PnGOXHSlXHUwMDFha7BcYs45XHUwMDAxuvTPSv6VXCJcYp6JtjF1XHUwMDBm3EPHxVKLk1x1MDAxZaXw5Vx1MDAxOM5gzahHLVx1MDAwNT+cM6Buf/5yXHUwMDBl8yDM2yt7Jlxme7CBXHUwMDA2dFx1MDAwNUpYdE9OKzXR9jekXHUwMDExWr+P9FVuSeQmxVd4e84uXHUwMDE3XCLnzJyS+OOQgPxnXHUwMDFha5Jwpp+Q1jLjW/1f+lx1MDAxZlxmhFx1MDAwMW9cbpCstVx1MDAxMrNGgil9knirXHUwMDE29JTgj1x1MDAxOFxuXHUwMDFlkuJcdTAwMTLzT4WjNyD1XHUwMDA0qHTQeVx1MDAxYVx1MDAwN+iYr/NK4lx1MDAwZqVjXHIk7GBPYy1cdTAwMDY4tFx1MDAxMoerXHUwMDA2XHIkI8yjXHUwMDFjTKeFXHUwMDFkpFx1MDAxOVxyXHUwMDFiSCrBO8PGgDinXSrqXHUwMDFhJe5cdTAwMTl02iwjlnFuSG5cdTAwMWaj7OPDyvaRWiUwo9RZhsDCOVxcXHUwMDFmI28sXHUwMDA1dJnMu+1/XHUwMDEzXHUwMDBiWYzcptN3Qzv0m5lIXHUwMDAxXHUwMDBmXHUwMDA3XHUwMDFjflx1MDAxMCmAU8l9TVb/skXgfFx1MDAxMISwXHUwMDA0sGK9edhcdTAwMTYlspDx5bRBXHUwMDBiKaywhlx0o4ghXFzpcNjGgsdkKMVcdTAwMWNNIG2gnq+yj8OToqxvd5qV7f6kWGa9/lxyXHUwMDFmJuqDg1x1MDAxZjuOM1wiXHUwMDFjuMDg4NmAeVx1MDAwNKR56MnDPsdpUcLhXHUwMDE1XHUwMDFhg6kwXHUwMDE2k2pcZmfEXHUwMDExsOG5V1x1MDAxOGVcdTAwMGW7yb1ChcdcdTAwMGbWL/D91VdRVo9TTbA9Q8YuIWx1Kn29xpZwXHTPftVTdKpvUTX2JvDd33fiXHUwMDE1WPa851x1MDAxN7HOjDrg3K+VdvaPK/2Xp2pl22yJ1nmtXHUwMDE0xi1eclx1MDAwZbeWYet1bcGeT+OtQVkjNPPAXHUwMDFiwFx1MDAxOUKScaZcdTAwMWTDhjT3NCeSSyFcYmCfOoCLsVxcsMdcdTAwMDRT5LAlVj5YJVxux73V3T48XHUwMDE5MO6sVVx1MDAxYlxyb6ByeMrLJa3GwFx1MDAxYnaG1l8valwi9yi+iqHtmaWmmefpLDqg43sxxuB2r7u23XzcXHUwMDE19+9deVxcZ9v7p623hMZAeNxIXHUwMDBle0FcdTAwMTFKWfDYRVjucVx1MDAxMFhA8ThUxeHiTOdiI1GAl1x1MDAwN16oi8PBXHUwMDE1ZNg8XHUwMDFk/lx1MDAwMGHgaedD56NsQX91XHUwMDE3XHUwMDA3+z2AJpOu7lx1MDAwMdFcdTAwMWQxqJTMwuPJuudcdTAwMWSQXHS27Ppya1x1MDAxMLVL8Vx1MDAxNd6ff3NjsL119nj1drhWuejussbR1aFZXHUwMDFiJ++ywJjAjFx1MDAxNEGp4thEX1x1MDAwNcPCQlx1MDAwYvy4XGJFZ1BKaVx1MDAxZFx0K2BSXGZ8olKD10WZf+JXPoswtU14SmpcdTAwMTOiT2JcdMexkcaZt8rC00tnjeNxQLBeLl/lY3mpXHUwMDBlYtlO87C+9X5SrWxccmtv963Bw97mfqq+8Vx1MDAxOMXwjTRe6cDUvZpcdTAwMTCQXHUwMDFjg8yYXHUwMDAx+aE1JitcdTAwMThMiZkjVebhcDMjsGhIUMfJXG7FKlx1MDAxMWuNXHUwMDA1e0Xhi1x1MDAwM0F5c7tIyDynKFx1MDAxMlx1MDAwMVx1MDAxM8ZcdTAwMTh3usYsOrdcdTAwMTRcdTAwMWVcdTAwMWZcdTAwMDd46MzpMu3unW3GOZ+z3a22XHUwMDFhP3qDXHUwMDA2fP6TbDrRrXpuuoCXorznyDvJxo8+WCtcdTAwMTdcdTAwMWaGt4O7XHUwMDEx2zk/3nq5Wr9hz0lAXHUwMDBlqlx1MDAxOMSxtVJcdMlcdTAwMTXTPDi0l2nlXHSilOHgYSiguDDIpfA0MCimXHUwMDA0XHUwMDFhK5zaOY9/RYF8kFx1MDAxY+RcdTAwMTScXHUwMDFihmmHLlx1MDAwMoxcdTAwMDa55Vx1MDAwMrQr96UvZFx1MDAwM3JgXHUwMDA0blZcdTAwMWFccrGGjVx1MDAxYb99+Mu5yoxAyyv68EjdnFxmTvu9w0P2VLw8u03k71x1MDAxYYszRCVT0lx1MDAxMtCnc+1JkLcxXHUwMDEzm2hpLHbXXHSDljBPc4vjJq1iII9cdTAwMWRnemBcdTAwMTeM4JJcdKFcdTAwMDS4XHUwMDEzvmneOYiDIFx1MDAxZSZcdTAwMTW3kVx1MDAwZa/G9i+BNiP+ltCR0pZjfm/mOVx1MDAwZsLgIF3j39Jf4u5Gb1J8XHUwMDE1Q/tzdr2QwP47OLznrztnd4fj9W6L2rOKrZOn+uFlQmsgPa5cdTAwMTVocUWEJiZcdTAwMTj94lx1MDAwNFxi2lxiXHUwMDBipoLjXHUwMDFjXHUwMDE2R1x1MDAxOVx1MDAxOOGeXHUwMDE1eKRcYmJd+itcdTAwMDNnxsB4XG7buHBcdTAwMWPi4Vx1MDAxZnyb24KgLVx1MDAxOK1sXHUwMDBiKHapXHUwMDAz/HFXJzVcdTAwMTY9II1cdTAwMTiqgjV8WVlcdTAwMDMpfDMjv85cdTAwMWFEbFJ8zW/Pv7kpmIzfKmed0V73YJ3eXHUwMDFjjtrHz2tmklDNW09cYk4kXHUwMDA1S2CoXGbmOCtOPa24sth0jyuXKeDKw/Tnn9FXaV2zx3M5XHUwMDFmif5xXG45L7lAXHRcdTAwMWSe6oZvimicXHUwMDBiLDxcIjx71tfC38R0XHRBf3x29meh3YOP6Vx1MDAxYut511wis5HzOy/D5mhysqf65Fq9vU3M5dn5XTJcdTAwMDI3yrNcdTAwMThLg6dgpLBh1FLOMb4jYcfYsFx1MDAwZo5eurZUwWZiRlx0V9FcdTAwMTBnnuJcdTAwMDJcdTAwMWPIaWzG5ifZUVx1MDAxMJ6sTuDoXHUwMDFiXHUwMDFiXHUwMDAwlVx1MDAwM9hcIjpcdTAwMThnXHUwMDE4kL7JPFHFmEDrvK+i78gtiq/i/O78m/P3afngpnZE9m+Or5rlzcYtXHUwMDE5brGNhFJcdTAwMWXLT6nWXHUwMDE2cC60XGJKeYVcdTAwMWTXiKZaaUIsseHyQYb9jsFXwnQ2QrGnn8NcdTAwMTRcdTAwMDSPXG7zMutIW/CSgS3Q2PjBOPvgs5hDK3h+TJDsSd5cdTAwMDCPLFx1MDAxNbXL1lx1MDAxYURuU3yFNujf3Fx1MDAxY5Sqpc6dkmuWVVx1MDAwZe/fTjvsaK/1mvwomzLlXHUwMDExKiTOYcTJYVx1MDAwMZNAqbJcdTAwMWVcdTAwMThNjU3mjWQynOjGqPHAXFxg7j+IfqlcXCfZlnuGXHQ1XHUwMDE1/aA/fFx1MDAxZOFym1x1MDAxMLRcdO9JbULkSTbiXHUwMDEwXHUwMDFi3zrnOUY7+Fxcckrsko3SP1aX6iD78NS+93ftRcV0umZvX1x1MDAxZN2XN/tJK4q3j9Uznbzuljauz95cdTAwMDbD3dP2buk0+FeW6s94/17qvb+Mtnbsc6ls6c7ZO528JLtuyHqEilx1MDAxOaghjM62zkpcdTAwMDfv7k8vXHUwMDA0e8fBO1x1MDAwNVx1MDAwYlx1MDAxOIN4LVx1MDAxNiCeWuZcdTAwMTlrjaCcoFx1MDAxM+8qVc5pP1xu4pS4Ie724rWSXFy6+zMyXHUwMDFiXapcZtZWY+frjI/lJFXaypXO3n1cdTAwMDPUzoJT0P46y8anXHUwMDBmXGboO/b4kjZNXHUwMDBiaDVmMFxc4vvKJjBcdTAwMTBvXHTjivfw1Fx1MDAwM3P+OFx1MDAxNjZpRYOH8yBcZoxcdTAwMDduguRY9i6ZXHUwMDBlXHUwMDFm9M1cdTAwMWWGr0eBXHUwMDA3PqGUXHUwMDE2y0VcdTAwMDEuedZapCGgK+t/XHRGmEfMeqWhb35YXHUwMDA3XHUwMDBiz1x1MDAxYVx1MDAwZmyybrJENeyVpaxDpvI/8NPh/Zil3I8qg4s3XHUwMDFlXHUwMDA1f2lcdTAwMWW4b+iigDPCiVx1MDAxMpKEOypJT1x1MDAxMsxcdTAwMTS1ONtXXHUwMDFivWRp3tv+YavTsaP6pbw+pfsl9Xg+abjWVIRFXHUwMDExqTVVXG44hFvLRXhRwuNcdTAwMDL8TuBcdTAwMDPGKbpZ4T36SbV541x1MDAxM8UnneHm9Val8TBZf3xTa8eJ3Vx1MDAxZsqk9ahg3GrMMVwiNlx1MDAxOFx1MDAxMsGiSY9Ixlxm7G74XHUwMDEyXHUwMDBlicC2h1xy9vGyM2Mxq/NReXJDpFxy3FnZ4cFcZmlcdN68M3M3/N2/rCDmIVAhlktPdHtcdTAwMTCLXHUwMDFjnuJ5m9HqZO9Ad0t3pVNanrS21lx1MDAxMztcdTAwMTCaMVx1MDAxMIOE+DpcdTAwMGKt5EC4V5PAgVx1MDAwMGEg4jGj4jFcdTAwMDP2wqPYqkFzMH44OC1cZpr8XHUwMDE4MFx1MDAxYTNcdTAwMTFDYZ1cdTAwMWWEksRgsNddzlx1MDAxZpn8o60l2L4xy3L+pTZwyIE4m1xyS/7ac8BFzFx1MDAxM5WrXHUwMDFiWHg2LkB80Fwi3lx1MDAwNVx1MDAxMFx1MDAxYUS+hW1iKabzzTU4YjiKXGY7XHUwMDFjXHUwMDExIVxyUSzsXHUwMDAzMICy0pQzKeFcdTAwMDc1d4T/4Fx1MDAxYVx1MDAxYywxqFx1MDAxOCyolTRvKVx1MDAxOFx1MDAwNW22+pFcdTAwMDB80spcdTAwMDZSK1x1MDAxMiX7gXTHWlxumnWHo9Qhr9/jXHUwMDEzRO5SfIX257dyXHUwMDExpnJcdTAwMWM+Q2ZcdTAwMTVHvqTMv/qPVlx1MDAxOVx1MDAwNlxcOiNcdTAwMDRcdTAwMTNEMFcroUQ+XHUwMDAyfzy4aI72qptcdTAwMGbDtYudx3rrobRcdTAwMWTRUoRcdTAwMTJNQFx1MDAwMEhCtDXYgMnhXCKAeypcdTAwMDRcdTAwMDX/wcKatP0qXHUwMDE3oWpp65Ko/fPTt+v7g2Fl/exaOqbMRbtcYsYjWKSnXGZcdTAwMTPCmmDAlFx0Kz2F3TvAfzPCn/ww81x1MDAxMYinfC+HiVx1MDAxNFx1MDAxMjST70XzXHUwMDAy4ChcdTAwMWKpVvZcdTAwMTioXHUwMDAyu1x1MDAwNNvWXHUwMDE5VnW0hvsodlx1MDAwMFx1MDAwMsR5gZ84w+piZMdcdTAwMTdH5lxcvUxqL7Vq+dne3XWSnmWcX/X3b6k4a9TEbXP81Lt62DlTwb+y3FlcdTAwMDYoOVx1MDAwMILmOk2wJ1x1MDAwNqDuu0zmivA4bIJ1WoDNvMCosFx1MDAwMlx1MDAxNnVcbk9cdTAwMDRb41x1MDAxMs6lM/E4XGbFXHUwMDE56FxmI4LKLI8ypvtXXHUwMDEySVY6yvg2XHUwMDE1RotcYu5cdTAwMTOLjrZqXbn5vls8L1x1MDAxZo+vWkebd5XaUYr+5pwzuD7sXHUwMDExS4SvI9ivYkHCXHUwMDAyYFx1MDAwZbtcIpRjXHUwMDBi5dnLUXekc2KNXHUwMDAys1mZWDmhUqqI5qo0XHUwMDA0/FlnZY0zrD91OCSdXGbeVaNRNddNeTS4mrxcdTAwMThyxZJcdTAwMTLrcal2dlThJ4NH1t+9fzB9+9xPOPwp9rqk+7C313w6fms9XHUwMDFjXHUwMDBm9i+qrfuJbWZw3f4pv66MSufdo2ve6mzfXHUwMDE0qzdmmMF113pcdTAwMGZk0DmZlKuVo9Z75apO3lgpg+s+XGaatWJleFdcdTAwMTGbXHUwMDBm5f3b0fh+cp5wvVx1MDAwYoWLJFxcXHUwMDEwklFcdTAwMTKGe1x1MDAxNyVcdTAwMTEuXFxST1x1MDAwMlx1MDAxYlx1MDAxYUGptf7Ayk9bx0y8rVx1MDAwM4fdXHUwMDEzXHUwMDFjXHUwMDFjLCxXwU5cdFx1MDAwZadcIu9cdTAwMTdcdTAwMWZp62yaJFxmXHUwMDFjXHUwMDFhXCKFs3dcdTAwMThcdTAwMGJ3RZhlWeKTNXy5kXYxylx1MDAwNeSQTLWBQ8plY9Cow1x1MDAxZmpXO4fVXrX1PVpcdTAwMWYskFx1MDAwZfNcIibmXHUwMDFlspEyj71X2VRsa3+9yVx1MDAwZi+NYlXxZpJLXHUwMDE5kC9cdTAwMWVXQjFsTW04nfNMjCCeXHUwMDAw7GslMEXVMVx1MDAwNDdcdTAwMTczhVx1MDAxNVx1MDAwML62elwipVx1MDAwMshcdTAwMWKrXf5cbmeRYoZzLTFb5lx1MDAxM89cdTAwMTXvu+e7zTVzUieVdrvycnqpt9f4V3P45dbz1eNRjau1y/r95KK71excdTAwMWTvZnBdMX5cdTAwMWGvrd9cdTAwMWVcdTAwMWW3T0ovd+/kdnP8yDNcdTAwMGJqcCpkRtrA/VSSaFx1MDAwM2WtZ8BcdTAwMDEyXHUwMDFjtDS3Ys5cdTAwMGYySsWbXHUwMDBlijlcctRiYpfIJ8mkNFx1MDAxY+vJlVx1MDAwMTfSUEGdpVicR2dngoOEOTWZXHUwMDBiXHUwMDAzbvD8YiVh0IfPYaPTXqtXn0bfQ1x1MDAxNiyg4ZAsiLqDbERcdTAwMDEp1YaH1cH2XHUwMDA1bdn2+8Vl88ast1KIXHUwMDAyxlx1MDAwMplcdTAwMTPB01ZOQDNgd3lGXGZcbodcdTAwMTm756IgXHUwMDEzbG+sLFxuXGZO3aPSfXJA445XJU5ElJ+oXG5q/F6eNDuXh+So2uGj8rh/XU3MsmdcdTAwMDfP98OzXHUwMDFiPbZvXHUwMDE3XHUwMDBmvWNV7lx1MDAxZDevM2DvXHUwMDE1XCInXHUwMDBi2Vx1MDAxYsyfoqlqzGOA7v70XHUwMDEysTee9km0xoxcdTAwMWFDmZ7DuDXxXHUwMDE4p1R5XHUwMDE2W/5bXHUwMDAxJOIur8j5O1x1MDAxMuObKTx77LtqTERjJFx1MDAxNt1cdFhrgs3sdMZnXHUwMDEy6TdwiMDPXHUwMDFhtUFjdDbqXHUwMDBmXHUwMDFh34G8XHUwMDE30GUoRcq1+myIe0J3h/WDij1pXHUwMDFlX7fO1c7p6enIMdgx2ps3nsJ0XHUwMDA3jvPDVLBZKZ9cdTAwMTacxlx1MDAxZTPmxD397rKg3lr9zJ+C4Fx1MDAxNjg31Yn1SHeeUYKD/fztZX87c0/232/2znnreP2iSq9cdTAwMGZcdTAwMGZa23fDs69m7pNO9a5UXHUwMDE5XHUwMDFli9fH5sXz/e677ZwkXHUwMDFj4Vx1MDAxYXvdca9zUp286vPiyPZPT/RcdTAwMDWpnN5+R0XgfipJXHUwMDE0gVx1MDAxNsqjYDy04jhD0MypfnBcdTAwMDZcdTAwMTdYXHUwMDBmMD5oM6SAW1KcXHUwMDE5R5FBrlxiXCKNx3ZcbkVcdTAwMDCvn23uXFxWQkR2VND4i1xcf0NBcNRcdTAwMTi99Fx1MDAwN4/fQVxmLKDgeTFcdTAwMTBaeTZCIN4+xs461Mri9HTGmJSKzqUngFxiUN60XHUwMDFjXG5cdTAwMWJhYKJpXGLG07prabBcdTAwMTePXHUwMDAwJ9JqR/tcdTAwMTTgKVx1MDAwZt41Qlx1MDAxYY49k/NZh1GwTlxcOVx1MDAxND1cdTAwMTSIXHUwMDEwZqzmzvYpMmxcdTAwMDNmXHUwMDAxPFx1MDAxY2nJ6XJcdTAwMDG85TTB2tlNsbtbqonKXvNl9HTZeHh/OE7LhdJ8fSZ2MVx1MDAxYVx1MDAwNdO3Q1x1MDAwMJhdMHSPmSVjx5umQiBcdTAwMTmbgG+vXGLWwsNXXCKEY5hiaMMlSr6Od5Z8i4A1UJyMiaFcdTAwMDaL0UTmXHUwMDE4eetZIbRcdTAwMDZJoeGpW0tcdTAwMWQo+Jzsa/fOTaKbqLGewL7kSMiWy7lzXHUwMDEw+PxBXHUwMDE2cYMjXCKFZipcXGiGbpmeZlfAbsM00VxcN6UxsCnqzFx1MDAwNKbaXHUwMDEyqVxccdHIOFxuQ9XLqVhuekqkqaNg1Fx0Wa5cdTAwMGbVX7Lp1ySF7yCbXHUwMDE2XGKWedlcdTAwMTRaeTayKT5wXHUwMDFiK5uE4nBtolx1MDAxOefYiDzYfpJpbT0r6XRcdTAwMDJcdTAwMDTOXHUwMDEwdpSLgn+kwVx1MDAwNoDkMkBcdTAwMWGGuFx1MDAwMqI4L8ZcdTAwMTJcdTAwMDOGUSv4K3mHqShU760sm6iySlx1MDAxYuZcdTAwMWaK6WtBKWN6yzLDpTKfeVxiwo5t+2n8+tQz7HD9stZ4O9yumVQpXHUwMDAxy2dbZd+uXHUwMDFlXHUwMDE0nFx1MDAwNfVcdTAwMDGeplxudLikgnuG4CxHXHUwMDA1Zlx1MDAxOKC28GqRkMJXXGJMnyHB4tO/guqHaauNXHUwMDEyXGbbzYHlIL7W/Z8kwUBcdTAwMDdyXHJwZOg9XHUwMDEwJYBwXHUwMDFjXHUwMDFh7Kskl3vXJ5JcXFx1MDAxMlxcXFyQlXp6XHUwMDAyxdWcsVx1MDAwNrXrgYurtNBWXHUwMDEwf7nNh4/LPNik4FRRgVOMWX54lco4pynt18pcdTAwMTiAvDNWXHUwMDE12VximIKNIIL5XHUwMDFiY2SlubCj5iqaa9hcdTAwMTh9XHUwMDA3vbVA6czrrcCqs9Fao1xyMrx5PmJcdTAwMGbj3vshXHUwMDFmXlx1MDAxN0uT7mHysypcdTAwMGLm219cdTAwMTM3nz4m8ChKXHUwMDAxQq2EL9rR8Ds/rCqsXHUwMDAw4v2kXG4r5rCKXHUwMDE5baS7iSdcdTAwMTfRXHUwMDA301ZcdTAwMTJ45ny5tlx1MDAxZMsprMdTWX47U2+14avZql+o0fbl1dVXXHUwMDE3vFRvj1x1MDAxZUp7raPt3qF9u314XHUwMDEwz5W2yeC67EI15GlP1KvFbqNe2tuolVuTZNdNoDRBRLCMkk/dTyWBXHUwMDAyYKBcdTAwMWY9qoD6wYOmRtHgUTc2l1hgPajROFjJSLhcdTAwMWZcdPpIOKxHLlx1MDAwMVwirUcpuVx1MDAwMlDwXGIwyddlJKLnWDNucaJnpmOsp9tXXG6dqiA8JFx1MDAwMDY61XG9gVx1MDAxOZzfKP90XHUwMDAxXHUwMDE5h/JPo+8ho1xm1FiTXHUwMDE5XHUwMDFiiJGSejhtl3BmpFJiriRcdTAwMDVndVxuycCn4thtxNG4S3DrKVx1MDAwMb9cdTAwMGW/yyWljnNo8E09hc2kKc6/pjZcdTAwMDd6XHUwMDE00Fx1MDAwZlZcdTAwMGXEgOxcdTAwMDaHzJl+LkmkXHUwMDA3YLD+kSj6icW2/dp+q3svSseqdMVcdTAwMWXE5oFiL6/puJHDvvz6oYGRXHUwMDAwwFdo68+uXHUwMDE2usHPXHUwMDBmm1x1MDAxNHFaNpCxXHUwMDEwXHUwMDE0yNlcdTAwMWEzq1x1MDAxNCj4Oo1cdTAwMWEmwUFQYFx1MDAxZIDew1x1MDAwMjVRXHUwMDE0Jd5kXHUwMDA218Qp0VpcdTAwMWIhsbWKpTK0pi9cdTAwMGKiuDdtXHUwMDAyXHRFwVx1MDAxYvZAJ1x07H1ltJ9pp3aWgZ2Fm1x1MDAwNaWFs9J4OIZcdTAwMDJcdTAwMTKLodfFXGKeMfrn6+RcdTAwMDIqgV1NXHUwMDExQ1F4LK7TnVpxKi3q4mz1XHUwMDEzeHxGidVGXHUwMDFlXHUwMDBmXHUwMDFhzUFjeP9cdTAwMWRU01x1MDAwMpVcdTAwMTI+tJpbeTZaqdZv7bxsv76X5Zq4vj/cnrRGLcdcYuTIxl/EeEKjm8OwN1x1MDAxMFx1MDAwYuKYXHUwMDBiTTxcdTAwMWJbh0dcZlx1MDAxOF3fy+FcdEmKdcCzXHUwMDE3z1x1MDAxM36igH20elxcRStsWS2c5TtcIia9jyjCJP9ExTQ8rGyq67dy+frgoLn2ptn12d5+0ihF64U/V9ZL91x1MDAwN1vdh955u/5A1H4lg+iHPXlcdTAwMTjsqN2T9Vx1MDAwM0JEy5Zf1LbJZDZcbio8bIbB0pi/XHUwMDE43Ls/vWTUzeMhL1x1MDAxN0DeSE/4U3XDiM/biUVcIvw4OXNcdTAwMDNrW43OrFx1MDAwM8ksnOQ/azVErOHULtdqKH778lTbN8TehcOz9cLToF/Dm3ZS+Kf1XHUwMDEzW8CbodknzpVnQ+Gnjd3hydrFzfpO/bIoXHUwMDFmrHnfmlSTU7hQOtCFY65wR2CGWd6F47dcdTAwMDG6vDplXHUwMDEzK4gwwjnjxJ9kPU/ZzCgwwMtFOZfj7Luj4d7a6OH98oCsXHUwMDFm0f3r8tZlu5qUW9Vo6+6yaLcv2uyy+lTstCtcdTAwMGbNLE5CesXTm1xyZq/3X6s3tbfB1e56dzDI4Lq/q1x1MDAxY4jviN6WIFfV89b704XunrPa+k1GXHUwMDFhg6JPTtM0SIgxTO6nnURjSC488PWUwPZcdTAwMWNk3iRJKuNNUl5cZlRYwVwinSSXXHUwMDE4lGlcIkBcdTAwMDe6O5bq6Dw3RbRlVmbaS32p7Vx1MDAxYtJcdTAwMTjfqFx1MDAxYWhcdTAwMDGxf1I1UGlz8lJ+XW+N9y9Grclg1Lh7bt2kkFx1MDAxN9pcdTAwMDbchbnKPthcYlx1MDAxZceJXHUwMDE3eFx1MDAxNorjT3N9kS2aT1fWXHUwMDE3XHUwMDEyzKzmhjub++noQ1x1MDAxNENcdTAwMTm3VHxiXHUwMDA10O559WLt4PrugLx3R0e1O92pdSp/Y3nxuXTt/vRcdTAwMTLRNbbmXHUwMDE3UisqXGblYo6uXHUwMDE151x1MDAwYiCuiGekxYnrWlIrec7XaVx1MDAxMH6WnK8x3Y0q6q7dje7NY1x0jqbmy1x1MDAxNfj/VrbutO9cdTAwMWVcdTAwMDfj3ndg61x1MDAwNTw5z9ahlWfD1vFWLD73wVx1MDAxMI9cdTAwMTFcdTAwMWPRh1x1MDAwM6hcYpkr3lVEeThaXHUwMDA1c5+ldVx1MDAxMbU3zbdcdTAwMDVtJ6yWVlx1MDAxMUftLrdcdTAwMWVcdTAwMGWV0/BcdTAwMGbA3eg80lx1MDAxN1x06/OVk1x1MDAxZqiZziczzkFHjEZcdTAwMDZcdTAwMDZwko6UYNB/gzrnbKla/Wzra6N36vTt8CadXTFEydlNO4q1XHUwMDFmhWCBLdeWcFx1MDAwM1x1MDAwZa5cdTAwMTVCUiUyK++I9znmVkFBXHUwMDE4XHUwMDFhYH9C4dlKqZOs4pNSXHUwMDEz4lVXrFx1MDAxZERDZ8E/xdpcdTAwMWZi53tcdTAwMThIXHUwMDA17ypCuLBcdTAwMDbEjCNcdTAwMDVcZuczwHtGg3dcZvvLXHUwMDBmtNxcdTAwMGWmt4NcdTAwMTcr20FuXHUwMDE15cyG+5BPbV1kkMKAbVx1MDAxMIRlfVx1MDAwZfJdrGD0NsXX19jANNaHaa2tXHUwMDE0XHUwMDE2XGY5OFx1MDAxZMRV4L+cXHRMbIhhXHI47FxurJ9cdTAwMTCCYGhcIjx17stcZmB89DleXGJqXHUwMDAzQlx1MDAxMFRcdTAwMDBcdTAwMGVcdTAwMTaS3M5ZQGG0p1x1MDAxOG5cbsOwO8VsK8+yOrhHLbj/hFLcPi5cdTAwMGJIhcWRglx1MDAxNPjXXHUwMDAwznTexiXSXHUwMDAyRmRrpVGCXHUwMDFh9Vx1MDAxY1x1MDAxODNnXHUwMDFiR1x1MDAxNd2UlWKfdmqWXHUwMDFiLfWxvnT1yJdyWL4+XHUwMDExo4cjUde74909flv6vS3Nfo+BXHUwMDE1OFhNYSVcdTAwMDG1mislXHUwMDAzXHUwMDA2tmiFp1x1MDAxMEBUMMUtXHUwMDE42kXXi0bV9HphQH03i02JsUzgwNNphlx1MDAxMVx1MDAwYreFkaApXHL2XHUwMDFjXHUwMDA1cVx1MDAwNbdpljPgiVvVYF5cdTAwMTl4Opjqa1x1MDAxNVc6XHUwMDExh3xWgbJcdTAwMTNcdTAwMDYh++0sUKZcdTAwMWVocSU1h8/bn7r5szyJi8DpWVjAYm27XHUwMDA2MpNcdTAwMTJcdTAwMGK4/XNyZzl5M4OQW+ugtb5yW+uIXnpcbvSVdFx1MDAxNjGGbfWHf47VyUJl7Z9jQVx1MDAwYoBypXhcdTAwMWPGxFx1MDAwNv3Oj151PLrvXHUwMDBmMJg1++i+LDS3QFx1MDAwYs2H5uJuXCKbKN3e+d2wf90q717Yg7OT6uX67d1Vilx1MDAxZflcXIq4lFx1MDAxZKpEoFtm2EPNj9Rcbisg/DqpXHUwMDFlizxSY2BWpTBOjzSmhyZAlONcdMlyx+bLybH12ulwZ+P6zFx1MDAwZeR2bbBblVx1MDAwZq3Xt0/oXFxcdTAwMWJ73fdjY8XGQN7qnbdTdnT6cH59fZbsur9BPsZcdTAwMDDd/eklYXGB3WyIUMpaQoU/hvtcdTAwMTPjlsRjXHUwMDFjXGLeg5+yhEjKVH6ollx1MDAxMuM3KVhcXFJcdTAwMDX/x5zT72KqjNG5MpIxkvGxWlx1MDAwNi1xy/CB1962ekCL34LAXHUwMDE38OU8gbuXn1xydcebtPjAsqaeXHUwMDAxLFx1MDAxYoynqVAqXGY2uVx1MDAwNs9NMm6x1C+MaGw7wFx1MDAwMPJKK5yWJ1x1MDAxZOdrUmNnUPBcdTAwMTSFXHUwMDA2XHUwMDA3kdk8qFx1MDAxMlx1MDAwNfBKUlx1MDAxMo9cZqpcYm5cYmEqnFx1MDAwMTP1j1wi1bvgQmLNY8Zh5W/TvDZ6m07fXHUwMDBl7dDZXHUwMDA1Q9T8Jc1rXHUwMDAxf0RoSbAnLDHc10XuV0hAedhUSnGFwTHGuVxybZBEUYp4m1x1MDAxNlxcXHUwMDEzXHUwMDAz0aeoUkpcdTAwMGJcIn3tij7WxDyQlIJcblx1MDAwYnJBY69IXHUwMDFk3rWfXHUwMDEzt9irVJ96e1x1MDAwZj2+b24vdmXpuWLejpIpXHUwMDFlpj3YXHUwMDE2SmLluFx1MDAxNnbOq2GGL1A87GdjNVx1MDAwNsbVgFx1MDAwZkRcdTAwMWNFwXncXCLSIN6lUDxYuo9cdTAwMTFIV1x1MDAxZVx1MDAxMWjOKNOnLNdML5lcdTAwMTL4W1x1MDAwNc/9aPTkXHJcdTAwMWHP48Zw9KM3nFx1MDAwZdnxxsNMxM+CKqN46Vx1MDAxM7iJeZ2zeNXZaFx1MDAxZX7RpIOdrdExqNzL2snWsPfeTFFhhIN8/SP9XHUwMDAyuGbCXHUwMDA22iWFdU9cdTAwMWWtmH53WVxcN5Jcbp3oXHUwMDAyI5znwDBcdTAwMWbC5eDQSKVjqFx1MDAwNEttMzw7+lx1MDAxNVx1MDAxYW9cdTAwMWSfblx1MDAwMVx1MDAxOY6rI8H1UNT2d+/2fMw5TzNcdTAwMWZvLKir2drit3fHXHUwMDBmXHUwMDBmr1x1MDAwZsPNcrdULFx1MDAxZPuYK3DZVMGK86v+/i1cdTAwMTVnjZq4bY6feldcdTAwMGY7ZyqD65Jqm+pcdTAwMDa52N3vXFxcdTAwMGZ6XHUwMDBmO8eXr7uHya5cdTAwMWJSWqFiTUJcdTAwMDGOaVpVxZiP+OdcdTAwMTUnXHSMXHUwMDAxtUiYmFxuXHUwMDAyYuRcXDsmyWS86aDU4MBcdTAwMTFcdTAwMGJ3w6aTflx1MDAxZEdcdTAwMTl5XHUwMDEwJNJ0NJNLXHUwMDAyrYXE4brOXHUwMDE4yExEh4uNucL2L1krXHUwMDAyZak/0XypMYFDXFxoYa28l4VcYlg1XHUwMDAysoCCw2NcdTAwMDJcdTAwMWSrz0hcZsSaydhcdTAwMDCIlYBXQ7i0WoI7M9f0R2rtKYJcdTAwMDMnYUNo6ZhV4Vx1MDAxOVx1MDAwNWLCwu+CLyS5K/4hPKE1xa3IXGKTvsyGXHUwMDFj20Fst5LKgpjZQFx1MDAxY1x1MDAxZVx1MDAwM3ibrlx1MDAwMIiImVxyioVBzJCs04ux2TmTS/VXzjZcdTAwMDJcdTAwMTK5T/FcdTAwMTXaobPLhVg5s/hHvPUoXHUwMDA0szRcdTAwMThlXHUwMDAwTmZA9U07jvh+7K/BOVrgbCdAMFx1MDAxNpNwtWRcdTAwMTM0dXLy+rr1/PpSPO5cdTAwMWbfjlx1MDAwZu5PLt+fXFyrmqZcdTAwMWFzbY1cdTAwMTBcdTAwMDJcdTAwMDNu8OmGO7MpT1hBtbCcM8yPXHUwMDE24X37OVx1MDAxMZDHm8PTY1V7K2+eli6rnafX7Z3NnbCBjDzcNdI/R1XMXHUwMDFk/ICGWVBNJeAnXCL6Jc1EXHUwMDBmXHUwMDA3YeR75T2VXCJccuV9UkNcdTAwMTntP1x1MDAxOWUxxchZdiVFtDhcItwoIc1yXHUwMDA3RG7XYWH2XWNMXHUwMDFhj2fnXFzd1G9L47eb0Vx1MDAxNe2n61IklH/K42qeg3M1STxcdTAwMDduSSyK7KKaRPA8JLWYqs01ulx1MDAwZdZcdTAwMDGi3HOIXHUwMDAyTTu552A1VpxcdFdmqlxmf3c2YFxcgk+HXU8yXHUwMDE2XHUwMDExsH1Zqu1cdTAwMWLyXHUwMDFjXG7Daq9+13/Nwm1YoUPRXHUwMDAyXHUwMDFhXG51KFxurTpcdTAwMWJ3YUC6/dez8fXr9j2hXHUwMDBmrYNav3hwkFxiwlpoXHUwMDBmXHUwMDA3Xlx1MDAxMSA0KXQwbMgl+Fx1MDAxMkQyPEnDUVx1MDAwMI6ZWFx1MDAxNJWWtVx1MDAxNDBcZlx1MDAxZVx1MDAwN9eOJPT8OCBcdTAwMTLBXHUwMDBmKY5cdTAwMDOIJHj45OxcdTAwMDJcdTAwMDLGN1x1MDAxMsOMWODGTLtcdTAwMDZ+x1x1MDAxNjkhXHUwMDBiMWlcdTAwMDOO+8VeY/Sj9+vfJ0P4le992rB41Vx1MDAxOVx1MDAwNVx1MDAxOGJcdTAwMWZe/PhhTj1p8HhcdTAwMTDP5uVcXGtCqaWnjVx1MDAwNZeMUiyEXHUwMDBmmVxmnOxcdTAwMDSCXHUwMDAwZ5ArUFxi3GExhGeIgVxyILDiSPhcdTAwMWJi5lx1MDAwNiRoQFx1MDAxZZPq5uiqXHUwMDE1ib6oYe7OI9HaXHUwMDAwXHUwMDA3LTHGlzt4iDtmZIKor68rKUZuU3yFNujsciHTlll8IZ7kXHUwMDBifk+eXHUwMDAzXHUwMDFiK+wxq3B9ZFx1MDAxNq8pfIRcdTAwMTekJ1xmY4qBLMehqeFUhmwrmXFcdTAwMTRcdTAwMTTOv9VcdTAwMDQ+SiO5dVSBSCyt0dhcdTAwMWRe4rLZl41cbn5pnmhF71x1MDAwZt5Pitf1Xql0Kt7oelwiOVx1MDAwNUzrWYZjkTXl4FjOzbxcdTAwMDIx7ZmZR1x1MDAxNDaOjID1JFx1MDAxY/ab/jkr2GFcdTAwMWNzhyjKXHUwMDFhdlLIKarwsJU4h4fqyORwXHUwMDBiwiQ4XHUwMDEwKyOrZ43RK8lcdTAwMWQwMU/93vRPuVx1MDAwNFx1MDAwZVeB7/4+lyhW3bhcdTAwMTaZUcbouWzfXHUwMDFjVlx1MDAwZvpcdTAwMWJcdTAwMTO9dlFcdTAwMWRUVeuWhiHbXHQkq+K2kYL71VxmXHUwMDBmXCKWWrNcYrHUWXiL8cPZi/nC9zlog6Dtrn5GQik303FcIi4sR/ZOXHUwMDAzptFcdTAwMDSgnPFcdTAwMDRKLoFVSZpT/d+jYPxcdTAwMWJcdTAwMTNfxfCezFK1zDNxxDupXHUwMDE4XHUwMDFj34vBfPGo2CqWXHUwMDE0XHUwMDFiXHUwMDBlzcaBKG3L7sCS5GdcdTAwMDA4N4FJLlx0Jt3MdSBh0/4kQOGgobBcbp+HY5eaeVx1MDAwZeAr7umcoKOw3kuK9Zg0KVx1MDAwMFx1MDAxN0fp7cA6jVx1MDAxYvpNgPGl+syh38dcdTAwMWLD0+LkulXe3lx1MDAxMneb3etcdTAwMWIx3molTTzqn/Lryqh03j265q3O9k2xemOGwb+ywohHSvyN5lY6PnDfZVx1MDAxMrGMPS5cZkBcdTAwMGLcXHUwMDAyiUXUc1xiZMpj8HY0XHUwMDAyufbgXHUwMDFki41V4Fx1MDAwMtKVspifXHUwMDFlRGKxn0IsXHUwMDAzu2KcwJWaSEV0R0OOJ/As+1x0j2l3b0gse43exCmTPznhaFx1MDAwMYPNy+fgsrNcdTAwMTHO8XYmNlx1MDAxMCiwzIRinVx1MDAwNteGyHlnV1lcdTAwMWNqgFx1MDAwZstSgKijllx1MDAwMHxdXHUwMDBippxcdTAwMDN2MZeDuVx1MDAwNoxcdTAwMTlcdTAwMGbn2FmOx1DgV+ehwCg8P62so6m0XGa7rjl9Ylx1MDAxNs2tzFx1MDAxYU1cdTAwMDOF1l+N80yFdPQ2xVcxtEOzVNVZjFuEzSUlUfCRanBijVxuR96MJ1xinvBxkMKYc1x1MDAxNN4hiYKB8das4Fx1MDAwZlAyXFyQIZRpXHUwMDFj9KzD9V/CXHUwMDAz7sBcdTAwMDRcdTAwMDPsZs6VXHUwMDBl1399UixwtFljr8Va11x1MDAxZXSP1o+a70VaOU1Rllx1MDAwMVx1MDAxY+iRyPBcdTAwMDKnhi9cdTAwMWFjrUFcdTAwMDY5XHUwMDFjXHJtckcj2lx1MDAxOD6v7mhoXHUwMDBlkCbamXjJots4cyqEtuIz04kmXHUwMDFi593Hp7O3x7WNl81K+bhCT8uJXHUwMDBiMn7XkLah3LeiUrt57eu9i53TzbPxhj7N4Lo7l7y0O6iwPlx1MDAxZG/u7lx1MDAxZdfv1lx1MDAxZlx1MDAxZt8yuC67UFxyedpcdTAwMTP1arHbqJf2Nmrl1iSD615uPV89XHUwMDFl1bhau6zfTy66W83e8W5WfpyRIL/TlCPHXHUwMDE4OvcuSuLHWSGwj6UlzFx1MDAxMFjSXFxcdTAwMTZcdTAwMTiwuFx1MDAwN/ogzsRcdLBlXHUwMDE0RFx1MDAwNNZcdTAwMTOAyfdcdMnckVts61x1MDAwNm5b53LkgHWZXHUwMDA2ied05Ex08IRcdTAwMTlhuWWZ54Gl3r8hT+7sZ0LVWWMwade+RVx1MDAxYo1cdTAwMDWCIVREXHUwMDEycVx1MDAwM9l4d/HWMr6OXHUwMDA0W1x1MDAxZEqpLMdcdTAwMWVcdTAwMDPKXHUwMDA2Yc20XHUwMDAwYcM0R3dcdTAwMDGgT8PjozXH8VJcdTAwMThatcRcYuXK81x1MDAxMMyDv6Bccof3uTS+485cdTAwMWPkQZBcdTAwMGaTXG6amPakxFpDrbN6TPiSc+dcdTAwMDWN1uA5fGp30sOrx+ZwZ//+ddBd21x1MDAxM8VbtbtH27+XXHUwMDE4f4/riCcuhlx1MDAwMa0p8GRcdTAwMTWzgVwiXHUwMDE1poTHLbY3MWLa6HfR1aJcdTAwMDCFrzCUZldcdTAwMGJ9XFyZuaGJi0uwkT0sXGYoxMJcdTAwMWRIQ41ccjcmpdhcdTAwMTBcdTAwMTlcdTAwMWJqKkmJYUT91Vx1MDAwZTulXHUwMDFmXHUwMDFhb4FcdTAwMDN+KDVcdTAwMTR9Myy1kfCoyDdqru9cdTAwMDZBXCI9RrVnuSCUK2qkmOtqxmDLeYJJTuCGpTHWcbRlXHUwMDE3puXnSb3RplqkiayDrsJOKs7ZfpHJd5YpKcE7zTriJolkqVxu0kN6rNFcdTAwMWKOXHUwMDA3jVx1MDAxZr1cZtPzV1x1MDAxNWRcdTAwMGI00Lwgi7yDbFx1MDAxNFm8v1x1MDAxZKfIsFx1MDAxMamnNMXuRNjUdrZ/f/pZXG7ng4FcdTAwMGZcdTAwMDW2U9hwtF1P45RqXHUwMDFh4tXwj+u0jFxijyhCsK9cdTAwMGaQksr1WCTIR6vrMdBcdTAwMDNGXHUwMDAy9p1cdTAwMTMzbHS0XHUwMDFku0pcdTAwMDNhZY39b1wimYqR+1x1MDAxNF/hXHUwMDFk+lx1MDAxOTInsaCYVvYqXHUwMDA2T1x1MDAwZif2aCG4o7WZ9pbUNfHj0IO6XHUwMDA2Plx1MDAxYVx1MDAwM+TNNMEyvPBcIqhnXHUwMDAz1av8yyp53+zzySOvlsr0oPh6eG3uu6f1l7BBjFxusEtKPYH6WYJYoXauXHUwMDBmO1x1MDAxM0GtXHUwMDEzsoqgklx1MDAwMjW6XHUwMDBlL9XwQN6UyFP5oozieOWoO1x1MDAwN82qiGPg6Vx1MDAxZphaXHUwMDE1ZVx1MDAxMkFFKWLNZ05BfbQnRdbtXXJa2Sixo7vSbfGU582KXHUwMDEyxprdn15cdTAwMTLfXHUwMDA2dC9Gk8FgYVVcIveFhX/iXHUwMDFkfGpQR1ZcdTAwMTNUstLXqGJWschwwqLFXHUwMDAzyKiKxXxQWCTEJyl8XHUwMDFiXHUwMDAxz0lZyp1cdTAwMTX5MZl6XHUwMDEyKctmn2MvsSZtXHUwMDE156bagj9TXHUwMDFiddxejVx0fPd3ezVcdTAwMGJoc96rXHQvPVx1MDAxYncm3pQtXHUwMDFjhcq55IZwXHUwMDAwrORzUOaMe1ZJoUDLaOJcdTAwMTiApVx1MDAwMOk4v0ZOOYNLh0NDtfRASFx1MDAxMi6oXHUwMDExWqpcdTAwMWPYkcB+ycChIVxum0o5u1xmMFx1MDAxM1x1MDAxOcygmJ5cIlx1MDAwNVkumrFcdTAwMWN9X1x1MDAxMPY02Hndulx1MDAxMIo9PfW2yPnNzVt6OuR+zHzVmFVYjFx1MDAwNl9cYvBkibKBqLC0nrZ45sokXHUwMDE2eUq58HKRoMJXXHUwMDE4TrPrhT6xzLyveEtcdTAwMTdwe5RcdTAwMTBcZj9cYlxmWlLlqjFcZm3eRJ5X2cqLx4Pz56MxXFx9bbjXP14/2o9wXHUwMDAwp2UlXHUwMDFhfT/CMDPZ4XpJIbSW4FwiXHUwMDEyXHUwMDA2jklcdTAwMThPn+N4uTGQRIRxYz2psbBUXG6p2HztXHUwMDA09pi2sVx1MDAwMWbMXHRcdTAwMDCGN1xmh1x1MDAwM1x1MDAxYlwiXHUwMDFjTebyXHUwMDAz/2hTXHUwMDFkMavQOYxe4ORg96FcdTAwMWaLLHOkllx0zFx1MDAxNcx6XHUwMDE2/TRNeCmj+SHBnp46b4Vis9Ct9trNxnDkvVW7XHUwMDExguxzw8xcdTAwMGKUUEiQLbiRbORZvOFaXHUwMDFjXFxcdTAwMTFAIFx1MDAxYbu+So3V8PPNJMmC6FxutSpcdTAwMTBd0a6u0vlJUlx1MDAxNNDfkmqy6CxGXHUwMDAybKS1cmoyXHUwMDEzmcRIXHTjPHgs9dslWbW4vcW361x1MDAwN+UmebqSL+2z+qXY/eqIylxuWYxcdTAwMGIlJHy+VqeaUVx1MDAxZFx1MDAwM3T3p5eEzCVgVGA0RHFcdTAwMDPLmlx1MDAwYqBKxlx1MDAwMlxcXHUwMDFlhjjh01x1MDAxMVhcdTAwMWP+XHUwMDA184FEXoWVXHUwMDA24u/JqVx1MDAxY56FgeekXFznReGuUFx1MDAxZv3hiWVUXHUwMDEwvVx1MDAxNJJjmz/jMeQqVL6GIYnv0vp5XHUwMDAxUc6zt2Pt2Vx1MDAxMHa8wVnQl8mCL0iIXHUwMDE1XHUwMDEywCjkPJKV9VxiOCZcdTAwMDT0oJR+w/NcdTAwMDFlpTwuqcaB5FL5wT4rbyZcdTAwMWVT4F3hISQ4mzpvRlx1MDAxMoVsRpOSd2RARStCiHGfXHUwMDBms0jAU0ooXHUwMDBleP5cdTAwMGZt/Fx1MDAxY7lJ8Vx1MDAxNd6es8uF+DizXHUwMDAwReLogICVK1x1MDAwM1x1MDAwZtWCKVx1MDAwN5hcdTAwMWHRKFJHgCC0IbLu9IxBV00xXFxnrFx1MDAxMdxcdTAwMTEnXHSeitovm3RcdTAwMTV/U1x0puJgNlx1MDAwMTZusdjnd+Z6/qxQ5XJBJlx1MDAxY50//HWUqOIsNuJ75SYxyiSylf1cdTAwMTmQolx1MDAwMFx1MDAxY+Zuglx1MDAxOW5hN1x1MDAxYuaNUVDwaT7Rn7l+2D993+Nv5Ll8Pjrc5OXScfn9q6unftf4nd9VlVW9PXoo7bWOtnuH9u324UE8V9omg+uK8dN4bf328Lh9Unq5eye3m+NHnsF1aZGut0eT/lx1MDAxZK+v64uzTa1cdTAwMGXfXHUwMDEzzmJcdTAwMGXxU4iBQdJcdFx1MDAxZlxuV/JcdTAwMTfduzOJv2hwwCo4elpO673svE3Vfpvq8Fx1MDAxN8HmXHUwMDFh7NSuMU1RkbxrRypcdTAwMTPK3SbUef6uhZLaXG7nRDFcdTAwMWFuoPMhILkxhNHMXHUwMDEzXGaXXHUwMDE3kFx1MDAwMY/xsNqrtvyu3tc5jVx1MDAwYsSJ02lcZi0/XHUwMDFiv5HR27PH16uzu+5B8+H67mr9cXJ8n1xcJzHLPEy6+JVFJ+fGXHUwMDA3XHUwMDFhxTwyS6txZNXwueZiXHUwMDBlnZRcdTAwMDd6I1FcdTAwMWRRMZCqXFxcdTAwMWRLazTnrsy58DdngV6cZvu5gV5SXHUwMDE5n19W1EZnXdxW7i+O+5dcdTAwMDdXN0lcdPb9XHUwMDE49N/GQN7qnbdTdnT6cH59fZZVQFYrjHlnQ7Duu0xCsOBleFRcdTAwMTIgUk3g//lcXFx1MDAxY8eYQIpbXHUwMDE4i1x1MDAxMlximFqLI86Z1Ia6ukfkXGZcdTAwMWKJRZmCYYmglGrubJhcdTAwMTPTkl8r2HBiOcxluX1DXHUwMDA0W4aPu/b2XHUwMDFkqHVcdTAwMDGfzVPr/MKzIdV4Y1x1MDAxM99cdTAwMWKLXHRcdTAwMGbnaCugQkKoXGaqZE6BUCmnkilcdTAwMDEqzSGTLfW0IUZcdTAwMTFp4Ln6VfZcZsPMeJZcdTAwMWLsOiRcdTAwMDSxyjcuIcd0XHUwMDEw0yopv0Ynt1GCXHUwMDEzXHUwMDEznS1mabhHjC/wXHUwMDAwT5D5R3/9dn5tXHUwMDFmVsWlXps8vraO7cmk8qpf4YGm4UGjwT/7+lIgbTxFpWTAdZpcdTAwMDJcdTAwMTi4/7eLiFx1MDAxMKJgtVx1MDAwNFtnW7roakXKPaqwylx1MDAxN/NcdTAwMTTNrG3h9M0wmGbXXHUwMDBifWCZRY73zu+G/etWeffCXHUwMDFlnJ1UL9dv765avmfqzyvD8iyc3IldTFxytb7lz0p6gFx1MDAwMeBcdTAwMTYt19zC/4X2cqIwcrzlXHKuiU1PXHUwMDAxJVx1MDAxOChcboqDutaEp1x1MDAxMZphg0AsdSMmtKhPXG4ku1GRSI9J7SmFXHUwMDEzXHUwMDE44f+lXGKmKWOSX0COOWLIRGF7XHUwMDFiXHUwMDBiu0pp0NquioO8lVek7dYpTshx1JHROlxcOI1PKrLiQE9cdTAwMWLM+X8rIz3GXHJZLkX4Q49cctq9Wvup2in8r8KP3lpthOOG/9ePXHUwMDFlSJv+ePA9+t0sXHUwMDEwSSGllviWstFw8TVRsVx1MDAxYc5InPPCNdYnWObvPfqrQEF5P2NlgHvhK8f+qLh2XHJcdTAwMDZQeVx1MDAxNCRcbulmZZUmLKYkXHUwMDEz6zpcdTAwMWWKaW8lsEWLoeZcdTAwMTNcdTAwMGJcdTAwMTCKr4Pa47uyx2Mq6udnb+a8dp5KpIFOofZcdTAwMWJcZkqKnnSBL2HmamdcdTAwMTdWIOhAXHUwMDAxXHUwMDAzwOUzdFjiXHUwMDEyXHUwMDAz1GGGaOxcdTAwMDcrmLHKXG7H2flfS04pvFx1MDAxMs+PJlx1MDAxZY4j4VjebTSXXHUwMDA09FdoXHUwMDExysOGo1x1MDAxOFx1MDAwNMKvIM++Sne5N3pcdTAwMTLdpTX259FcdTAwMTSgqVxy80+i/2l+XHUwMDE1XG5enLch0Vx1MDAwMVx1MDAxMOFAXHUwMDE4XHUwMDBlU8KjJkHBR4go9cxcdTAwMDNh0eY4RZlcdTAwMDFcdTAwMDUvjVx1MDAxMCGdyUq+vIpwradcdTAwMTCWKZV1Y0FcZsyt1lwiftjvNu77L4Ve46UwhFx1MDAxYsdb+Vx1MDAwNmprgZyZV1uxd5GNwFrrPZBB52RSrlaOWu+Vqzp5Y6VkXHUwMDAyS3LpccHAp9JcZsyUmUM4mFmPYJlcdTAwMTRcdTAwMTUg0ClzjJ9cdTAwMDVcdFx1MDAwNq7u9ChaUeFv1utvlFxmyNfg2oPfLJWvUWFcdTAwMGX4IOD3VtZfXHUwMDE4XHUwMDA0XHUwMDEwXHUwMDAwPOJywGz0LEkqrDHiM1x1MDAwYkC3XHUwMDFmeen9ub27tbV1e361/dQ8XHUwMDFmib1U+lx1MDAwYqNkZKmRbdnqr0hcYuArtPk/Q02lak5vXHJO4YKlWcxcdTAwMTdxyanl1NRj71U2XHUwMDE129pfb/LDS6NYVbyZiEVcYlx1MDAxY79cdTAwMDc7l0lcbqtcdTAwMTHMkZJJPYleoGaEXHUwMDEyjr3XvkpPuTduXHUwMDEyPSUxc1ZrjpW7jDEzf65I0dBcdTAwMWKMhVBcdTAwMWPtXHUwMDFhXHUwMDBlZCnrMVx1MDAwYspcdTAwMTc+XHUwMDAzpfCZ5XIqhXXdT66mMEAt0ZQ6rGh00SbIXSBUQZdrPVx1MDAxZpe4w1x0pyuJqUGjOWhcZu+/g4BaIFfmXHUwMDA1VGjl2Yim+LzG+C6ARHlGXHUwMDE5PEPWlM+N38ZpxFx1MDAxZVbm4zBcdTAwMGaibFgygVNlsKqLXHUwMDBiPFx1MDAwNGGz3f6BYYtcdTAwMDFt0M/KWFx1MDAwNj9BcsFcdTAwMTRcdTAwMDXp0uqCXHSeXCIlWJPjgLqM9pskJ0Jo9ZmK6bC111Yvp7dHm/SubN9e32rUbqSbOodnXHUwMDFmafJXf49iikJcdTAwMDC+XHUwMDAwPYQwzoSgRjK7MFpcdTAwMDWUqjnGWKQk8Fx1MDAwYsGLhYD0XHUwMDE54itFXHUwMDE1XGJOTcVZioxLXCJAXHUwMDAwhbXXtGCFayY1OGc4jmc5LTbaIMOb5yP2MO69XHUwMDFm8uF1sTTpXHUwMDFlRmgxnPsnKOfSKnBcdTAwMGLBK3AsSlx0hbWmsDKDh9VfJcXciEhcIsWs1Ni2hSiNOZeczffWN4ukXHUwMDE4sIA3/VWBXHUwMDFkd5jKtVgqw32QXCKyxfGIXHUwMDFijK1LjPHIloSgxMB8aF++SEZaTMCeWS2w9VR96Xm/gkGFf5bPb/7nd9BlXHUwMDBiXHUwMDE0USiwXHUwMDE1d1x1MDAxN1x1MDAxOaVUx05cdTAwMDSK12hKg1NplMXiaynsXFxgXHUwMDBivlx0nlx1MDAxOFx1MDAxN9jkTmtLwipNMU9wjVxyM1xi52DsXHUwMDFjpbhMWlx1MDAwZlPLXGLXXHUwMDE2dX8u06LQfriyTFx1MDAwM46kViunXHKgJGZcdTAwMDCqJGo5XHUwMDEzsJxG2ylfNI5uz0/JhK+r3k3tpfbcf/47XHUwMDBlzuBcdTAwMWNrVFx1MDAxNXC8XHUwMDAwXWKDXcio9jjOm1LgXHUwMDAwXHTOlF50NZzRaCzgzGpcdTAwMWOHwH1+XHUwMDExvkJI+lxmnZZYXHUwMDEzXHUwMDExjysqXHUwMDA0LNIqjkNcdTAwMWNdWVZcdTAwMWYnfdjmkplwmUDWgzNcYtFARmDctLBcdTAwMTQj+K4jSEvAR6E/v4JK+Sqd5kZFXCKdps10koZcdTAwMDStJXz5bX9cctLQXHUwMDBiqmLgXHUwMDA3YJ/CXlVcdTAwMDbELHEk8eZzNFwizfZRcpFcdTAwMDao1cRY6ix+ifai0axPg75cdTAwMTmLNMOB9FdcdTAwMWKj8dqo/Vx1MDAwYkTNj95cdTAwMDB2f+G+OsC7L1x1MDAwMGBG31wi6WuBNlxuzdRIdDvZyLb4gYvxXHUwMDFkVFxm8Fx1MDAwNDHAKVx1MDAwNrP32FxcaStA3SPUckK4wFxmXFzHeaRcdTAwMDXLqEG4wT5cdTAwMTB4aOlK21dcdTAwMWO8aiPBt6M4XHUwMDFiO/fSolxmwPHKulxy57cqaYx74GG4MPbDMihcdTAwMDLKTVj+idptvb5duTq5eTguypPic3fnvqJu639H7SY9iZEwS1xmoZz4RtHgXHUwMDBiXHUwMDEwXHUwMDA2ylx1MDAwMTuKXHUwMDEwwIdYrN2iIYWvXHUwMDEwmD5DuqU5WiSKM8opZsiDwDTGXHUwMDE5Y7NoebBmT1NDw+GszLVcdTAwMWKjIHQxbUpgXG6Zr2/Gx6K4h1lumHpcdTAwMDVeqoRb+Crt5kZFXHUwMDEy7Vx1MDAwNrfmgW/ALdNgzoWYa1I7P1x1MDAwNS1cdTAwMTdvmdrucopcYptmxGhpnX1cbqJtNKOUWyV59oOQwNvi/9HqbYFE+kr1djQ62z65Odw+Yd2XzcvNtff29sZRXHUwMDEyvFx1MDAwM5+Ao/zR6WmujyWXbIGvXHUwMDA23nOgi5PjYDRvYFx1MDAxMFx099NcdTAwMTRw55wwZpw5+iraV5vWl3G13FxiardcIvu54+zJw2BH7Z6sXHUwMDFmXHUwMDEwXCJatvyits1LUKAt37KHY5P0lUqA9nqF0X2jUKtcdTAwMGVcdTAwMWKFfvNH77BdXHUwMDFi9H/NJiw8XHL6k3Y9qlx1MDAxOUq6gSSdRnNcdTAwMTRjUkb9pyh7XHUwMDEyuJ1541x1MDAxMVxcf2HB8lx1MDAwM0Yk+IGksVwi8c801lx1MDAwNyTBTijzZkQxT4NsXHUwMDEyXHUwMDE2q7ON42RcdTAwMGVknietpGBwrEFJ6khJpcJcdTAwMDNcdTAwMDOlXHUwMDE0M1x1MDAwMoefKJZXbkdcdTAwMTmWM7dhSZNiIeAxXHUwMDEyKZ1cdTAwMDZHRM9cdTAwMTTDXHUwMDE0XHUwMDFi8Fx1MDAxZnXWwSHJkYy+3EuL3qf4Koa36OyCIbOXmWdcdTAwMTXP/lx1MDAwMSdcdTAwMDYwaDBhdPq/moT9qqjKo5R+VYqhi1pzxbTQmIdHyExCfiyJSjBcdTAwMWXYllx1MDAwYj5R+Mrkl4XE4/vfxVdFKlx1MDAxYlx1MDAxMFrzuVx1MDAwYnLRdGlfXHUwMDA15GzGYlx1MDAxZVx1MDAwNYuygOdZZOVLonCqj8NcdTAwMDKyXHUwMDE4XHUwMDBiSFx1MDAxNKOCZ95JXHUwMDE4L/lccvLAiirw8+ZTYkcp0rNcdTAwMThcdTAwMTHTxFum8Fx1MDAwMUpXo2D4Ico1wziNsWirl7NxqVx1MDAwMlqgZzW3XHUwMDFjq1xuzfSsI7Qs6Vx1MDAxMazZ49Ov7OtcdTAwMDZcdTAwMWOdq+b+sXh92Z+csd1xS+nec9tRelx1MDAxONVcdTAwMTNcdTAwMGYsnIdcdTAwMTNcclwihp+AK7Lg9E84XGadylxyXZShu0hq6GKa4Fx0bNDB3S3TI1x1MDAxM+dhI1NjxXLTjj5WlyrY3y+dNJ5ftjrNp8dbvmbuX0vHT2uf0GQ29rpcdTAwMGaDZq1YXHUwMDE53lXE5kN5/3Y0vp+cXHUwMDBmM/KEJeHCXHUwMDFmVFmpt57701x1MDAwYlx1MDAwMds1PtZcYlx1MDAwZlxmXHUwMDE4XHUwMDE34JqD8pVyXHUwMDBl03JBlEhzz1xubSVjmLOtjSNKlM+YjER4mrll2G1LWmfGVWS3LdDeYKwzbqsnhGTGrJZy+dxpR0V+P3dq7Fx1MDAwMkpcZmVZzi08m1x1MDAxOG+8/Yp3PkRMdIYpvYCShaNcdTAwMDNAXHUwMDFl1I2C61VcdTAwMDbn78Cr4CpqV+yFRlx1MDAwN3uxXVx1MDAxODxdlvXRzrSD+tefkFx1MDAxN4WM+PlcdTAwMTCPfoXjwX3ynVPiXHUwMDBiXGZ9XGJ8QWOHgyTyOuJt0ZzXoZVl/NdXf8FEYZZtXHUwMDE4XHUwMDFjNMjC8Z5Pcjts2Z6RYXdrv79pRvywT86GI8dcXNVIt0OrgNsxXHUwMDE3gVx1MDAwNobzdGxtXGLV1rP+l0OkKD5cdTAwMWZcdTAwMWTLrWCEXHUwMDE1vF7ZLVx1MDAxMVx1MDAxY8cx+Xui+41gZFsqzKo18jNTkI7PL9/21dPL2lWRXGZVs/duR5P9pN7Da3X/ukNvr4+PyrXGZe36bqO0VsrIe1x1MDAxMFxum3Km0WAx+HTfZVx1MDAxMu9BM+EpqjH6aVx1MDAwNOYjzUGTL5p7bJhn8CBTYua6XHUwMDEx3HE2lFdtRVwi8SaF+2CwqZtyXHUwMDA2XHUwMDAy/HGZOchhO1MubOZcdTAwMTX0XG6odLleIVx1MDAxZi7Ez0qnutuJ+Nz0kVx1MDAwNVx1MDAwNFx1MDAxN3JcIkJLz8aNeNx7v7uv7Z9vXHUwMDFm3W7vq45cXHtcdTAwMThtpOBZQXTAlZjr0K2DoVx1MDAwMFd8L+fZLNFdWZ1nsVFcdTAwMDV+cYHel+FcdTAwMTdKLVx1MDAwMVOtXHUwMDE0Wy5wsFx1MDAxY9Nuqc39c/l8vb/2cPj+tnd6cv58nJJcdTAwMTGZtamiXHUwMDEyMUhyryZcdCNcbspiQVx1MDAxNJxcdTAwMWLjXHUwMDAyXHUwMDExoViaZalWXHUwMDA0qNNcdTAwMTJHIXOeZlx1MDAxOVx0mmpySrRofsCldrXmouG+9rNcdTAwMTZ9Wlx1MDAwYkPEcjo0llx1MDAxM2m66dchTjw++9ErXHUwMDE2NtaQQoajLJhx+Vx1MDAxY6hFbDRPilFrz4ZcdTAwMWFcdTAwMDct2z1T426pu31bu36/Py097qY4+ZqjxrlpUNwsXHUwMDFlm5lTY5Yor61+Mlx1MDAwNsZcdTAwMTeI0Vx1MDAxZIjzXHUwMDA3XHUwMDExwsOEsYlcdTAwMDFfLlx1MDAwYmo5bqxcdTAwMWOvy0p5fdQ5M5aNXs3a7Utx9Fx1MDAxZueFuu8yXHUwMDEx50rraS6wXHSbJpzQOc7FruXxXHUwMDAzXHS09OB9XHUwMDBi0ObwYJ2Um3uhkWCsJ6dcXMooXHUwMDA1oUYjzqMjS1x1MDAxYlx1MDAxNOgmwbJv5Ka41T5rvVx1MDAwNOXW8e3Bt/BCXHUwMDE3cNw84YZWnlxy08bbm/hq07izLFx1MDAwMOai4nJXeolcdTAwMGWGweVsXHUwMDA35TBcdTAwMGXCuJGUU2NGQlx0Ou1cdOpKq1x1MDAwYn/z45BcdTAwMWFcdTAwMWK1kk9cdTAwMWRFXT5g9++bl0ftXHUwMDFie3lxW1x1MDAxZD1u72w9pWM+7EGzVFx1MDAxZEO2XHUwMDA3Zyzw81wiOKVe0oX92oo+1OArhJfZXHUwMDA1Qlx1MDAxZkpmR3Hx8bNCoFRcdTAwMTP7o1uOedggPjhcdFdqLpnWXHUwMDFjbz1cdTAwMDNrXHUwMDEwmuKULUalYVx1MDAxYXZBeFxyVFx1MDAwNkqnXHUwMDE4+7KWbO6dnkRVSVx1MDAwMU6N1IJcdTAwMTLBjOBzLZu4oVx1MDAwYqZuXHUwMDAy1XtcdTAwMTSMOcU0TPB/Zr+fi6rF1riZXCK0L2CLXHUwMDE56WwxXHUwMDFlk8xsKfZv8Nf8Z6SpMLV6pVxur+F9u1v416Q6+NdPjeJccuF/voPCWqBtQnH++PvIRm/F5yjGT1x1MDAxYpBxXHUwMDA362DaXHUwMDE2XHUwMDFkrFx1MDAxYr5wxjVcdTAwMGIxUlx1MDAwZflcYsi3Vlx1MDAxN2BYa+Mv7PdcdTAwMTmC6IiGIWA7sI15toYgfYLr75FIkZtcdTAwMTRfxfD+/FxmwZNmIFx1MDAwMD5cdTAwMWKtKFx1MDAwZVbTQjp6nVHPXHUwMDEyoFchXHUwMDA0XHUwMDA1XHUwMDEw89+efUQ8io9XXHUwMDEyK+HjxJYmzvFcdTAwMDDuj/XzXHUwMDBiu+SG2GSXlf3axtawOtSbe/u360lcdTAwMDSQMkFvdK7XXHUwMDE5qFx1MDAxYb91XGa3Plx1MDAxMtrhjvrtRG7/gvbvPoXkYVx1MDAwMmd3uLtcdTAwMTj5ctBDaZTwi4TLrFx1MDAwN43DXHUwMDA2V4YsVcL6l+ahXuGw+tgo3MHzrN3/6DGvcDH9K4Vhrdps9jv1XHUwMDFmPe5cdTAwMTU2XHUwMDA2jTqsp13tXHUwMDFjVnvVVmNQqPbqhV5j9NJcdTAwMWY8XHUwMDE2QK70XHUwMDFh01x1MDAwMZI/MpnN9Jsq4H/jvWZULr+5K9dOtlx1MDAxZppcdTAwMTfH5dvKVXtTrLVOXHUwMDFkXlPUcZHBWedEol+EU1x0gqdFlIFcdTAwMDc5XHUwMDFkwkZcdTAwMDVcdTAwMDf3Uc32zYfrZMCmT3t+M4lcdTAwMTNTZyidzSSgnuFcZsyvwpHDxjf3MDcrQbPSXvmsiFHGLcdcdTAwMTMjl7CKtjaYlkukL89idWNjtFx1MDAwMe9KXHSZ5pw4ZqOf6+Lrzrnolt+2XHUwMDFhdX5/+LI22msl3ujg4lx1MDAxM1x1MDAwZjPTXHUwMDA17ECwg8FcdTAwMTBcdTAwMDFFXHUwMDExXHUwMDAwqoVpjdOs/NL/Y6Nr7lx1MDAxMUM4nlxiXGJcdTAwMDbb3lGOYCVcdTAwMDZ9JNH4XHUwMDA0sDNSvtMjdvrDyjtdTjvmqvCexl+KzohcdTAwMDBcdTAwMDbUXHUwMDFhq2Az3+nWpDqe+bnT//FLs/9RfXo6m/Zk+utDgI+2XZ/jwp/fXHUwMDFiNZ5mPDj91mG/3tjqVe868zfxx6TdeFlcdTAwMGbvpP/RnL4wjDz9YHFcdTAwMTc1plx1MDAwZuC//vFf/1x1MDAwZrCHTlIifQ== agentd process clusterHOST (pc, vm, ...) agentd- local controller- API for clients- API for operator- session API- sandbox SDK consumerOn dev PC (locally or in the cloud)On k8sKernelKVM, cgroup, ..QEMU, Docker, Libkrun,MicrosandboxSandbox backendSandbox network(optional) SDK- Secret store APIs - Authorization engine- Sandbox facade agent- agent-runtime- Session APIBuilding blocks / layers operator- scheduling- orchestrationGH ARC / actionsrunner stuffDesigner agents /AltinityLevels node sandboxsessions(claude code, ...) sessiond- PTY- harness sandbox 0 (vm, container, isolate, ...)- sessiond, sessionctl- PTY allocation, harness, ...pod 0 agentd- local controller- API for clients- API for operator- session API- sandbox SDK consumer sandbox 0 (vm, container, isolate, ...)- sessiond, sessionctl- PTY allocation, harness, ...pod operator- scheduler/orchestrator- 1 pod per manifest? kind: ConfigMap spec: manifest.yaml: ... Dockerfile: ... (or a bundle)Kubernetes APISandbox layerSandbox imageresolverAgent layerOSS, infra agentd- Session API- Sandbox backendsSandbox SDKAgent layerCredentialManagerCodexCliAdapterSecretStoreNetworkresolvesetClaudeCodeAdapterrefresh MSB processNetworklibkruncontrolauthorizePolicyEnginehttp.requestsecret.useSession API sandboxvirtio-netvirtio-vsockComponents.envSandboxServiceensuresandboxagentctlapply -f manifest.yamlAgent APIAgentManagerPolicyPrincipal + Action +Resourcesomehow new sessionrefreshspawn.session (PTY)exec/PTYread harness stateexec/PTYread harness stateIn the case ofMicrosandbox providersqlitesessiondOS- CA trustdockerdshim /var/docker.sock1. Make branch2. Update scaffold3. CredentialManager and network connection \ No newline at end of file diff --git a/agentctl/install.ps1 b/agentctl/install.ps1 new file mode 100644 index 0000000..799f35f --- /dev/null +++ b/agentctl/install.ps1 @@ -0,0 +1,127 @@ +$ErrorActionPreference = "Stop" + +$Repository = if ($env:AGENT_GITHUB_REPOSITORY) { $env:AGENT_GITHUB_REPOSITORY } else { "Altinn/altinn-studio" } +$Version = $env:AGENT_VERSION +$InstallMode = if ($env:AGENT_INSTALL_MODE) { $env:AGENT_INSTALL_MODE } else { "managed" } +$InstallRoot = if ($env:AGENT_INSTALL_ROOT) { $env:AGENT_INSTALL_ROOT } else { Join-Path $env:LOCALAPPDATA "Agent" } +$BinDirectory = if ($env:AGENT_INSTALL_DIR) { $env:AGENT_INSTALL_DIR } else { Join-Path $InstallRoot "bin" } +$AgentHome = if ($env:AGENT_HOME) { $env:AGENT_HOME } else { Join-Path $env:USERPROFILE ".agent" } +$LocalArchive = $env:AGENT_LOCAL_ARCHIVE +if ($InstallMode -notin @("managed", "standalone")) { + throw 'AGENT_INSTALL_MODE must be "managed" or "standalone"' +} +$InstallRoot = [IO.Path]::GetFullPath($InstallRoot) +$BinDirectory = [IO.Path]::GetFullPath($BinDirectory) +$AgentHome = [IO.Path]::GetFullPath($AgentHome) +if ($LocalArchive) { $LocalArchive = [IO.Path]::GetFullPath($LocalArchive) } +$JournalPath = Join-Path $InstallRoot "update.json" + +function Invoke-Completion($Journal) { + $Target = $Journal.targetRelease + $TargetVersion = $Journal.targetVersion + if (-not $Target -or -not $TargetVersion) { + throw "The Agent update journal does not name a usable staged release: $JournalPath" + } + $Agentctl = Join-Path $Target "agentctl.exe" + if (-not (Test-Path $Agentctl -PathType Leaf)) { + throw "The Agent update journal does not name a usable staged release: $JournalPath" + } + $Arguments = @( + "--home", $AgentHome, "self", "__complete-update", + "--install-root", $InstallRoot, "--bin-directory", $BinDirectory, + "--target-release", $Target, + "--target-version", $TargetVersion, "--repository", $Repository + ) + if ($Journal.previousRelease) { $Arguments += @("--previous-release", $Journal.previousRelease) } + & $Agentctl @Arguments + if ($LASTEXITCODE -ne 0) { throw "Target Agent updater exited with code $LASTEXITCODE" } +} + +if ($InstallMode -eq "managed" -and (Test-Path $JournalPath -PathType Leaf)) { + $Journal = Get-Content $JournalPath -Raw | ConvertFrom-Json + if ($Journal.phase -ne "complete") { + Invoke-Completion $Journal + Write-Host "Installed agentctl and agentd to $BinDirectory" + exit 0 + } +} + +if ($LocalArchive -and -not $Version) { throw "AGENT_VERSION is required when AGENT_LOCAL_ARCHIVE is set" } +if (-not $Version) { + $Page = 1 + do { + $Releases = Invoke-RestMethod "https://api.github.com/repos/$Repository/releases?per_page=100&page=$Page" + $Release = $Releases | Where-Object { $_.tag_name -like "experimental-agent/v*" } | Select-Object -First 1 + $Page++ + } while (-not $Release -and $Releases.Count -eq 100) + if (-not $Release) { throw "Could not resolve the latest experimental Agent release" } + $Version = $Release.tag_name.Substring("experimental-agent/".Length) +} +if (-not $Version.StartsWith("v")) { $Version = "v$Version" } + +$Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$Platform = switch ($Architecture) { + "X64" { "windows-x86_64" } + "Arm64" { "windows-aarch64" } + default { throw "Unsupported Windows architecture: $Architecture" } +} +$Temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("agentctl-install-" + [guid]::NewGuid()) +$SourceRelease = Join-Path $Temporary "release" +New-Item -ItemType Directory -Path $Temporary | Out-Null +try { + $ReleasesDirectory = Join-Path $InstallRoot "releases" + $Target = Join-Path $ReleasesDirectory "$Version-$Platform" + if ($InstallMode -eq "standalone" -or -not (Test-Path $Target -PathType Container)) { + if ($LocalArchive) { + $Archive = Split-Path $LocalArchive -Leaf + Copy-Item $LocalArchive (Join-Path $Temporary $Archive) + $Checksum = if ($env:AGENT_LOCAL_ARCHIVE_SHA256) { $env:AGENT_LOCAL_ARCHIVE_SHA256 } else { "$LocalArchive.sha256" } + } else { + $Archive = "agent-$Platform.tar.gz" + $Base = "https://github.com/$Repository/releases/download/experimental-agent/$Version" + Invoke-WebRequest "$Base/$Archive" -OutFile (Join-Path $Temporary $Archive) + $Checksum = Join-Path $Temporary "$Archive.sha256" + Invoke-WebRequest "$Base/$Archive.sha256" -OutFile $Checksum + } + $Expected = (Get-Content $Checksum -Raw).Split(' ')[0].Trim().ToLowerInvariant() + $Actual = (Get-FileHash (Join-Path $Temporary $Archive) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($Actual -ne $Expected) { throw "Agent archive checksum mismatch" } + New-Item -ItemType Directory -Path $SourceRelease | Out-Null + Push-Location $SourceRelease + try { + tar -xzf "../$Archive" + if ($LASTEXITCODE -ne 0) { throw "Failed to extract Agent archive" } + } finally { + Pop-Location + } + if ($InstallMode -eq "standalone") { + New-Item -ItemType Directory -Force -Path $BinDirectory | Out-Null + Copy-Item -Force (Join-Path $SourceRelease "agentctl.exe") (Join-Path $BinDirectory "agentctl.exe") + Copy-Item -Force (Join-Path $SourceRelease "agentd.exe") (Join-Path $BinDirectory "agentd.exe") + Write-Host "Installed standalone agentctl and agentd to $BinDirectory" + exit 0 + } + & (Join-Path $SourceRelease "agentctl.exe") --home $AgentHome self __publish-release ` + --install-root $InstallRoot --bin-directory $BinDirectory ` + --source-release $SourceRelease --target-version $Version + if ($LASTEXITCODE -ne 0) { throw "Target Agent publisher exited with code $LASTEXITCODE" } + } + + $Previous = $null + $Current = Join-Path $InstallRoot "current" + if (Test-Path $Current -PathType Leaf) { $Previous = (Get-Content $Current -Raw).Trim() } + $Journal = [pscustomobject]@{ + targetRelease = $Target + targetVersion = $Version + previousRelease = $Previous + } + Invoke-Completion $Journal +} finally { + if (Test-Path $Temporary) { Remove-Item -Recurse -Force $Temporary } +} + +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if (($UserPath -split ';') -notcontains $BinDirectory) { + [Environment]::SetEnvironmentVariable("Path", (($UserPath.TrimEnd(';') + ';' + $BinDirectory).TrimStart(';')), "User") +} +Write-Host "Installed agentctl and agentd to $BinDirectory" diff --git a/agentctl/install.sh b/agentctl/install.sh new file mode 100755 index 0000000..54e4a67 --- /dev/null +++ b/agentctl/install.sh @@ -0,0 +1,132 @@ +#!/bin/sh +set -eu +umask 077 + +repository="${AGENT_GITHUB_REPOSITORY:-Altinn/altinn-studio}" +version="${AGENT_VERSION:-}" +install_mode="${AGENT_INSTALL_MODE:-managed}" +bin_directory="${AGENT_INSTALL_DIR:-${HOME}/.local/bin}" +install_root="${AGENT_INSTALL_ROOT:-${XDG_DATA_HOME:-${HOME}/.local/share}/agent}" +agent_home="${AGENT_HOME:-${HOME}/.agent}" +local_archive="${AGENT_LOCAL_ARCHIVE:-}" + +case "${install_mode}" in + managed | standalone) ;; + *) echo "AGENT_INSTALL_MODE must be \"managed\" or \"standalone\"" >&2; exit 1 ;; +esac +case "${install_root}" in /*) ;; *) install_root="$(pwd)/${install_root}" ;; esac +case "${bin_directory}" in /*) ;; *) bin_directory="$(pwd)/${bin_directory}" ;; esac +case "${agent_home}" in /*) ;; *) agent_home="$(pwd)/${agent_home}" ;; esac +if [ -n "${local_archive}" ]; then + case "${local_archive}" in /*) ;; *) local_archive="$(pwd)/${local_archive}" ;; esac +fi +journal="${install_root}/update.json" + +resume_update() { + target="$(sed -n 's/^ "targetRelease": "\(.*\)",$/\1/p' "${journal}")" + target_version="$(sed -n 's/^ "targetVersion": "\(.*\)",$/\1/p' "${journal}")" + previous="$(sed -n 's/^ "previousRelease": "\(.*\)",$/\1/p' "${journal}")" + if [ -z "${target}" ] || [ -z "${target_version}" ] || [ ! -x "${target}/agentctl" ]; then + echo "The Agent update journal does not name a usable staged release: ${journal}" >&2 + exit 1 + fi + set -- --home "${agent_home}" self __complete-update \ + --install-root "${install_root}" --bin-directory "${bin_directory}" \ + --target-release "${target}" \ + --target-version "${target_version}" --repository "${repository}" + if [ -n "${previous}" ]; then + set -- "$@" --previous-release "${previous}" + fi + "${target}/agentctl" "$@" +} + +if [ "${install_mode}" = managed ] && [ -f "${journal}" ] && ! grep -q '^ "phase": "complete"$' "${journal}"; then + resume_update + echo "Installed agentctl and agentd to ${bin_directory}" + exit 0 +fi + +if [ -n "${local_archive}" ] && [ -z "${version}" ]; then + echo "AGENT_VERSION is required when AGENT_LOCAL_ARCHIVE is set" >&2 + exit 1 +fi +if [ -z "${version}" ]; then + page=1 + while [ -z "${version}" ]; do + releases="$(curl -fsSL "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}")" + version="$(printf '%s' "${releases}" \ + | sed -n 's/.*"tag_name": "experimental-agent\/\(v[^"]*\)".*/\1/p' \ + | head -n 1)" + [ "${releases}" != "[]" ] || break + page=$((page + 1)) + done +fi +if [ -z "${version}" ]; then + echo "Could not resolve the latest experimental Agent release" >&2 + exit 1 +fi +case "${version}" in + v*) ;; + *) version="v${version}" ;; +esac + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) platform=linux-x86_64 ;; + Linux-aarch64 | Linux-arm64) platform=linux-aarch64 ;; + Darwin-arm64) platform=macos-aarch64 ;; + *) echo "Unsupported Agent host: $(uname -s) $(uname -m)" >&2; exit 1 ;; +esac + +temporary="$(mktemp -d -t agentctl-install.XXXXXXXX)" +source_release="${temporary}/release" +trap 'rm -rf "${temporary}"' EXIT HUP INT TERM + +target="${install_root}/releases/${version}-${platform}" +if [ "${install_mode}" = standalone ] || [ ! -d "${target}" ]; then + if [ -n "${local_archive}" ]; then + archive="$(basename "${local_archive}")" + cp "${local_archive}" "${temporary}/${archive}" + cp "${AGENT_LOCAL_ARCHIVE_SHA256:-${local_archive}.sha256}" "${temporary}/${archive}.sha256" + else + archive="agent-${platform}.tar.gz" + base="https://github.com/${repository}/releases/download/experimental-agent/${version}" + curl -fsSL "${base}/${archive}" -o "${temporary}/${archive}" + curl -fsSL "${base}/${archive}.sha256" -o "${temporary}/${archive}.sha256" + fi + if command -v sha256sum >/dev/null 2>&1; then + (cd "${temporary}" && sha256sum -c "${archive}.sha256") + else + (cd "${temporary}" && shasum -a 256 -c "${archive}.sha256") + fi + mkdir "${source_release}" + tar -xzf "${temporary}/${archive}" -C "${source_release}" + chmod 0755 "${source_release}/agentctl" "${source_release}/agentd" + if [ "${install_mode}" = standalone ]; then + mkdir -p "${bin_directory}" + install -m 0755 "${source_release}/agentctl" "${bin_directory}/agentctl" + install -m 0755 "${source_release}/agentd" "${bin_directory}/agentd" + echo "Installed standalone agentctl and agentd to ${bin_directory}" + exit 0 + fi + "${source_release}/agentctl" --home "${agent_home}" self __publish-release \ + --install-root "${install_root}" --bin-directory "${bin_directory}" \ + --source-release "${source_release}" --target-version "${version}" +fi + +previous="" +if [ -L "${install_root}/current" ]; then + previous="$(readlink "${install_root}/current")" + case "${previous}" in + /*) ;; + *) previous="${install_root}/${previous}" ;; + esac +fi +set -- --home "${agent_home}" self __complete-update \ + --install-root "${install_root}" --bin-directory "${bin_directory}" \ + --target-release "${target}" \ + --target-version "${version}" --repository "${repository}" +if [ -n "${previous}" ]; then + set -- "$@" --previous-release "${previous}" +fi +"${target}/agentctl" "$@" +echo "Installed agentctl and agentd to ${bin_directory}" diff --git a/agentctl/installation-test.sh b/agentctl/installation-test.sh new file mode 100755 index 0000000..a04b509 --- /dev/null +++ b/agentctl/installation-test.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Exercises standalone installation and the managed installation and upgrade lifecycle. +set -euo pipefail + +old_version="v0.0.1-dev.upgrade-smoke" +target_version="v0.1.0-preview.2.smoke" +smoke_root="$(mktemp -d /tmp/au.XXXXXXXX)" +export AGENT_SMOKE_ID="${smoke_root##*/}" +# Build outside smoke_root: CI runners keep /tmp on a small tmpfs, and the two +# dev-profile builds below do not fit there. +smoke_target="${CARGO_TARGET_DIR:-$(git rev-parse --show-toplevel)/target}/upgrade-smoke-${AGENT_SMOKE_ID}" +binary_directory="${smoke_target}/debug" +target_binaries="${smoke_root}/target-binaries" +old_archive="${smoke_root}/old.tar.gz" +target_archive="${smoke_root}/target.tar.gz" +export AGENT_HOME="${smoke_root}/home" + +cleanup() { + status=$? + log="${AGENT_HOME}/agentd.log" + if [ "${RUNNER_OS:-}" = "Windows" ]; then + log="$(cygpath -u "${AGENT_HOME}")/agentd.log" + fi + if [ "${status}" -ne 0 ] && [ -f "${log}" ]; then + printf '%s\n' 'agentd.log:' >&2 + cat "${log}" >&2 + fi + if [ "${RUNNER_OS:-}" = "Windows" ]; then + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + Get-CimInstance Win32_Process | + Where-Object { + $_.Name -eq "agentd.exe" -and + ($_.CommandLine -like "*$env:AGENT_SMOKE_ID*" -or $_.ExecutablePath -like "*$env:AGENT_SMOKE_ID*") + } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force } + ' 2>/dev/null || true + else + pkill -f "agentd.*--home ${AGENT_HOME}" 2>/dev/null || true + fi + rm -rf -- "${smoke_root}" "${smoke_target}" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "${smoke_root}" + +# Build release fixtures + +CARGO_TARGET_DIR="${smoke_target}" CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 \ + AGENT_VERSION="${old_version}" cargo build --locked -p agent --bins +./agent/package.sh "${old_archive}" "${binary_directory}" +CARGO_TARGET_DIR="${smoke_target}" CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 \ + AGENT_VERSION="${target_version}" cargo build --locked -p agent --bins +suffix="" +if [ -f "${binary_directory}/agentctl.exe" ]; then + suffix=".exe" +fi +mkdir "${target_binaries}" +mv "${binary_directory}/agentctl${suffix}" "${binary_directory}/agentd${suffix}" "${target_binaries}/" +rm -rf -- "${smoke_target}" +./agent/package.sh "${target_archive}" "${target_binaries}" + +if [ "${RUNNER_OS:-}" = "Windows" ]; then + # Standalone installation + + standalone_root="${smoke_root}/standalone" + mkdir -p "${standalone_root}/managed" + printf '{ "phase": "prepared" }\n' > "${standalone_root}/managed/update.json" + export AGENT_INSTALL_MODE=standalone + export AGENT_INSTALL_ROOT="$(cygpath -w "${standalone_root}/managed")" + export AGENT_INSTALL_DIR="$(cygpath -w "${standalone_root}/bin")" + export AGENT_HOME="$(cygpath -w "${standalone_root}/home")" + export AGENT_VERSION="${target_version}" + export AGENT_LOCAL_ARCHIVE="$(cygpath -w "${target_archive}")" + pwsh -NoProfile -File "$(cygpath -w agent/install.ps1)" + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + $agentctl = Join-Path $env:AGENT_INSTALL_DIR "agentctl.exe" + $agentd = Join-Path $env:AGENT_INSTALL_DIR "agentd.exe" + if (-not (Test-Path $agentctl -PathType Leaf) -or -not (Test-Path $agentd -PathType Leaf)) { + throw "standalone installation did not copy both binaries" + } + $actual = (& $agentctl --version | Out-String).Trim() + if ($actual -ne "agentctl $env:AGENT_VERSION") { + throw "standalone agentctl reports $actual" + } + if (Test-Path (Join-Path $env:AGENT_INSTALL_ROOT "releases")) { + throw "standalone installation created a managed release tree" + } + ' + test "$(cat "${standalone_root}/managed/update.json")" = '{ "phase": "prepared" }' + test ! -e "${standalone_root}/home" + unset AGENT_INSTALL_MODE + + # Managed installation and self-update + + AGENT_INSTALL_ROOT="$(cygpath -w "${smoke_root}/install")" + AGENT_INSTALL_DIR="$(cygpath -w "${smoke_root}/bin")" + AGENT_HOME="$(cygpath -w "${smoke_root}/home")" + export AGENT_VERSION="${old_version}" + AGENT_LOCAL_ARCHIVE="$(cygpath -w "${old_archive}")" + export AGENT_INSTALL_ROOT AGENT_INSTALL_DIR AGENT_HOME AGENT_LOCAL_ARCHIVE + pwsh -NoProfile -File "$(cygpath -w agent/install.ps1)" + export AGENT_SMOKE_BIN="${AGENT_INSTALL_DIR}" + AGENT_LOCAL_ARCHIVE="$(cygpath -w "${target_archive}")" + export AGENT_LOCAL_ARCHIVE + export AGENT_TARGET_VERSION="${target_version}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + $agentctl = Join-Path $env:AGENT_SMOKE_BIN "agentctl.cmd" + & $agentctl --home $env:AGENT_HOME self update --version $env:AGENT_TARGET_VERSION + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $actual = (& $agentctl --version | Out-String).Trim() + if ($actual -ne "agentctl $env:AGENT_TARGET_VERSION") { + throw "updated agentctl reports $actual" + } + & $agentctl --home $env:AGENT_HOME self update --version $env:AGENT_TARGET_VERSION + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ' + AGENT_INSTALL_ROOT="$(cygpath -w "${smoke_root}/install")" + export AGENT_INSTALL_DIR="${AGENT_SMOKE_BIN}" + AGENT_LOCAL_ARCHIVE="$(cygpath -w "${old_archive}")" + export AGENT_VERSION="${old_version}" + export AGENT_INSTALL_ROOT AGENT_LOCAL_ARCHIVE + pwsh -NoProfile -File "$(cygpath -w agent/install.ps1)" + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + $agentctl = Join-Path $env:AGENT_SMOKE_BIN "agentctl.cmd" + $actual = (& $agentctl --version | Out-String).Trim() + if ($actual -ne "agentctl $env:AGENT_VERSION") { + throw "installer did not replace a newer release with development build $actual" + } + ' +else + # Standalone installation + + standalone_root="${smoke_root}/standalone" + mkdir -p "${standalone_root}/managed" + printf '{ "phase": "prepared" }\n' > "${standalone_root}/managed/update.json" + AGENT_INSTALL_MODE=standalone \ + AGENT_INSTALL_ROOT="${standalone_root}/managed" \ + AGENT_INSTALL_DIR="${standalone_root}/bin" \ + AGENT_HOME="${standalone_root}/home" \ + AGENT_VERSION="${target_version}" \ + AGENT_LOCAL_ARCHIVE="${target_archive}" \ + ./agent/install.sh + test -x "${standalone_root}/bin/agentctl" + test -x "${standalone_root}/bin/agentd" + test "$("${standalone_root}/bin/agentctl" --version)" = "agentctl ${target_version}" + test "$(find "${standalone_root}/bin" -type f | wc -l | tr -d ' ')" = 2 + test ! -e "${standalone_root}/managed/releases" + test ! -e "${standalone_root}/home" + test "$(cat "${standalone_root}/managed/update.json")" = '{ "phase": "prepared" }' + + # Managed installation and self-update + + export AGENT_INSTALL_ROOT="${smoke_root}/install" + export AGENT_INSTALL_DIR="${smoke_root}/bin" + export AGENT_HOME="${smoke_root}/home" + export AGENT_VERSION="${old_version}" + export AGENT_LOCAL_ARCHIVE="${old_archive}" + ./agent/install.sh + agentctl="${AGENT_INSTALL_DIR}/agentctl" + export AGENT_LOCAL_ARCHIVE="${target_archive}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + "${agentctl}" --home "${AGENT_HOME}" self update --version "${target_version}" + test "$("${agentctl}" --version)" = "agentctl ${target_version}" + "${agentctl}" --home "${AGENT_HOME}" self update --version "${target_version}" + export AGENT_INSTALL_ROOT="${smoke_root}/install" + export AGENT_INSTALL_DIR="${smoke_root}/bin" + export AGENT_LOCAL_ARCHIVE="${old_archive}" + export AGENT_VERSION="${old_version}" + ./agent/install.sh + test "$("${agentctl}" --version)" = "agentctl ${old_version}" +fi diff --git a/agentctl/make-user-install.ps1 b/agentctl/make-user-install.ps1 new file mode 100644 index 0000000..b0d608c --- /dev/null +++ b/agentctl/make-user-install.ps1 @@ -0,0 +1,85 @@ +[CmdletBinding()] +param( + [string]$Version +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-PathFromRoot([string]$Path, [string]$Root) { + if ([IO.Path]::IsPathRooted($Path)) { + return [IO.Path]::GetFullPath($Path) + } + return [IO.Path]::GetFullPath((Join-Path $Root $Path)) +} + +function Restore-ProcessEnvironment([string]$Name, [AllowNull()][string]$Value) { + if ($null -eq $Value) { + Remove-Item "Env:$Name" -ErrorAction SilentlyContinue + } else { + Set-Item "Env:$Name" $Value + } +} + +$Root = [IO.Path]::GetFullPath($PSScriptRoot) +if (-not $Version) { + $Version = "v0.0.1-dev.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))" +} elseif (-not $Version.StartsWith("v")) { + $Version = "v$Version" +} + +$TargetRoot = if ($env:CARGO_TARGET_DIR) { + Resolve-PathFromRoot $env:CARGO_TARGET_DIR $Root +} else { + Resolve-PathFromRoot "../../target" $Root +} +$ReleaseBinDirectory = Join-Path $TargetRoot "release" +$ArchiveDirectory = Join-Path $Root "build/user-install" +$ArchiveName = "agent-$Version.tar.gz" +$Archive = Join-Path $ArchiveDirectory $ArchiveName +$Installer = Join-Path $Root "agent/install.ps1" +$PreviousVersion = [Environment]::GetEnvironmentVariable("AGENT_VERSION", "Process") +$PreviousArchive = [Environment]::GetEnvironmentVariable("AGENT_LOCAL_ARCHIVE", "Process") +$PreviousChecksum = [Environment]::GetEnvironmentVariable("AGENT_LOCAL_ARCHIVE_SHA256", "Process") + +Push-Location $Root +try { + Write-Host "Building experimental Agent $Version..." + $env:AGENT_VERSION = $Version + & cargo build --release --locked -p agent --bins + if ($LASTEXITCODE -ne 0) { + throw "Cargo build exited with code $LASTEXITCODE" + } + + foreach ($Binary in @("agentctl.exe", "agentd.exe")) { + $BinaryPath = Join-Path $ReleaseBinDirectory $Binary + if (-not (Test-Path -LiteralPath $BinaryPath -PathType Leaf)) { + throw "Missing Agent binary: $BinaryPath" + } + } + + New-Item -ItemType Directory -Force -Path $ArchiveDirectory | Out-Null + if (Test-Path -LiteralPath $Archive) { + Remove-Item -LiteralPath $Archive -Force + } + & tar.exe -czf $Archive -C $ReleaseBinDirectory agentctl.exe agentd.exe + if ($LASTEXITCODE -ne 0) { + throw "Agent packaging exited with code $LASTEXITCODE" + } + + $Digest = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash.ToLowerInvariant() + $Checksum = "$Digest $ArchiveName`n" + [IO.File]::WriteAllText("$Archive.sha256", $Checksum, [Text.UTF8Encoding]::new($false)) + + $env:AGENT_LOCAL_ARCHIVE = $Archive + $env:AGENT_LOCAL_ARCHIVE_SHA256 = "$Archive.sha256" + & $Installer + if ($LASTEXITCODE -ne 0) { + throw "Agent installer exited with code $LASTEXITCODE" + } +} finally { + Pop-Location + Restore-ProcessEnvironment "AGENT_VERSION" $PreviousVersion + Restore-ProcessEnvironment "AGENT_LOCAL_ARCHIVE" $PreviousArchive + Restore-ProcessEnvironment "AGENT_LOCAL_ARCHIVE_SHA256" $PreviousChecksum +} diff --git a/agentctl/package.sh b/agentctl/package.sh new file mode 100755 index 0000000..dd62f9b --- /dev/null +++ b/agentctl/package.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 ARCHIVE BINARY_DIRECTORY" >&2 + exit 1 +fi + +archive="$1" +binary_directory="$2" +executable_suffix="" +if [ -f "${binary_directory}/agentctl.exe" ] && [ -f "${binary_directory}/agentd.exe" ]; then + executable_suffix=".exe" +fi + +for binary in agentctl agentd; do + if [ ! -f "${binary_directory}/${binary}${executable_suffix}" ]; then + echo "Missing Agent binary: ${binary_directory}/${binary}${executable_suffix}" >&2 + exit 1 + fi +done + +archive_directory="$(dirname "${archive}")" +archive_name="$(basename "${archive}")" +mkdir -p "${archive_directory}" +temporary="$(mktemp -d -t agentctl-package.XXXXXXXX)" +trap 'rm -rf "${temporary}"' EXIT HUP INT TERM + +cp "${binary_directory}/agentctl${executable_suffix}" "${binary_directory}/agentd${executable_suffix}" "${temporary}/" +tar -czf "${archive}" -C "${temporary}" "agentctl${executable_suffix}" "agentd${executable_suffix}" +if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "${archive}" | awk '{ print $1 }')" +else + digest="$(shasum -a 256 "${archive}" | awk '{ print $1 }')" +fi +printf '%s %s\n' "${digest}" "${archive_name}" > "${archive}.sha256" diff --git a/agentctl/src/authorization/agent_policy.rs b/agentctl/src/authorization/agent_policy.rs new file mode 100644 index 0000000..ca4c981 --- /dev/null +++ b/agentctl/src/authorization/agent_policy.rs @@ -0,0 +1,265 @@ +//! Agent implementation of `sandbox_authorization::PolicyEngine`. + +use std::{cell::RefCell, collections::BTreeMap}; + +use sandbox_authorization::{ + AuthorizationDecision, AuthorizationRequest, PolicyEngine, + vocabulary::{action, context, principal_kind}, +}; + +use crate::Agent; +use sandbox::SandboxName; + +/// Live policy registry keyed by stable Agent/Sandbox name. +#[derive(Default)] +pub struct AgentPolicyEngine { + agents: RefCell>, + platform_endpoint: RefCell>, +} + +struct PlatformEndpoint { + host: String, + port: u16, +} + +impl AgentPolicyEngine { + /// Creates an empty, fail-closed policy registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers the one Sandbox-reachable host endpoint exposed by the platform. + /// + /// Host-destined traffic (the Network Backend's host alias, and raw dials + /// into host-reserved address ranges) is denied except for exactly this + /// endpoint; without a registered endpoint it is denied entirely. + pub fn set_platform_endpoint(&self, host: impl Into, port: u16) { + *self.platform_endpoint.borrow_mut() = Some(PlatformEndpoint { + host: host.into(), + port, + }); + } + + /// Replaces the policy for one desired Agent generation. + pub fn set_agent( + &self, + sandbox: &SandboxName, + agent: &Agent, + managed_secrets: impl IntoIterator)>, + ) { + let secrets = agent + .spec + .secrets + .iter() + .map(|secret| (secret.environment.clone(), secret.allowed_hosts.clone())) + .chain(managed_secrets) + .collect(); + self.agents.borrow_mut().insert( + sandbox.as_str().into(), + AgentPolicy { + denied_hosts: agent.spec.network.deny.clone(), + secrets, + }, + ); + } + + /// Removes policy for a released Agent. + pub fn remove_agent(&self, sandbox: &SandboxName) { + self.agents.borrow_mut().remove(sandbox.as_str()); + } +} + +struct AgentPolicy { + denied_hosts: Vec, + secrets: BTreeMap>, +} + +impl PolicyEngine for AgentPolicyEngine { + fn evaluate( + &self, + request: AuthorizationRequest, + ) -> sandbox_authorization::LocalFuture<'_, Result> { + Box::pin(async move { Ok(self.evaluate_request(&request)) }) + } +} + +impl AgentPolicyEngine { + fn evaluate_request(&self, request: &AuthorizationRequest) -> AuthorizationDecision { + if request.principal.kind != principal_kind::SANDBOX { + return AuthorizationDecision::Deny; + } + let Some(agent_name) = request + .context + .get(context::SANDBOX_NAME) + .and_then(|value| value.as_str()) + else { + return AuthorizationDecision::Deny; + }; + let agents = self.agents.borrow(); + let Some(policy) = agents.get(agent_name) else { + return AuthorizationDecision::Deny; + }; + let host = request_host(request); + if let Some(host_destined) = self.host_destined_decision(request, host) { + return host_destined; + } + match request.action.as_str() { + action::NETWORK_CONNECT | action::DNS_QUERY | action::HTTP_REQUEST => host + .map_or(AuthorizationDecision::Deny, |host| { + decision(!policy.denied_hosts.iter().any(|pattern| host_matches(pattern, host))) + }), + action::SECRET_USE => { + let Some(host) = host else { + return AuthorizationDecision::Deny; + }; + decision( + policy + .secrets + .get(&request.resource.id) + .is_some_and(|patterns| patterns.iter().any(|pattern| host_matches(pattern, host))), + ) + } + _ => AuthorizationDecision::Deny, + } + } +} + +impl AgentPolicyEngine { + /// Decides host-destined traffic: the Network Backend rewrites its host + /// alias (and only its gateway addresses) to host loopback, so anything + /// aimed at the host must match the registered Platform API endpoint exactly. + /// + /// The trusted Network Backend reports host-destined flows through the + /// `network.destinationIsHost` attribute, set from the same gateway-to- + /// loopback rewrite it applies at dial time — this is the authoritative + /// signal. The host-reserved range check is kept as defense in depth for + /// requests that predate the flag or arrive without it. Returns `None` for + /// traffic that is not host-destined. + fn host_destined_decision( + &self, + request: &AuthorizationRequest, + host: Option<&str>, + ) -> Option { + let action = request.action.as_str(); + if !matches!( + action, + action::NETWORK_CONNECT | action::DNS_QUERY | action::HTTP_REQUEST + ) { + return None; + } + let endpoint = self.platform_endpoint.borrow(); + let alias = endpoint + .as_ref() + .zip(host) + .is_some_and(|(endpoint, host)| host.eq_ignore_ascii_case(&endpoint.host)); + let destination = destination_address(request); + let flagged = request + .context + .get(context::NETWORK_DESTINATION_IS_HOST) + .and_then(sandbox_authorization::AuthorizationValue::as_bool) + .unwrap_or(false); + let reserved = destination.is_some_and(|address| is_host_reserved(address.ip())); + if !alias && !flagged && !reserved { + return None; + } + if action == action::DNS_QUERY { + // Resolving the alias only reveals the gateway address. + return Some(AuthorizationDecision::Allow); + } + let port = destination + .map(|address| address.port()) + .or_else(|| authority_port(request)); + let allowed = alias && endpoint.as_ref().is_some_and(|endpoint| port == Some(endpoint.port)); + Some(decision(allowed)) + } +} + +/// Destination socket address reported by the Network Backend, when present. +fn destination_address(request: &AuthorizationRequest) -> Option { + request + .context + .get(context::NETWORK_DESTINATION_ADDRESS) + .and_then(|value| value.as_str()) + .and_then(|value| value.parse().ok()) +} + +/// Port carried by the HTTP authority, when present. +fn authority_port(request: &AuthorizationRequest) -> Option { + request + .context + .get(context::HTTP_AUTHORITY) + .and_then(|value| value.as_str()) + .and_then(|authority| authority.rsplit_once(':')) + .and_then(|(_, port)| port.parse().ok()) +} + +/// Address ranges that can carry the Network Backend's gateway, plus ranges +/// that never name a legitimate upstream from inside a Sandbox. +const fn is_host_reserved(address: std::net::IpAddr) -> bool { + match address { + std::net::IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_link_local() + || v4.is_unspecified() + // CGNAT 100.64.0.0/10 carries the per-Sandbox gateway. + || (v4.octets()[0] == 100 && (v4.octets()[1] & 0b1100_0000) == 64) + } + std::net::IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + // ULA fc00::/7 carries the per-Sandbox gateway. + || (v6.octets()[0] & 0b1111_1110) == 0xfc + // Link-local fe80::/10. + || (v6.octets()[0] == 0xfe && (v6.octets()[1] & 0b1100_0000) == 0x80) + } + } +} + +fn request_host(request: &AuthorizationRequest) -> Option<&str> { + request + .context + .get(context::HTTP_AUTHORITY) + .and_then(|value| value.as_str()) + .or_else(|| { + request + .context + .get(context::NETWORK_HOSTNAME) + .and_then(|value| value.as_str()) + }) + .or_else(|| (request.action.as_str() == action::DNS_QUERY).then_some(request.resource.id.as_str())) + .map(without_port) +} + +fn without_port(authority: &str) -> &str { + if let Some(bracketed) = authority.strip_prefix('[') + && let Some(end) = bracketed.find(']') + { + return &bracketed[..end]; + } + authority + .rsplit_once(':') + .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .map_or(authority, |(host, _)| host) +} + +fn host_matches(pattern: &str, host: &str) -> bool { + let pattern = pattern.to_ascii_lowercase(); + let host = host.trim_end_matches('.').to_ascii_lowercase(); + pattern.strip_prefix("*.").map_or_else( + || host == pattern, + |suffix| { + host.len() > suffix.len() + && host.ends_with(suffix) + && host.as_bytes()[host.len() - suffix.len() - 1] == b'.' + }, + ) +} + +const fn decision(allowed: bool) -> AuthorizationDecision { + if allowed { + AuthorizationDecision::Allow + } else { + AuthorizationDecision::Deny + } +} diff --git a/agentctl/src/authorization/mod.rs b/agentctl/src/authorization/mod.rs new file mode 100644 index 0000000..7c0d5e4 --- /dev/null +++ b/agentctl/src/authorization/mod.rs @@ -0,0 +1,5 @@ +//! Authorization for operations originating inside Agent Sandboxes. + +mod agent_policy; + +pub use agent_policy::AgentPolicyEngine; diff --git a/agentctl/src/bin/agentctl/format.rs b/agentctl/src/bin/agentctl/format.rs new file mode 100644 index 0000000..f2d7753 --- /dev/null +++ b/agentctl/src/bin/agentctl/format.rs @@ -0,0 +1,409 @@ +use agent::{Agent, ConditionStatus}; + +/// Lists the declared access capabilities, or `-` when there are none. +pub(crate) fn format_access(spec: &agent::Spec) -> String { + if spec.access.is_empty() { + return "-".into(); + } + spec.access + .iter() + .map(|capability| match capability { + agent::AccessSpec::Ssh {} => "ssh", + agent::AccessSpec::Vnc {} => "vnc", + }) + .collect::>() + .join(",") +} + +/// Renders an SSH access descriptor as aligned `key: value` lines. +pub(crate) fn ssh_access_lines(access: &agent::ssh::AccessInfo) -> Vec { + vec![ + format!("Type: {}", access.kind), + format!("Agent: {}", access.agent), + format!("Agent ID: {}", access.agent_id), + format!("Alias: {}", access.alias), + format!("User: {}", access.user), + format!("Identity: {}", access.identity_file.display()), + format!("Known hosts: {}", access.known_hosts_file.display()), + format!("Config: {}", access.config_file.display()), + format!("Proxy: {}", access.proxy_command), + format!("Directory: {}", access.working_directory), + format!("Connect: ssh -F {} {}", access.config_file.display(), access.alias), + ] +} + +/// Renders a VNC access descriptor as aligned `key: value` lines. +pub(crate) fn vnc_access_lines(access: &agent::vnc::AccessInfo) -> Vec { + vec![ + format!("Type: {}", access.kind), + format!("Agent: {}", access.agent), + format!("Agent ID: {}", access.agent_id), + format!("Guest port: {}", access.guest_port), + access.web_guest_port.map_or_else( + || "Web port: - (none, or unknown until the Agent is next reconciled)".to_owned(), + |port| format!("Web port: {port} (agentctl vnc --web {})", access.agent), + ), + format!("Forward: {}", access.forward_command), + format!("Connect: agentctl vnc {}", access.agent), + ] +} + +pub(crate) fn describe_agent_lines(agent: &Agent) -> Vec { + let provider = agent + .status + .sandbox + .as_ref() + .map_or("-", |assignment| assignment.provider().as_str()); + let sandbox = agent + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .map_or_else(|| "-".into(), ToString::to_string); + + let source = agent.status.provenance.as_ref().map_or_else( + || "-".into(), + |provenance| { + provenance + .manifest_path + .as_ref() + .unwrap_or(&provenance.source_directory) + .display() + .to_string() + }, + ); + + let secrets = if agent.spec.secrets.is_empty() { + "-".to_owned() + } else { + agent.status.provenance.as_ref().map_or_else( + || "-".into(), + |provenance| { + provenance + .env_file + .clone() + .unwrap_or_else(|| provenance.source_directory.join(agent::control_plane::ENV_FILE)) + .display() + .to_string() + }, + ) + }; + + let mut lines = vec![ + format!("Name: {}", agent.metadata.name), + format!("Generation: {}", agent.metadata.generation), + format!("Run state: {:?}", agent.spec.run_state()), + format!("Source: {source}"), + format!("Secrets: {secrets}"), + format!("Harnesses: {}", format_harnesses(&agent.spec)), + format!("Access: {}", format_access(&agent.spec)), + format!("Provider: {provider}"), + format!("Sandbox: {sandbox}"), + ]; + if let Some(failure) = agent.status.failure { + lines.push(format!("Failure: {}", failure_kind(failure))); + } + if let Some(provisioning) = &agent.status.progress { + let progress = &provisioning.progress; + lines.extend(provisioning_lines( + progress, + progress.output().lines().map(|line| line.text.as_str()), + )); + } + lines.push("Conditions:".to_owned()); + if agent.status.conditions.is_empty() { + lines.push(" None".to_owned()); + return lines; + } + let rows = agent + .status + .conditions + .iter() + .map(|condition| { + vec![ + condition.kind.clone(), + condition_status(condition.status).into(), + condition.reason.clone(), + condition.last_transition_time.map_or_else(|| "-".into(), format_age), + condition.message.clone(), + ] + }) + .collect::>(); + lines.extend(table_lines(&["TYPE", "STATUS", "REASON", "AGE", "MESSAGE"], &rows)); + lines +} + +/// An Agent's readiness and, after a failed pass, whether it is retried. +pub(crate) fn readiness_lines(status: &agent::Status) -> Vec { + let ready = status.ready_condition().map_or_else( + || "Unknown".to_owned(), + |ready| match ready.status { + ConditionStatus::True => "True".to_owned(), + condition => format!("{} ({})", condition_status(condition), ready.detail().trim_end()), + }, + ); + let mut lines = vec![format!("Ready: {ready}")]; + if let Some(failure) = status.failure { + lines.push(format!("Failure: {}", failure_kind(failure))); + } + lines +} + +/// Renders a pass: its phases with the steps they still retain, the step in +/// progress, the failure detail when it failed, then `output` under its own +/// heading. +pub(crate) fn provisioning_lines<'a>( + progress: &sandbox::progress::Progress, + output: impl IntoIterator, +) -> Vec { + let mut lines = vec!["Provisioning:".to_owned()]; + for phase in progress.finished() { + lines.push(format!( + " {} {} ({})", + outcome_mark(phase.outcome), + phase.phase.label, + crate::progress::duration(phase.elapsed_ms) + )); + lines.extend(phase.steps.iter().map(step_line)); + } + if let Some(current) = progress.current() { + lines.push(format!( + " → {} ({})", + current.phase.label, + format_age(current.started_at) + )); + lines.extend(current.finished_steps.iter().map(step_line)); + if let Some(step) = progress.current_step() { + lines.push(format!(" {}{}", step.name, measurement(step.measurement))); + } + } + if let sandbox::progress::OperationStatus::Failed { detail } = progress.status() { + lines.push(format!(" Failed: {detail}")); + } + let mut output = output.into_iter().peekable(); + if output.peek().is_some() { + lines.push(" Output:".to_owned()); + lines.extend(output.map(|line| format!(" {line}"))); + } + lines +} + +fn step_line(step: &sandbox::progress::FinishedStep) -> String { + format!( + " {} {}{} ({})", + outcome_mark(step.outcome), + step.name, + measurement(step.measurement), + crate::progress::duration(step.elapsed_ms) + ) +} + +const fn outcome_mark(outcome: sandbox::Outcome) -> &'static str { + match outcome { + sandbox::Outcome::Failed => "✗", + _ => "✓", + } +} + +fn measurement(measurement: Option) -> String { + measurement.map_or_else(String::new, |measurement| { + format!(": {}", crate::progress::format_measurement(measurement)) + }) +} + +pub(crate) const fn failure_kind(kind: agent::FailureKind) -> &'static str { + match kind { + agent::FailureKind::Invalid => "Invalid (change the Agent to continue)", + agent::FailureKind::Transient => "Transient (retrying in the background)", + } +} + +pub(crate) const fn condition_status(status: ConditionStatus) -> &'static str { + match status { + ConditionStatus::True => "True", + ConditionStatus::False => "False", + ConditionStatus::Unknown => "Unknown", + } +} + +pub(crate) fn format_harnesses(spec: &agent::Spec) -> String { + spec.harnesses + .iter() + .map(|harness| { + let suffix = if spec.harnesses.len() == 1 || harness.default { + " (default)" + } else { + "" + }; + let version = harness + .version + .as_deref() + .map(|version| format!(" {version}")) + .unwrap_or_default(); + format!("{}{version}{suffix}", harness.kind.as_str()) + }) + .collect::>() + .join(", ") +} + +pub(crate) const fn session_state(state: agent::sessions::State) -> &'static str { + match state { + agent::sessions::State::Starting => "Starting", + agent::sessions::State::Working => "Working", + agent::sessions::State::WaitingForInput => "WaitingForInput", + agent::sessions::State::Idle => "Idle", + agent::sessions::State::Archiving => "Archiving", + agent::sessions::State::Archived => "Archived", + agent::sessions::State::Failed => "Failed", + } +} + +pub(crate) fn format_age(created_at: time::OffsetDateTime) -> String { + let seconds = (time::OffsetDateTime::now_utc() - created_at).whole_seconds().max(0); + match seconds { + 0..60 => format!("{seconds}s"), + 60..3600 => format!("{}m", seconds / 60), + 3600..86_400 => format!("{}h", seconds / 3600), + _ => format!("{}d", seconds / 86_400), + } +} + +/// Renders turns as `agentctl turns` prints them: a heading per turn, then one +/// line per text part or tool call, marked with its author. +pub(crate) fn turn_lines(turns: &[agent::sessions::Turn]) -> Vec { + use agent::sessions::{Part, Role}; + let mut lines = Vec::new(); + for (index, turn) in turns.iter().enumerate() { + if index > 0 { + lines.push(String::new()); + } + lines.push(format!("=== turn {} ===", index + 1)); + for message in &turn.messages { + let who = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + for part in &message.parts { + lines.push(match part { + Part::Text { text } => format!("[{who}] {text}"), + Part::ToolCall { name, failed } => { + let mark = if *failed { " (failed)" } else { "" }; + format!("[{who}] -> {name}{mark}") + } + }); + } + } + } + lines +} + +pub(crate) fn table_lines(headers: &[&str], rows: &[Vec]) -> Vec { + let widths = headers + .iter() + .enumerate() + .map(|(index, header)| { + rows.iter() + .filter_map(|row| row.get(index)) + .map(String::len) + .max() + .unwrap_or_default() + .max(header.len()) + }) + .collect::>(); + let mut lines = vec![row_line( + &headers.iter().map(|value| (*value).to_owned()).collect::>(), + &widths, + )]; + lines.extend(rows.iter().map(|row| row_line(row, &widths))); + lines +} + +fn row_line(values: &[String], widths: &[usize]) -> String { + let mut line = String::new(); + for (index, value) in values.iter().enumerate() { + line.push_str(value); + if index + 1 < values.len() { + let width = widths.get(index).copied().unwrap_or_default(); + for _ in value.len()..width + 2 { + line.push(' '); + } + } + } + line +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn describe_shows_the_failure_class_and_condition_age() { + let mut agent: Agent = + serde_yaml_ng::from_str(include_str!("../../../examples/minimal/agent.yaml")).expect("example manifest"); + agent.status.failure = Some(agent::FailureKind::Transient); + agent.status.conditions = vec![agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "SandboxReconcileFailed".into(), + message: "registry unavailable".into(), + last_transition_time: Some(time::OffsetDateTime::now_utc() - time::Duration::minutes(3)), + }]; + + let mut progress = sandbox::progress::Progress::new(); + let step = sandbox::StepId::generate(); + progress.apply(&sandbox::ProgressEvent::PhaseStarted { + phase: sandbox::SandboxPhase::ImageResolve.phase(), + }); + progress.apply(&sandbox::ProgressEvent::StepStarted { + id: step.clone(), + name: "Pull OCI image".into(), + unit: None, + total: None, + }); + progress.apply(&sandbox::ProgressEvent::StepOutput { + id: step, + stream: sandbox::OutputStream::Stderr, + bytes: b"connection reset\n".to_vec().into(), + }); + progress.fail("registry unavailable"); + agent.status.progress = Some(agent::progress::Provisioning { + pass: agent::resources::Changes::new().revision(), + progress, + }); + + let lines = describe_agent_lines(&agent); + assert!(lines.contains(&"Failure: Transient (retrying in the background)".to_owned())); + for expected in [ + "Provisioning:", + " Failed: registry unavailable", + " Output:", + " connection reset", + ] { + assert!( + lines.iter().any(|line| line == expected), + "missing {expected:?} in {lines:#?}" + ); + } + assert!(lines.iter().any(|line| line.starts_with(" ✗ Resolve Sandbox Image ("))); + assert!(lines.iter().any(|line| line.contains(" AGE "))); + assert!( + lines + .iter() + .any(|line| line.contains("SandboxReconcileFailed") && line.contains(" 3m ")) + ); + } + + #[test] + fn session_state_output_does_not_depend_on_debug_names() { + assert_eq!(session_state(agent::sessions::State::Starting), "Starting"); + assert_eq!(session_state(agent::sessions::State::Working), "Working"); + assert_eq!( + session_state(agent::sessions::State::WaitingForInput), + "WaitingForInput" + ); + assert_eq!(session_state(agent::sessions::State::Idle), "Idle"); + assert_eq!(session_state(agent::sessions::State::Archiving), "Archiving"); + assert_eq!(session_state(agent::sessions::State::Archived), "Archived"); + assert_eq!(session_state(agent::sessions::State::Failed), "Failed"); + } +} diff --git a/agentctl/src/bin/agentctl/launch.rs b/agentctl/src/bin/agentctl/launch.rs new file mode 100644 index 0000000..4baba3a --- /dev/null +++ b/agentctl/src/bin/agentctl/launch.rs @@ -0,0 +1,343 @@ +//! Hands addresses and editor sessions to applications on the machine running `agentctl`. +//! +//! Everything here is best effort: no portable viewer or editor exists. A +//! launcher that fails at once is reported; one that fails later, after it +//! has handed over to the application, cannot be observed. + +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +/// How long a launcher gets to fail before it is taken to have launched. +const LAUNCH_GRACE: Duration = Duration::from_secs(2); + +/// Windows `CREATE_NO_WINDOW`: a console launcher such as `code.cmd` opens no console window. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// An editor that opens an Agent's working directory over SSH. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Editor { + VsCode, + Zed, +} + +impl Editor { + pub(crate) const ALL: [Self; 2] = [Self::VsCode, Self::Zed]; + + pub(crate) const fn label(self) -> &'static str { + match self { + Self::VsCode => "VS Code", + Self::Zed => "Zed", + } + } + + /// Executable names the editor's command-line launcher is installed as, most common first. + /// Some distribution packages install Zed's as `zeditor`. + const fn executables(self) -> &'static [&'static str] { + match self { + Self::VsCode if cfg!(windows) => &["code.cmd"], + Self::VsCode => &["code"], + Self::Zed if cfg!(windows) => &["zed.exe"], + Self::Zed => &["zed", "zeditor"], + } + } + + /// Finds the editor's launcher on `path`, a `PATH`-style variable. + pub(crate) fn locate(self, path: Option<&OsStr>) -> Option { + let directories = path + .map(std::env::split_paths) + .into_iter() + .flatten() + .collect::>(); + self.executables().iter().find_map(|name| { + directories + .iter() + .filter(|directory| directory.is_absolute()) + .map(|directory| directory.join(name)) + .find(|candidate| candidate.is_file()) + }) + } + + /// How to open `directory` in the Agent reached as the OpenSSH `alias`, given the launcher + /// found on `PATH`. VS Code falls back to its URL handler; Zed has none to fall back to. + pub(crate) fn launch(self, launcher: Option<&Path>, alias: &str, directory: &str) -> Option { + match (self, launcher) { + (Self::VsCode, Some(program)) => Some(Launch::Command { + program: program.to_path_buf(), + arguments: vec!["--remote".into(), format!("ssh-remote+{alias}"), directory.to_owned()], + }), + (Self::VsCode, None) => Some(Launch::Url(format!( + "vscode://vscode-remote/ssh-remote+{alias}{directory}" + ))), + (Self::Zed, Some(program)) => Some(Launch::Command { + program: program.to_path_buf(), + arguments: vec![format!("ssh://{alias}{directory}")], + }), + (Self::Zed, None) => None, + } + } + + /// Why the editor cannot be launched without its command-line launcher, if it needs one. + pub(crate) fn missing_launcher(self) -> Option { + match self { + Self::VsCode => None, + Self::Zed => Some(format!("not found on PATH ({})", self.executables().join(", "))), + } + } +} + +/// One way to hand something to a local application. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Launch { + /// Runs a program with arguments, detached from the terminal. + Command { program: PathBuf, arguments: Vec }, + /// Opens an address with whichever application handles its scheme. + Url(String), +} + +impl Launch { + /// Starts the launch, detached from the caller's terminal, and waits + /// briefly for it to fail. + /// + /// # Errors + /// + /// Returns why the program could not be started or failed at once, such as + /// an opener finding no application for the address. + pub(crate) async fn start(&self) -> Result<(), String> { + match self { + Self::Command { program, arguments } => run_detached(program.as_os_str(), arguments, true).await, + Self::Url(url) => open_url(url).await, + } + } +} + +/// Hands `url` to the program that opens addresses on this operating system. +/// +/// # Errors +/// +/// Returns why the opener could not be started or failed at once. +pub(crate) async fn open_url(url: &str) -> Result<(), String> { + // Explorer exits with 1 even when it opened the address, so there only a + // failure to start it tells. + run_detached(OsStr::new(opener()), &[url.to_owned()], !cfg!(windows)).await +} + +/// The program that opens addresses on this operating system. +pub(crate) const fn opener() -> &'static str { + if cfg!(target_os = "macos") { + "open" + } else if cfg!(target_os = "windows") { + "explorer" + } else { + "xdg-open" + } +} + +/// Runs a launcher outside the caller's terminal: its output is discarded, +/// and it gets a process group of its own, so closing the terminal does not +/// close the application. One still running after [`LAUNCH_GRACE`] is taken +/// to have launched and is left running; Tokio reaps it when it exits. With +/// `exit_tells` unset, an exit status is not taken as a failure. +async fn run_detached(program: &OsStr, arguments: &[String], exit_tells: bool) -> Result<(), String> { + let name = program.to_string_lossy(); + let mut command = tokio::process::Command::new(program); + command + .args(arguments) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + command.process_group(0); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + let mut child = command + .spawn() + .map_err(|error| format!("could not run {name}: {error}"))?; + match tokio::time::timeout(LAUNCH_GRACE, child.wait()).await { + Ok(Ok(status)) if exit_tells && !status.success() => Err(format!("{name} failed ({status})")), + Ok(Err(error)) => Err(format!("could not wait for {name}: {error}")), + Ok(Ok(_)) | Err(_) => Ok(()), + } +} + +/// The address that opens a forward listening at `local` to `guest_port`: a +/// VNC client for the desktop's RFB port, a browser for anything else. +pub(crate) fn forward_url(local: impl std::fmt::Display, guest_port: u16) -> String { + if guest_port == agent::vnc::GUEST_PORT { + format!("vnc://{local}") + } else { + format!("http://{local}/") + } +} + +/// Names a forward to one of the desktop's ports, however it was made. +pub(crate) const fn forward_label(guest_port: u16) -> Option<&'static str> { + match guest_port { + agent::vnc::WEB_GUEST_PORT => Some("desktop"), + agent::vnc::GUEST_PORT => Some("vnc"), + _ => None, + } +} + +/// Why an application started here would not appear in front of the person, +/// when it would not. `AGENTCTL_OPEN=launch` or `copy` overrides the guess. +/// +/// On Linux a window needs a display; that also covers `ssh -X` and remote +/// shells that are not SSH. macOS and Windows have no display variable, and +/// open windows on the machine's own screen, so there a terminal reached over +/// SSH is taken to be elsewhere. +pub(crate) fn launch_blocked(variable: impl Fn(&str) -> Option) -> Option { + let set = |name: &str| variable(name).is_some_and(|value| !value.is_empty()); + match variable("AGENTCTL_OPEN").as_deref().and_then(OsStr::to_str) { + Some("launch") => return None, + Some("copy") => return Some("AGENTCTL_OPEN=copy asks to copy instead of opening".into()), + _ => {} + } + if cfg!(any(target_os = "macos", windows)) { + (set("SSH_CONNECTION") || set("SSH_TTY")) + .then(|| "this terminal is reached over SSH, so it would open on that machine".into()) + } else { + (!set("DISPLAY") && !set("WAYLAND_DISPLAY")).then(|| "this terminal has no display to open windows on".into()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + #[test] + fn vs_code_uses_its_launcher_or_else_its_url_handler() { + let launcher = Path::new("/opt/bin/code"); + assert_eq!( + Editor::VsCode.launch(Some(launcher), "agentctl-worker", "/srv/work"), + Some(Launch::Command { + program: launcher.to_path_buf(), + arguments: vec![ + "--remote".into(), + "ssh-remote+agentctl-worker".into(), + "/srv/work".into() + ], + }) + ); + assert_eq!( + Editor::VsCode.launch(None, "agentctl-worker", "/srv/work"), + Some(Launch::Url( + "vscode://vscode-remote/ssh-remote+agentctl-worker/srv/work".into() + )) + ); + assert_eq!(Editor::VsCode.missing_launcher(), None); + } + + #[test] + fn zed_needs_its_launcher() { + let launcher = Path::new("/opt/bin/zeditor"); + assert_eq!( + Editor::Zed.launch(Some(launcher), "agentctl-worker", "/srv/work"), + Some(Launch::Command { + program: launcher.to_path_buf(), + arguments: vec!["ssh://agentctl-worker/srv/work".into()], + }) + ); + assert_eq!(Editor::Zed.launch(None, "agentctl-worker", "/srv/work"), None); + let names = if cfg!(windows) { "zed.exe" } else { "zed, zeditor" }; + assert_eq!( + Editor::Zed.missing_launcher(), + Some(format!("not found on PATH ({names})")) + ); + } + + #[cfg(unix)] + #[test] + fn launchers_are_found_under_any_of_their_names_in_path_order() { + let first = tempfile::tempdir().expect("directory"); + let second = tempfile::tempdir().expect("directory"); + std::fs::write(second.path().join("zeditor"), "").expect("zeditor"); + let path = std::env::join_paths([first.path(), Path::new("relative"), second.path()]).expect("PATH"); + + assert_eq!(Editor::Zed.locate(Some(&path)), Some(second.path().join("zeditor"))); + std::fs::write(first.path().join("zed"), "").expect("zed"); + assert_eq!(Editor::Zed.locate(Some(&path)), Some(first.path().join("zed"))); + assert_eq!(Editor::VsCode.locate(Some(&path)), None); + assert_eq!(Editor::VsCode.locate(None), None); + } + + fn only(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |variable: &str| { + pairs + .iter() + .find(|(name, _)| *name == variable) + .map(|(_, value)| OsString::from(value)) + } + } + + #[test] + fn forwards_open_in_the_application_for_their_guest_port() { + assert_eq!(forward_url("127.0.0.1:53817", 5900), "vnc://127.0.0.1:53817"); + assert_eq!(forward_url("127.0.0.1:53817", 6080), "http://127.0.0.1:53817/"); + } + + #[test] + fn forwards_to_the_desktop_are_named_by_their_port() { + assert_eq!(forward_label(6080), Some("desktop")); + assert_eq!(forward_label(5900), Some("vnc")); + assert_eq!(forward_label(3000), None); + } + + #[test] + fn the_override_decides_whatever_the_terminal() { + assert_eq!(launch_blocked(only(&[("AGENTCTL_OPEN", "launch")])), None); + assert!( + launch_blocked(only(&[("AGENTCTL_OPEN", "copy"), ("DISPLAY", ":0")])) + .is_some_and(|reason| reason.contains("AGENTCTL_OPEN=copy")) + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn on_linux_a_window_needs_a_display_whether_or_not_ssh_is_involved() { + assert_eq!(launch_blocked(only(&[("DISPLAY", ":0")])), None); + assert_eq!(launch_blocked(only(&[("WAYLAND_DISPLAY", "wayland-0")])), None); + assert_eq!( + launch_blocked(only(&[("DISPLAY", "localhost:10.0"), ("SSH_TTY", "/dev/pts/1")])), + None, + "ssh -X shows windows on the person's own screen" + ); + assert!(launch_blocked(only(&[("SSH_TTY", "/dev/pts/1")])).is_some()); + assert!(launch_blocked(only(&[("DISPLAY", "")])).is_some()); + } + + #[cfg(any(target_os = "macos", windows))] + #[test] + fn elsewhere_a_terminal_reached_over_ssh_is_somewhere_else() { + assert!(launch_blocked(only(&[("SSH_CONNECTION", "10.0.0.1 5000 10.0.0.2 22")])).is_some()); + assert_eq!(launch_blocked(only(&[])), None); + } + + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn a_launcher_that_fails_at_once_is_reported_and_one_that_runs_is_launched() { + assert!(run_detached(OsStr::new("false"), &[], true).await.is_err()); + assert_eq!( + run_detached(OsStr::new("false"), &[], false).await, + Ok(()), + "an opener whose exit status says nothing" + ); + assert_eq!(run_detached(OsStr::new("true"), &[], true).await, Ok(())); + assert_eq!( + run_detached(OsStr::new("sleep"), &["5".into()], true).await, + Ok(()), + "still running after the grace period" + ); + assert!( + run_detached(OsStr::new("agentctl-no-such-launcher"), &[], true) + .await + .is_err_and(|error| error.starts_with("could not run")) + ); + } +} diff --git a/agentctl/src/bin/agentctl/main.rs b/agentctl/src/bin/agentctl/main.rs new file mode 100644 index 0000000..1eb1536 --- /dev/null +++ b/agentctl/src/bin/agentctl/main.rs @@ -0,0 +1,2298 @@ +use std::{ + io::IsTerminal as _, + path::{Path, PathBuf}, + process::{Child, Command as ProcessCommand, ExitCode, Stdio}, + time::Duration, +}; + +use agent::{ + Agent, AgentVariantName, Error, RunState, + control_api::Client, + control_plane::ApplyRequest, + control_plane::WaitPolicy, + local::home::ControlPlaneHome, + manifest, + sandbox::forward, + sessions::{Session, SessionName, SessionRequest}, +}; +use clap::{Parser, Subcommand, ValueEnum}; + +mod format; +mod launch; +mod progress; +mod self_update; +mod tui; + +use format::{condition_status, format_age, format_harnesses, session_state}; +use futures_util::StreamExt as _; +use sandbox::{execution::ExecutionEvent, terminal::TerminalAttachOutcome}; +use tokio::io::AsyncWriteExt as _; +use tokio::runtime::LocalRuntime; + +#[derive(Parser)] +#[command(name = "agentctl", about = "Manage the per-user Agent control plane", version = agent::build_version())] +struct Arguments { + /// Agent control-plane home. + #[arg(long, global = true)] + home: Option, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Manage the Agent CLI installation. + Self_ { + #[command(subcommand)] + command: self_update::SelfCommand, + }, + /// Manage Claude Code harness authentication. + Claude { + #[command(subcommand)] + command: ClaudeCommand, + }, + /// Manage Codex CLI harness authentication. + Codex { + #[command(subcommand)] + command: CodexCommand, + }, + /// Create a Session and wait until its harness is ready, without attaching. + Create { + #[command(flatten)] + target: SessionTarget, + #[command(flatten)] + selection: SessionSelection, + /// Maximum wait, written as seconds, minutes, or hours. + #[arg(long, default_value = "10m", value_parser = parse_duration)] + timeout: Duration, + /// First prompt, handed to the harness at launch. + #[command(flatten)] + input: PromptInput, + }, + /// Deliver a prompt to a running Session's harness. + Prompt { + #[command(flatten)] + target: SessionTarget, + #[command(flatten)] + input: PromptInput, + #[command(flatten)] + completion: CompletionOptions, + }, + /// Read a Session's conversation as turns. + Turns { + #[command(flatten)] + target: SessionTarget, + /// Print only the last N turns. + #[arg(long)] + last: Option, + }, + /// Create or update an Agent from a manifest. + Apply { + /// Agent manifest path; defaults to ./agent.yaml. + #[arg(short = 'f', long = "filename", conflicts_with = "variant")] + filename: Option, + /// Variant of the Agent in the current directory. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "filename")] + variant: Option, + /// Override metadata.name so one manifest can create multiple Agents. + #[arg(long)] + name: Option, + /// File supplying declared manifest environment and secret values; defaults to `.env` + /// beside the manifest. Keep files containing secrets outside bind-mounted directories. + #[arg(long)] + env_file: Option, + /// Stay attached after applying and show provisioning progress until the Agent is Ready. + #[arg(long)] + wait: bool, + /// Maximum wait with `--wait`, written as seconds, minutes, or hours (for example `10m`). + #[arg(long, default_value = "10m", value_parser = parse_duration, requires = "wait")] + timeout: Duration, + }, + /// Display one or more resources. + Get { + /// Resource kind, optionally combined with a name (for example `agent/worker`). + resource: String, + /// Optional resource name when it is not part of `resource`. + name: Option, + /// Owning Agent for Session resources; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + /// List Sessions across every Agent instead of resolving one owner. + #[arg(short = 'A', long, conflicts_with_all = ["agent", "variant"])] + all_agents: bool, + /// Include archived Sessions in a Session listing. + #[arg(long)] + archived: bool, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Show detailed state and conditions for one resource. + Describe { + /// Agent resource, optionally combined with its name (for example `agent/worker`). + resource: String, + /// Optional Agent name when it is not part of `resource`. + name: Option, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Request deletion of a resource. + Delete { + /// Resource kind, optionally combined with a name (for example `agent/worker`). + resource: String, + /// Optional resource name when it is not part of `resource`. + name: Option, + /// Owning Agent for Session resources; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + }, + /// Stop an Agent's Sandbox VM, keeping its disk, so attaching to a Session after a start resumes it. + /// + /// Running harnesses are stopped with the VM. Applying the manifest again keeps the Agent stopped. + Stop { + #[command(flatten)] + target: RunStateTarget, + }, + /// Start a stopped Agent's Sandbox VM on its kept disk and wait until the Agent is Ready. + Start { + #[command(flatten)] + target: RunStateTarget, + }, + /// Archive a Session: stop its harness and hide it from listings, keeping its name and conversation. + Archive { + #[command(flatten)] + target: SessionTarget, + }, + /// Unarchive a Session; the next attach resumes its conversation. + Unarchive { + #[command(flatten)] + target: SessionTarget, + }, + /// Create or attach to a named Session in an Agent sandbox. + Attach { + /// Session resource, optionally combined with its name (for example `session/s1`). + resource: String, + /// Optional Session name when it is not part of `resource`. + name: Option, + /// Owning Agent; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + #[command(flatten)] + selection: SessionSelection, + }, + /// Execute a command in an Agent sandbox. + Exec { + /// Pass stdin to an allocated terminal. + #[arg(short = 'i', long, requires = "tty")] + stdin: bool, + /// Allocate a terminal; currently used together with --stdin. + #[arg(short = 't', long, requires = "stdin")] + tty: bool, + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Command and arguments to execute after `--`. + #[arg(last = true, required = true, num_args = 1..)] + command: Vec, + }, + /// Forward local ports to a running Agent sandbox until interrupted. + PortForward { + /// Agent name, as an alternative to a leading Agent argument. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + /// Optional leading Agent resource or name, followed by port mappings + /// written as GUEST, LOCAL:GUEST, or ADDRESS:LOCAL:GUEST. An empty + /// local port (`:GUEST`) selects an ephemeral local port. The Agent is + /// inferred from the current directory when no leading Agent is given. + #[arg(required = true, num_args = 1..)] + arguments: Vec, + }, + /// Open an OpenSSH session to an Agent through `agentctl ssh-proxy`. + Ssh { + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Remote command and arguments after `--`; an interactive shell when omitted. + #[arg(last = true)] + command: Vec, + }, + /// Relay one connection to an Agent's SSH server over standard input and output. + /// + /// The generated OpenSSH client configuration runs this as its `ProxyCommand`. + SshProxy { + /// Agent resource or name. + resource: String, + }, + /// Manage the OpenSSH client configuration for Agents. + SshConfig { + #[command(subcommand)] + command: SshConfigCommand, + }, + /// Describe how to reach an Agent over SSH. + SshInfo { + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Forward an Agent's desktop to a local VNC port until interrupted. + Vnc { + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Local port to listen on. Defaults to a free port, which is printed. + #[arg(long)] + port: Option, + /// Forward the browser-based viewer instead of the raw RFB port, so no VNC client is needed. + #[arg(long)] + web: bool, + /// Hand the address to the local browser or VNC handler instead of only printing it. + #[arg(long)] + open: bool, + }, + /// Relay one connection to an Agent's desktop over standard input and output. + VncProxy { + /// Agent resource or name. + resource: String, + }, + /// Describe how to reach an Agent's desktop over VNC. + VncInfo { + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Open the interactive terminal UI. + Tui, + /// Wait for a resource condition. + Wait { + /// Condition expression. Only `condition=Ready` is currently supported. + #[arg(long = "for", default_value = "condition=Ready")] + condition: String, + /// Maximum wait, written as seconds, minutes, or hours (for example `30s` or `10m`). + #[arg(long, default_value = "10m", value_parser = parse_duration)] + timeout: Duration, + /// Agent resource, optionally combined with its name (for example `agent/worker`). + resource: String, + /// Optional Agent name when it is not part of `resource`. + name: Option, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Resource { + Agent, + Session, +} + +/// The Agent `stop` or `start` acts on: `agent/NAME` or `agent NAME`, and how long to wait. +#[derive(clap::Args)] +struct RunStateTarget { + /// Agent resource, optionally combined with its name (for example `agent/worker`). + resource: String, + /// Optional Agent name when it is not part of `resource`. + name: Option, + /// Maximum wait, written as seconds, minutes, or hours (for example `2m`). + #[arg(long, default_value = "10m", value_parser = parse_duration)] + timeout: Duration, +} + +/// The Session a verb acts on: `session/NAME` or `session NAME`, plus its owning Agent. +#[derive(clap::Args)] +struct SessionTarget { + /// Session resource, optionally combined with its name (for example `session/s1`). + resource: String, + /// Optional Session name when it is not part of `resource`. + name: Option, + /// Owning Agent; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, +} + +/// Selections fixed when a command creates a Session. An existing Session keeps +/// its recorded values; naming different ones is an error. +#[derive(Default, clap::Args)] +struct SessionSelection { + /// Harness installation to bind when creating the Session. + #[arg(long, value_parser = parse_harness)] + harness: Option, + /// Model the harness launches with, in the harness's own spelling (for example + /// `fable` for Claude Code). Defaults to the installation's manifest `model`, + /// else the harness default. + #[arg(long, value_parser = parse_model)] + model: Option, + /// Effort level the harness launches with, in the harness's own spelling (for + /// example `high`). Defaults to the installation's manifest `effort`, else the + /// harness default. + #[arg(long, value_parser = parse_effort)] + effort: Option, +} + +impl SessionSelection { + fn request(self, initial_prompt: Option) -> SessionRequest { + SessionRequest { + harness: self.harness, + model_selection: agent::ModelSelection { + model: self.model, + effort: self.effort, + }, + initial_prompt, + } + } +} + +/// Whether a prompt waits for the next turn completion. +#[derive(clap::Args)] +struct CompletionOptions { + /// Wait for a turn completion and identical waiting activity in two polls + /// 250 ms apart, following newly observed work. Inspect output with `turns`. + #[arg(long)] + wait: bool, + /// Completion wait after submission, excluding setup and delivery; seconds, minutes, or hours. + #[arg(long, default_value = "10m", value_parser = parse_duration, requires = "wait")] + timeout: Duration, +} + +/// Prompt text from --prompt, --file, or piped standard input. +#[derive(clap::Args)] +struct PromptInput { + /// Prompt text. Read from a file with --file, or from standard input when + /// neither is given and stdin is piped. + #[arg(long, conflicts_with = "file", allow_hyphen_values = true)] + prompt: Option, + /// Read the prompt from a file instead of --prompt. + #[arg(short = 'f', long, conflicts_with = "prompt")] + file: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum OutputFormat { + Table, + Json, +} + +#[derive(Debug, thiserror::Error)] +enum CommandError { + #[error(transparent)] + Agent(#[from] Error), + #[error("{0}")] + Message(String), +} + +type CommandResult = Result; + +#[derive(Subcommand)] +enum ClaudeCommand { + /// Mint a long-lived Claude token on the host and store it for agents. + Login { + /// Read an existing credential from standard input instead of signing in. Inside an Agent + /// this accepts the mediated placeholder the Session holds as `AGENT_CLAUDE_ACCESS_TOKEN`, + /// so a nested `agentd` chains through the outer mediation without ever holding a real + /// credential. + #[arg(long)] + from_stdin: bool, + }, +} + +#[derive(Subcommand)] +enum SshConfigCommand { + /// Include the generated Agent configuration from `~/.ssh/config`, once, at the top. + Install, +} + +#[derive(Subcommand)] +enum CodexCommand { + /// Sign in with `ChatGPT` and store an Agent-only grant. + Login { + /// Read the harness's credential file from standard input instead of signing in. Inside + /// an Agent this accepts the file the harness already has, whose placeholders let a nested + /// `agentd` chain through the outer mediation without ever holding a real credential. + #[arg(long)] + from_stdin: bool, + }, +} + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + // The daemon rejected the desired state; the message is the whole story. + Err(CommandError::Agent(Error::Rpc(error))) if error.is_invalid_params() => { + eprintln!("agentctl: {}", error.message); + ExitCode::FAILURE + } + Err(error) => { + eprintln!("agentctl: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> CommandResult { + let arguments = Arguments::parse(); + let home = ControlPlaneHome::resolve(arguments.home.as_deref())?; + let client = Client::for_path(home.socket_path()); + LocalRuntime::new().map_err(Error::from)?.block_on(async move { + if !matches!(arguments.command, Command::Self_ { .. }) { + self_update::resume_pending_before_command(&home)?; + } + if !matches!( + arguments.command, + Command::Create { .. } | Command::Prompt { .. } | Command::Self_ { .. } | Command::SshConfig { .. } + ) { + ensure_daemon(&home, &client).await?; + } + execute(arguments.command, &home, &client).await + }) +} + +#[allow( + clippy::too_many_lines, + reason = "keep command dispatch together; behavior lives in the handlers" +)] +async fn execute(command: Command, home: &ControlPlaneHome, client: &Client) -> CommandResult { + match command { + Command::Self_ { command } => self_update::execute(command, home).await?, + Command::Claude { + command: ClaudeCommand::Login { from_stdin }, + } => { + let token = if from_stdin { + read_token_from_stdin()? + } else { + agent::harness::acquire_host_credential(agent::Harness::ClaudeCode, home.path())? + }; + let imported = client + .auth_login(agent::Harness::ClaudeCode, token.to_string(), from_stdin) + .await?; + println!("{} authentication stored", imported.provider); + } + Command::Codex { + command: CodexCommand::Login { from_stdin }, + } => { + let credential = if from_stdin { + read_stdin_to_end()? + } else { + agent::harness::acquire_host_credential(agent::Harness::Codex, home.path())? + }; + let imported = client + .auth_login(agent::Harness::Codex, credential.to_string(), from_stdin) + .await?; + println!("{} authentication stored", imported.provider); + } + Command::Apply { + filename, + variant, + name, + env_file, + wait, + timeout, + } => { + let filename = apply_manifest_path(filename, variant)?; + let mut request = read_apply_request(filename, env_file)?; + if let Some(name) = name { + request.agent.metadata.name = name; + } + let applied = client.apply(request).await?; + let name = applied.metadata.name; + println!("agent/{name} applied"); + if wait { + let converged = wait_until_converged(client, &name, timeout).await?; + let outcome = if converged.spec.is_stopped() { + "stopped" + } else { + "ready" + }; + println!("agent/{name} {outcome}"); + } + } + Command::Get { + resource, + name, + agent, + variant, + all_agents, + archived, + output, + } => get_resources(client, &resource, name, agent, variant, all_agents, archived, output).await?, + Command::Describe { resource, name, output } => describe(client, &resource, name, output).await?, + Command::Delete { + resource, + name, + agent, + variant, + } => { + let (resource, name) = resource_reference(&resource, name)?; + if resource == Resource::Agent { + reject_session_scope(agent.as_deref(), variant.as_ref(), false)?; + let name = require_name(name, "Agent")?; + client.delete(&name).await?; + println!("agent/{name} deleted"); + } else { + let name = SessionName::new(require_name(name, "Session")?)?; + let agent = resolve_agent_name(client, agent, variant).await?; + client.delete_session(&agent, name.clone()).await?; + println!("session/{agent}/{name} deleted"); + } + } + Command::Stop { target } => set_run_state(client, target, RunState::Stopped).await?, + Command::Start { target } => set_run_state(client, target, RunState::Running).await?, + Command::Archive { target } => set_archived(client, target, true).await?, + Command::Unarchive { target } => set_archived(client, target, false).await?, + Command::Attach { + resource, + name, + agent, + variant, + selection, + } => attach(home, client, &resource, name, agent, variant, selection).await?, + Command::Exec { + stdin, + tty, + resource, + agent, + variant, + command, + } => return exec_command(home, client, resource, agent, variant, &command, stdin, tty).await, + Command::PortForward { + agent, + variant, + arguments, + } => { + return port_forward(home, client, agent, variant, &arguments).await; + } + Command::Ssh { + agent, + variant, + resource, + command, + } => return ssh(client, resource, agent, variant, &command).await, + Command::SshProxy { resource } => return ssh_proxy(home, client, resource).await, + Command::SshConfig { + command: SshConfigCommand::Install, + } => install_ssh_config(home)?, + Command::SshInfo { + resource, + agent, + variant, + output, + } => ssh_info(client, resource, agent, variant, output).await?, + Command::Vnc { + agent, + variant, + resource, + port, + web, + open, + } => { + let options = VncOptions { port, web, open }; + return vnc(home, client, resource, agent, variant, options).await; + } + Command::VncProxy { resource } => return vnc_proxy(home, client, resource).await, + Command::VncInfo { + resource, + agent, + variant, + output, + } => vnc_info(client, resource, agent, variant, output).await?, + Command::Create { + target, + selection, + input, + timeout, + } => create_session(home, client, target, selection, input, timeout).await?, + Command::Prompt { + target, + input, + completion, + } => prompt_session(home, client, target, input, completion).await?, + Command::Turns { target, last } => turns(client, target, last).await?, + Command::Tui => return tui::run(home, client).await, + Command::Wait { + condition, + timeout, + resource, + name, + } => wait(client, &condition, timeout, &resource, name).await?, + } + Ok(ExitCode::SUCCESS) +} + +#[allow(clippy::too_many_arguments, reason = "mirrors the get command's flags")] +async fn get_resources( + client: &Client, + resource: &str, + name: Option, + agent: Option, + variant: Option, + all_agents: bool, + archived: bool, + output: OutputFormat, +) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + match resource { + Resource::Agent => { + reject_session_scope(agent.as_deref(), variant.as_ref(), all_agents)?; + let agents = if let Some(name) = name { + vec![client.get(&name).await?] + } else { + client.list_agents().await? + }; + match output { + OutputFormat::Json => print_json(&agents)?, + OutputFormat::Table => print_agents(&agents), + } + } + Resource::Session => { + let sessions = if name.is_some() { + if all_agents { + return Err(Error::Invalid( + "a named Session requires --agent or current-directory inference".into(), + ) + .into()); + } + let agent = resolve_agent_name(client, agent, variant).await?; + vec![ + client + .get_session(&agent, SessionName::new(require_name(name, "Session")?)?) + .await?, + ] + } else { + let sessions = if all_agents { + client.list_sessions(None).await? + } else { + let agent = resolve_agent_name(client, agent, variant).await?; + client.list_sessions(Some(&agent)).await? + }; + sessions + .into_iter() + .filter(|session| archived || !session.is_archived()) + .collect() + }; + match output { + OutputFormat::Json => print_json(&sessions)?, + OutputFormat::Table => print_sessions(&sessions, all_agents), + } + } + } + Ok(()) +} + +fn print_json(value: &T) -> CommandResult<()> { + println!( + "{}", + serde_json::to_string_pretty(value).map_err(|error| Error::Invalid(error.to_string()))? + ); + Ok(()) +} + +async fn attach( + home: &ControlPlaneHome, + client: &Client, + resource: &str, + name: Option, + agent: Option, + variant: Option, + selection: SessionSelection, +) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Session { + return Err(Error::Invalid("attach requires a Session resource".into()).into()); + } + let session = SessionName::new(require_name(name, "Session")?)?; + let agent = resolve_agent_name(client, agent, variant).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_session(&agent, session, selection.request(None), WaitPolicy::UntilConverged), + ) + .await?; + agent::sessions::attach(home.path(), &target).await?; + Ok(()) +} + +#[allow( + clippy::too_many_arguments, + reason = "command flags remain explicit at the execution boundary" +)] +async fn exec_command( + home: &ControlPlaneHome, + client: &Client, + resource: Option, + agent: Option, + variant: Option, + command: &[String], + stdin: bool, + tty: bool, +) -> CommandResult { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + if tty && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal()) { + return Err(Error::Invalid("-it requires an interactive local terminal".into()).into()); + } + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + let spec = agent::sandbox::platform::execution_spec(&target.operating_system, command, tty)?; + let status = if stdin && tty { + match agent::sandbox::attach_terminal( + home.path(), + &target.sandbox, + ::sandbox::terminal::AttachTerminalRequest::new(spec), + ) + .await? + { + TerminalAttachOutcome::Exited(status) => status, + TerminalAttachOutcome::Detached => return Ok(ExitCode::SUCCESS), + _ => return Err(Error::Session("terminal execution returned an unsupported outcome".into()).into()), + } + } else { + let execution = agent::sandbox::start_execution(home.path(), &target, spec).await?; + stream_execution(execution).await? + }; + Ok(exit_code(status.code)) +} + +/// Splits a leading Agent reference from the port mappings. +/// +/// A first argument containing ':' or made only of digits is a port mapping; +/// anything else names the Agent. Agent names cannot contain ':' and port +/// mappings cannot contain letters, so the shapes never overlap. +fn split_forward_arguments(arguments: &[String]) -> (Option, &[String]) { + match arguments.split_first() { + Some((first, rest)) if !first.contains(':') && !first.bytes().all(|byte| byte.is_ascii_digit()) => { + (Some(first.clone()), rest) + } + _ => (None, arguments), + } +} + +async fn port_forward( + home: &ControlPlaneHome, + client: &Client, + agent: Option, + variant: Option, + arguments: &[String], +) -> CommandResult { + let (resource, ports) = split_forward_arguments(arguments); + if agent.is_some() && resource.is_some() { + return Err(Error::Invalid("the Agent was supplied both as an argument and with --agent".into()).into()); + } + if variant.is_some() && resource.is_some() { + return Err(Error::Invalid("the Agent was supplied both as an argument and with --variant".into()).into()); + } + if ports.is_empty() { + return Err(Error::Invalid("at least one port mapping is required".into()).into()); + } + let specs = ports + .iter() + .map(|port| forward::ForwardSpec::parse(port)) + .collect::, String>>() + .map_err(CommandError::Message)?; + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + let mut forwards = Vec::new(); + for spec in specs { + let forward = forward::PortForward::start(home.path().to_path_buf(), target.sandbox.clone(), spec).await?; + println!( + "Forwarding from {} -> {} (agent {agent:?})", + forward.local_address(), + forward.spec().guest_port + ); + forwards.push(forward); + } + hold_forwards(&forwards).await +} + +/// Holds forwards open until interrupted, reporting each connection failure +/// once, and fails when every forward has stopped serving. +async fn hold_forwards(forwards: &[forward::PortForward]) -> CommandResult { + let mut reported = vec![None; forwards.len()]; + let mut poll = tokio::time::interval(Duration::from_secs(1)); + loop { + tokio::select! { + result = tokio::signal::ctrl_c() => { + result.map_err(Error::from)?; + return Ok(ExitCode::SUCCESS); + } + _ = poll.tick() => { + for (forward, reported) in forwards.iter().zip(reported.iter_mut()) { + let status = forward.status(); + if status != *reported { + if let Some(message) = &status { + eprintln!("{} -> {}: {message}", forward.local_address(), forward.spec().guest_port); + } + *reported = status; + } + } + if forwards.iter().all(forward::PortForward::finished) { + eprintln!("every port forward has stopped"); + return Ok(ExitCode::FAILURE); + } + } + } + } +} + +/// Opens the local OpenSSH client against the Agent's generated alias. +/// +/// The Agent is converged first, so the server and the key material exist by +/// the time `ssh` runs the `ProxyCommand`. +async fn ssh( + client: &Client, + resource: Option, + agent: Option, + variant: Option, + command: &[String], +) -> CommandResult { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let wait = progress::Wait::start(); + wait.until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + let access = client.ssh_access(&agent).await?; + let mut ssh = ProcessCommand::new(ssh_client_executable()); + ssh.args(ssh_client_arguments(&access)).args(command); + run_ssh_client(ssh) +} + +fn ssh_client_executable() -> String { + format!("ssh{}", std::env::consts::EXE_SUFFIX) +} + +/// Arguments that point the OpenSSH client at an Agent's generated alias. +fn ssh_client_arguments(access: &agent::ssh::AccessInfo) -> [&std::ffi::OsStr; 3] { + ["-F".as_ref(), access.config_file.as_os_str(), access.alias.as_ref()] +} + +#[cfg(unix)] +fn run_ssh_client(mut ssh: ProcessCommand) -> CommandResult { + use std::os::unix::process::CommandExt as _; + + Err(ssh_client_error(&ssh.exec())) +} + +#[cfg(not(unix))] +fn run_ssh_client(mut ssh: ProcessCommand) -> CommandResult { + let status = ssh.status().map_err(|error| ssh_client_error(&error))?; + Ok(status.code().map_or(ExitCode::FAILURE, exit_code)) +} + +fn ssh_client_error(error: &std::io::Error) -> CommandError { + CommandError::Message(ssh_client_failure(error)) +} + +/// Why the OpenSSH client could not be started. +fn ssh_client_failure(error: &std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + "the OpenSSH client `ssh` was not found on PATH; install OpenSSH".into() + } else { + format!("could not run the OpenSSH client: {error}") + } +} + +/// Relays one SSH connection over standard input and output; the `ProxyCommand` entry point. +/// +/// Progress and errors go to standard error, which `ssh` shows to the user; +/// standard output carries only the SSH byte stream. +async fn ssh_proxy(home: &ControlPlaneHome, client: &Client, resource: String) -> CommandResult { + let agent = resolve_execution_agent(client, Some(resource), None, None).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + forward::relay_guest_port( + home.path(), + &target.sandbox, + agent::ssh::GUEST_PORT, + tokio::io::stdin(), + tokio::io::stdout(), + ) + .await?; + // A blocked standard-input read would keep the runtime from shutting down; + // the relay is finished, so leave immediately. + std::process::exit(0) +} + +fn install_ssh_config(home: &ControlPlaneHome) -> CommandResult<()> { + let include = agent::ssh::UserInclude::for_home(home)?; + let (line, user_config) = (&include.line, include.user_config.display()); + match include.install()? { + agent::ssh::IncludeOutcome::Installed => println!("added `{line}` at the top of {user_config}"), + agent::ssh::IncludeOutcome::AlreadyInstalled => println!("{user_config} already contains `{line}`"), + } + Ok(()) +} + +async fn ssh_info( + client: &Client, + resource: Option, + agent: Option, + variant: Option, + output: OutputFormat, +) -> CommandResult<()> { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let access = client.ssh_access(&agent).await?; + match output { + OutputFormat::Json => print_json(&access)?, + OutputFormat::Table => { + for line in format::ssh_access_lines(&access) { + println!("{line}"); + } + } + } + Ok(()) +} + +/// How one `agentctl vnc` invocation should expose the desktop. +struct VncOptions { + /// Local port to listen on; a free port when omitted. + port: Option, + /// Forward the browser-based viewer rather than the raw RFB port. + web: bool, + /// Also hand the address to whichever local application handles its scheme. + open: bool, +} + +/// Forwards the Agent's desktop to a local port and holds it open. +/// +/// The Agent is converged first, so the platform-owned bridge from the guest +/// port to the image's display socket exists before anything dials it. +async fn vnc( + home: &ControlPlaneHome, + client: &Client, + resource: Option, + agent: Option, + variant: Option, + options: VncOptions, +) -> CommandResult { + let VncOptions { port, web, open } = options; + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + // Refuses early, with the remedy, when the Agent declares no VNC access. + client.vnc_access(&agent).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + // Read again now the Agent is Ready: which ports its image offers is something a + // reconciliation pass observes, so before converging the browser viewer's port is unknown + // rather than absent. + let access = client.vnc_access(&agent).await?; + let guest_port = if web { + access.web_guest_port.ok_or_else(|| { + Error::Invalid(format!( + "the image of Agent {agent:?} serves no browser viewer; use `agentctl vnc {agent}` with a VNC client" + )) + })? + } else { + access.guest_port + }; + let spec = forward::ForwardSpec { + address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + // A free port by default: 5900 and 6080 are often taken locally, by a screen-sharing + // server or by another `agentctl vnc`, and the address is printed either way. + local_port: port.unwrap_or(0), + guest_port, + }; + let forward = forward::PortForward::start(home.path().to_path_buf(), target.sandbox.clone(), spec).await?; + let address = forward.local_address(); + // The image decides what its viewer port serves and where the root redirects, so the caller is + // pointed at the root rather than a path this side would have to keep in step with it. + let url = launch::forward_url(address, guest_port); + println!("Desktop of agent {agent:?} is at {url}"); + if web { + println!("Open that address in a browser; nothing needs installing."); + } else { + println!("Open it with any VNC viewer, for example `vncviewer {address}`, or pass --web for a browser."); + } + if open { + open_locally(&url).await; + } + hold_forwards(std::slice::from_ref(&forward)).await +} + +/// Hands the address to whichever local application handles its scheme. +/// +/// Best effort by design: there is no portable VNC viewer, the address is +/// already printed, and a missing handler must not fail the forward. +async fn open_locally(url: &str) { + match launch::open_url(url).await { + Ok(()) => println!("Asked {} to open {url}.", launch::opener()), + Err(error) => eprintln!("{error}"), + } +} + +/// Relays one desktop connection over standard input and output. +/// +/// This is the seam for a viewer that dials through a command rather than a +/// port, and for tooling that wants the RFB stream without a listening socket. +async fn vnc_proxy(home: &ControlPlaneHome, client: &Client, resource: String) -> CommandResult { + let agent = resolve_execution_agent(client, Some(resource), None, None).await?; + let access = client.vnc_access(&agent).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + forward::relay_guest_port( + home.path(), + &target.sandbox, + access.guest_port, + tokio::io::stdin(), + tokio::io::stdout(), + ) + .await?; + // A blocked standard-input read would keep the runtime from shutting down; + // the relay is finished, so leave immediately. + std::process::exit(0) +} + +async fn vnc_info( + client: &Client, + resource: Option, + agent: Option, + variant: Option, + output: OutputFormat, +) -> CommandResult<()> { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let access = client.vnc_access(&agent).await?; + match output { + OutputFormat::Json => print_json(&access)?, + OutputFormat::Table => { + for line in format::vnc_access_lines(&access) { + println!("{line}"); + } + } + } + Ok(()) +} + +/// Resolves a [`SessionTarget`] into the owning Agent and Session name. +async fn session_target(client: &Client, target: SessionTarget) -> CommandResult<(String, SessionName)> { + let (resource, name) = resource_reference(&target.resource, target.name)?; + if resource != Resource::Session { + return Err(Error::Invalid("this command requires a Session resource".into()).into()); + } + let session = SessionName::new(require_name(name, "Session")?)?; + let agent = resolve_agent_name(client, target.agent, target.variant).await?; + Ok((agent, session)) +} + +async fn create_session( + home: &ControlPlaneHome, + client: &Client, + target: SessionTarget, + selection: SessionSelection, + input: PromptInput, + timeout: Duration, +) -> CommandResult<()> { + let resource = target.resource.clone(); + let request = selection.request(read_prompt_arg(input)?); + let deadline = tokio::time::Instant::now() + timeout; + let timed_out = || { + CommandError::Message(format!( + "timed out creating {resource}; Agent resolution or provisioning did not finish; provisioning may continue" + )) + }; + let (agent, session) = tokio::time::timeout_at(deadline, async { + ensure_daemon(home, client).await?; + session_target(client, target).await + }) + .await + .map_err(|_| timed_out())??; + let wait = progress::Wait::start(); + wait.until( + client, + &agent, + tokio::time::timeout_at( + deadline, + client.ensure_session(&agent, session.clone(), request, WaitPolicy::UntilConverged), + ), + ) + .await + .map_err(|_| timed_out())??; + println!("session/{agent}/{session} ready"); + Ok(()) +} + +async fn prompt_session( + home: &ControlPlaneHome, + client: &Client, + target: SessionTarget, + input: PromptInput, + completion: CompletionOptions, +) -> CommandResult<()> { + ensure_daemon(home, client).await?; + let (agent, session) = session_target(client, target).await?; + let prompt = read_prompt_arg(input)?.ok_or_else(|| Error::Invalid("a prompt is required".into()))?; + client + .prompt_session( + &agent, + session.clone(), + prompt, + completion.wait, + completion.wait.then_some(completion.timeout), + ) + .await?; + println!("session/{agent}/{session} prompted"); + Ok(()) +} + +async fn set_archived(client: &Client, target: SessionTarget, archived: bool) -> CommandResult<()> { + let (agent, name) = session_target(client, target).await?; + let session = client.set_session_archived(&agent, name.clone(), archived).await?; + if !archived { + println!("session/{agent}/{name} unarchived"); + } else if session.status.state == agent::sessions::State::Archived { + println!("session/{agent}/{name} archived"); + } else { + println!("session/{agent}/{name} archived; its harness stops once it is idle"); + } + Ok(()) +} + +async fn turns(client: &Client, target: SessionTarget, last: Option) -> CommandResult<()> { + let (agent, session) = session_target(client, target).await?; + print_turns(&client.session_turns(&agent, session, last).await?); + Ok(()) +} + +/// Resolves the prompt from --prompt, --file, or piped standard input. +/// +/// With neither flag and an interactive terminal there is no prompt. +fn read_prompt_arg(input: PromptInput) -> CommandResult> { + if let Some(prompt) = input.prompt { + return Ok(Some(prompt)); + } + if let Some(file) = input.file { + return Ok(Some(std::fs::read_to_string(&file).map_err(Error::from)?)); + } + if !std::io::stdin().is_terminal() { + let mut buffer = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer).map_err(Error::from)?; + if !buffer.is_empty() { + return Ok(Some(buffer)); + } + } + Ok(None) +} + +fn print_turns(turns: &[agent::sessions::Turn]) { + if turns.is_empty() { + eprintln!("No turns yet."); + } + for line in format::turn_lines(turns) { + println!("{line}"); + } +} + +async fn resolve_execution_agent( + client: &Client, + resource: Option, + explicit: Option, + variant: Option, +) -> CommandResult { + if let Some(explicit) = explicit { + return Ok(explicit); + } + let Some(resource) = resource else { + return resolve_agent_name(client, None, variant).await; + }; + if !resource.contains('/') { + return Ok(resource); + } + let (kind, name) = resource_reference(&resource, None)?; + if kind != Resource::Agent { + return Err(Error::Invalid("this command requires an Agent resource".into()).into()); + } + require_name(name, "Agent").map_err(CommandError::from) +} + +async fn stream_execution( + mut execution: ::sandbox::execution::StartedExecution, +) -> Result<::sandbox::execution::ExitStatus, Error> { + let id = execution.id.clone(); + let mut stdout = tokio::io::stdout(); + let mut stderr = tokio::io::stderr(); + while let Some(event) = execution.events.next().await { + match event? { + ExecutionEvent::Started { .. } => {} + ExecutionEvent::Stdout(bytes) => stdout.write_all(&bytes).await?, + ExecutionEvent::Stderr(bytes) => stderr.write_all(&bytes).await?, + ExecutionEvent::Exited(status) => { + stdout.flush().await?; + stderr.flush().await?; + return Ok(status); + } + ExecutionEvent::Failed { message } => { + return Err(::sandbox::Error::ExecutionFailed { id, message }.into()); + } + _ => { + return Err(Error::Sandbox(::sandbox::Error::Backend( + "unsupported Execution event".into(), + ))); + } + } + } + Err(::sandbox::Error::ExecutionStreamEnded { id }.into()) +} + +fn exit_code(code: i32) -> ExitCode { + u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from) +} + +async fn describe(client: &Client, resource: &str, name: Option, output: OutputFormat) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Agent { + return Err(Error::Invalid("describe currently supports only Agent resources".into()).into()); + } + let agent = client.get(&require_name(name, "Agent")?).await?; + match output { + OutputFormat::Json => print_json(&agent)?, + OutputFormat::Table => print_agent_description(&agent), + } + Ok(()) +} + +async fn wait( + client: &Client, + condition: &str, + timeout: Duration, + resource: &str, + name: Option, +) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Agent { + return Err(Error::Invalid("wait currently supports only Agent resources".into()).into()); + } + if condition != "condition=Ready" { + return Err(Error::Invalid("only --for=condition=Ready is supported".into()).into()); + } + let name = require_name(name, "Agent")?; + // A stopped Agent is never Ready, also while its stop is still in progress. + if client.get(&name).await?.spec.is_stopped() + || wait_until_converged(client, &name, timeout).await?.spec.is_stopped() + { + return Err(Error::Stopped(name).into()); + } + println!("agent/{name} condition met"); + Ok(()) +} + +/// Resolves `agent/NAME` or `agent NAME` for a verb that acts only on Agents. +fn agent_reference(resource: &str, name: Option) -> Result { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Agent { + return Err(Error::Invalid("this command requires an Agent resource".into())); + } + require_name(name, "Agent") +} + +fn resource_reference(resource: &str, name: Option) -> Result<(Resource, Option), Error> { + let (kind, embedded_name) = resource.split_once('/').map_or((resource, None), |(kind, name)| { + (kind, (!name.is_empty()).then(|| name.to_owned())) + }); + if resource.matches('/').count() > 1 || (resource.contains('/') && embedded_name.is_none()) { + return Err(Error::Invalid("resource references must use TYPE/NAME".into())); + } + if embedded_name.is_some() && name.is_some() { + return Err(Error::Invalid("resource name was supplied twice".into())); + } + let resource = match kind.to_ascii_lowercase().as_str() { + "agent" | "agents" | "ag" => Resource::Agent, + "session" | "sessions" => Resource::Session, + _ => return Err(Error::Invalid(format!("unknown resource type {kind:?}"))), + }; + Ok((resource, embedded_name.or(name))) +} + +fn require_name(name: Option, resource: &str) -> Result { + name.ok_or_else(|| Error::Invalid(format!("{resource} name is required"))) +} + +fn reject_session_scope( + agent: Option<&str>, + variant: Option<&AgentVariantName>, + all_agents: bool, +) -> Result<(), Error> { + if agent.is_some() || variant.is_some() || all_agents { + Err(Error::Invalid( + "--agent, --variant, and --all-agents apply only to Session resources".into(), + )) + } else { + Ok(()) + } +} + +async fn resolve_agent_name( + client: &Client, + explicit: Option, + variant: Option, +) -> CommandResult { + if let Some(agent) = explicit { + return Ok(agent); + } + let directory = std::env::current_dir().map_err(Error::from)?; + match client.resolve_agent_variant(directory, variant).await { + Ok(agent) => Ok(agent.metadata.name), + Err(error) => Err(inference_error(error)), + } +} + +fn inference_error(error: Error) -> CommandError { + match error { + Error::Rpc(error) if error.is_not_found() => CommandError::Message( + "no Agent was applied from the current directory; specify --agent or --variant".into(), + ), + Error::Rpc(error) => CommandError::Message(error.message), + error => error.into(), + } +} + +/// Follows an Agent's convergence with live progress until it has its desired +/// run state, Ready or stopped, and returns it then; or until a terminal +/// error, the timeout, or Ctrl-C. +async fn wait_until_converged(client: &Client, name: &str, timeout: Duration) -> CommandResult { + let waited = progress::Wait::start() + .until(client, name, tokio::time::timeout(timeout, client.converge(name))) + .await; + let Ok(converged) = waited else { + return Err(CommandError::Message(match client.get(name).await.ok() { + Some(agent) if agent.spec.is_stopped() => { + format!("timed out waiting for Agent {name:?} to stop; agentd keeps stopping it") + } + agent => wait_timeout_message(name, agent.as_ref().and_then(|agent| agent.status.ready_condition())), + })); + }; + converged.map_err(CommandError::from) +} + +/// Stops or starts an Agent and waits until it is stopped or Ready. +async fn set_run_state(client: &Client, target: RunStateTarget, state: RunState) -> CommandResult<()> { + let name = agent_reference(&target.resource, target.name)?; + client.set_run_state(&name, state).await?; + let converged = wait_until_converged(client, &name, target.timeout).await?; + match (state, converged.spec.run_state()) { + (RunState::Stopped, RunState::Stopped) => println!("agent/{name} stopped"), + (RunState::Running, RunState::Running) => println!("agent/{name} started"), + (_, current) => { + return Err(CommandError::Message(format!( + "Agent {name:?} was set to {current:?} again before it finished" + ))); + } + } + Ok(()) +} + +fn wait_timeout_message(name: &str, ready: Option<&agent::Condition>) -> String { + let Some(ready) = ready else { + return format!("timed out waiting for Agent {name:?} to become Ready; no Ready condition was reported"); + }; + format!( + "timed out waiting for Agent {name:?} to become Ready: {}", + ready.summary() + ) +} + +fn parse_duration(value: &str) -> Result { + let (number, multiplier) = match value.as_bytes().last() { + Some(b's') => (&value[..value.len() - 1], 1), + Some(b'm') => (&value[..value.len() - 1], 60), + Some(b'h') => (&value[..value.len() - 1], 60 * 60), + _ => return Err("duration must end in s, m, or h".into()), + }; + let number = number + .parse::() + .map_err(|_| "duration must contain a positive whole number".to_string())?; + if number == 0 { + return Err("duration must be greater than zero".into()); + } + Ok(Duration::from_secs(number.saturating_mul(multiplier))) +} + +fn parse_harness(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn parse_model(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn parse_effort(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn parse_variant_name(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn print_agents(agents: &[Agent]) { + let rows = agents + .iter() + .map(|agent| { + let ready = agent.status.ready_condition(); + let ready_value = ready.map_or("Unknown", |condition| condition_status(condition.status)); + let status = if agent.metadata.deletion_timestamp.is_some() { + "Terminating" + } else { + ready.map_or("Pending", |condition| condition.reason.as_str()) + }; + let harnesses = format_harnesses(&agent.spec); + let provider = agent + .status + .sandbox + .as_ref() + .map_or("-", |assignment| assignment.provider().as_str()); + vec![ + agent.metadata.name.clone(), + ready_value.into(), + status.into(), + harnesses, + provider.into(), + ] + }) + .collect::>(); + print_table(&["NAME", "READY", "STATUS", "HARNESSES", "PROVIDER"], &rows); +} + +fn print_agent_description(agent: &Agent) { + for line in format::describe_agent_lines(agent) { + println!("{line}"); + } +} + +fn print_sessions(sessions: &[Session], show_agent: bool) { + let rows = sessions + .iter() + .map(|session| { + let mut row = Vec::new(); + if show_agent { + row.push(session.agent.clone()); + } + row.extend([ + session.name.as_str().to_owned(), + session.harness.as_str().into(), + session.model_selection.model_str().unwrap_or("-").into(), + session.model_selection.effort_str().unwrap_or("-").into(), + session_state(session.status.state).into(), + format_age(session.created_at), + ]); + row + }) + .collect::>(); + let headers = if show_agent { + vec!["AGENT", "NAME", "HARNESS", "MODEL", "EFFORT", "STATE", "AGE"] + } else { + vec!["NAME", "HARNESS", "MODEL", "EFFORT", "STATE", "AGE"] + }; + print_table(&headers, &rows); +} + +fn print_table(headers: &[&str], rows: &[Vec]) { + if rows.is_empty() { + eprintln!("No resources found."); + return; + } + for line in format::table_lines(headers, rows) { + println!("{line}"); + } +} + +async fn ensure_daemon(home: &ControlPlaneHome, client: &Client) -> Result<(), Error> { + if let Ok(daemon) = client.health().await { + return daemon.require_compatible(); + } + let mut daemon = spawn_daemon(home)?; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + if let Ok(daemon) = client.health().await { + return daemon.require_compatible(); + } + if let Some(status) = daemon.try_wait()? { + return Err(Error::Daemon(format!( + "automatic startup exited with {status}; {}", + daemon_startup_diagnostics(home) + ))); + } + } + Err(Error::Daemon(format!( + "automatic startup did not become ready within 10 seconds; {}", + daemon_startup_diagnostics(home) + ))) +} + +fn daemon_startup_diagnostics(home: &ControlPlaneHome) -> String { + let log = home.daemon_log_path(); + let marker = home.pending_session_relaunch_path(); + if marker.exists() { + format!( + "see {}; pending post-upgrade Session relaunch: {}", + log.display(), + marker.display() + ) + } else { + format!("see {}", log.display()) + } +} + +fn spawn_daemon(home: &ControlPlaneHome) -> Result { + home.prepare()?; + let log = home.open_daemon_log()?; + let executable = daemon_executable(&std::env::current_exe()?); + let mut command = ProcessCommand::new(executable); + command + .arg("--home") + .arg(home.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log); + agent::local::process::configure_detached(&mut command); + agent::local::process::configure_logging(&mut command); + command.spawn().map_err(Error::from) +} + +fn daemon_executable(agentctl: &Path) -> PathBuf { + agentctl.with_file_name(format!("agentd{}", std::env::consts::EXE_SUFFIX)) +} + +fn apply_manifest_path(filename: Option, variant: Option) -> Result { + match (filename, variant) { + (Some(filename), None) => Ok(filename), + (None, Some(variant)) => Ok(PathBuf::from(variant.filename())), + (None, None) => Ok(PathBuf::from(manifest::MANIFEST_FILE)), + (Some(_), Some(_)) => Err(Error::Invalid("--filename and --variant are mutually exclusive".into())), + } +} + +fn read_apply_request(filename: PathBuf, env_file: Option) -> Result { + let filename = absolute(filename)?; + let env_file = env_file.map(absolute).transpose()?; + let agent = manifest::resolve(&filename)?.agent; + let source_directory = filename + .parent() + .ok_or_else(|| Error::Invalid("manifest path has no parent directory".into()))? + .to_path_buf(); + Ok(ApplyRequest { + source_directory, + manifest_path: Some(filename), + env_file, + create_only: false, + agent, + }) +} + +fn read_token_from_stdin() -> Result, Error> { + let mut line = zeroize::Zeroizing::new(String::new()); + std::io::stdin() + .read_line(&mut line) + .map_err(|error| Error::Invalid(format!("could not read the token from standard input: {error}")))?; + let token = zeroize::Zeroizing::new(line.trim().to_owned()); + if token.is_empty() { + return Err(Error::Invalid("no token was provided on standard input".into())); + } + Ok(token) +} + +fn read_stdin_to_end() -> Result, Error> { + use std::io::Read as _; + + let mut text = zeroize::Zeroizing::new(String::new()); + std::io::stdin() + .read_to_string(&mut text) + .map_err(|error| Error::Invalid(format!("could not read the credential from standard input: {error}")))?; + if text.trim().is_empty() { + return Err(Error::Invalid("no credential was provided on standard input".into())); + } + Ok(text) +} + +fn absolute(path: PathBuf) -> Result { + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + struct StalledConnector { + healthy: bool, + } + + struct PreviewOneConnector; + + impl agent::control_api::Connector for PreviewOneConnector { + fn connect(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async { + use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _}; + let (client, server) = tokio::io::duplex(4096); + tokio::task::spawn_local(async move { + let mut server = tokio::io::BufReader::new(server); + let mut request = String::new(); + server.read_line(&mut request).await.expect("request"); + let request: serde_json::Value = serde_json::from_str(&request).expect("RPC"); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"protocolVersion": "v1"} + }); + server + .write_all(format!("{response}\n").as_bytes()) + .await + .expect("response"); + }); + Ok(Box::new(client) as Box) + }) + } + } + + #[tokio::test(flavor = "local")] + async fn incompatible_daemon_is_reported_without_starting_another() { + let directory = tempfile::TempDir::new().expect("temporary home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + let client = Client::new(std::rc::Rc::new(PreviewOneConnector)); + let error = ensure_daemon(&home, &client) + .await + .expect_err("preview daemon is incompatible"); + assert!(error.to_string().contains("protocol Some(\"v1\")")); + assert!(!home.daemon_log_path().exists(), "no second daemon was spawned"); + } + + #[test] + fn startup_diagnostics_identify_a_pending_session_relaunch() { + let directory = tempfile::TempDir::new().expect("temporary home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + std::fs::write(home.pending_session_relaunch_path(), "pending").expect("marker"); + + let diagnostic = daemon_startup_diagnostics(&home); + + assert!(diagnostic.contains(&home.daemon_log_path().display().to_string())); + assert!(diagnostic.contains(&home.pending_session_relaunch_path().display().to_string())); + } + + impl agent::control_api::Connector for StalledConnector { + fn connect(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + use tokio::io::AsyncBufReadExt as _; + if !self.healthy { + return std::future::pending().await; + } + let (client, server) = tokio::io::duplex(4096); + tokio::task::spawn_local(async move { + let mut server = tokio::io::BufReader::new(server); + let mut line = String::new(); + server.read_line(&mut line).await.expect("request"); + let request: serde_json::Value = serde_json::from_str(&line).expect("RPC"); + if request["method"] == "control.v1.health" { + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "protocolVersion": agent::control_api::PROTOCOL_VERSION, + "buildVersion": agent::build_version() + } + }); + server + .write_all(format!("{response}\n").as_bytes()) + .await + .expect("health response"); + } else { + std::future::pending::<()>().await; + drop(server); + } + }); + Ok(Box::new(client) as Box) + }) + } + } + + #[tokio::test(flavor = "local", start_paused = true)] + async fn create_stops_waiting_at_its_deadline() { + for (owner, healthy) in [(Some("worker"), false), (Some("worker"), true), (None, true)] { + let client = Client::new(std::rc::Rc::new(StalledConnector { healthy })); + let directory = tempfile::TempDir::new().expect("temporary home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + let result = tokio::time::timeout( + Duration::from_secs(2), + create_session( + &home, + &client, + SessionTarget { + resource: "session/s1".into(), + name: None, + agent: owner.map(str::to_owned), + variant: None, + }, + SessionSelection::default(), + PromptInput { + prompt: Some("go".into()), + file: None, + }, + Duration::from_secs(1), + ), + ) + .await + .expect("the command's own deadline must include Agent inference"); + assert!(matches!(result, Err(CommandError::Message(message)) if message.contains("timed out"))); + } + } + + struct DelayedHealthConnector { + remaining: std::rc::Rc>>, + } + + impl agent::control_api::Connector for DelayedHealthConnector { + fn connect(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + use tokio::io::AsyncBufReadExt as _; + let (client, server) = tokio::io::duplex(4096); + let remaining = self.remaining.clone(); + tokio::task::spawn_local(async move { + let mut server = tokio::io::BufReader::new(server); + let mut line = String::new(); + server.read_line(&mut line).await.expect("request"); + let request: serde_json::Value = serde_json::from_str(&line).expect("RPC"); + if request["method"] == "control.v1.health" { + tokio::time::sleep(Duration::from_millis(600)).await; + } else { + assert_eq!(request["method"], "sessions.v1.prompt"); + remaining.set(Some( + serde_json::from_value(request["params"]["timeout"].clone()).expect("timeout"), + )); + } + let result = if request["method"] == "control.v1.health" { + serde_json::json!({ + "protocolVersion": agent::control_api::PROTOCOL_VERSION, + "buildVersion": agent::build_version() + }) + } else { + serde_json::json!({}) + }; + let response = serde_json::json!({"jsonrpc":"2.0", "id":request["id"], "result":result}); + server + .write_all(format!("{response}\n").as_bytes()) + .await + .expect("response"); + }); + Ok(Box::new(client) as Box) + }) + } + } + + #[tokio::test(flavor = "local", start_paused = true)] + async fn prompt_setup_does_not_consume_the_completion_timeout() { + let remaining = std::rc::Rc::new(std::cell::Cell::new(None)); + let client = Client::new(std::rc::Rc::new(DelayedHealthConnector { + remaining: remaining.clone(), + })); + let directory = tempfile::TempDir::new().expect("home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + prompt_session( + &home, + &client, + SessionTarget { + resource: "session/s1".into(), + name: None, + agent: Some("worker".into()), + variant: None, + }, + PromptInput { + prompt: Some("go".into()), + file: None, + }, + CompletionOptions { + wait: true, + timeout: Duration::from_secs(1), + }, + ) + .await + .expect("prompt"); + assert_eq!(remaining.get(), Some(Duration::from_secs(1))); + } + + #[test] + fn create_accepts_a_bounded_wait() { + assert!(Arguments::try_parse_from(["agentctl", "create", "session/s1", "--timeout", "1s"]).is_ok()); + } + + #[test] + fn session_creation_commands_accept_optional_model_and_effort() { + for verb in ["create", "attach"] { + let arguments = Arguments::try_parse_from([ + "agentctl", + verb, + "session/s1", + "--model", + "claude-fable-5", + "--effort", + "xhigh", + ]) + .expect("model and effort parse"); + let (Command::Create { selection, .. } | Command::Attach { selection, .. }) = arguments.command else { + panic!("expected a Session creation command"); + }; + let request = selection.request(None); + assert_eq!(request.model_selection.model_str(), Some("claude-fable-5")); + assert_eq!(request.model_selection.effort_str(), Some("xhigh")); + assert_eq!(request.harness, None); + + let omitted = Arguments::try_parse_from(["agentctl", verb, "session/s1"]).expect("omitted selections"); + let (Command::Create { selection, .. } | Command::Attach { selection, .. }) = omitted.command else { + panic!("expected a Session creation command"); + }; + assert_eq!(selection.request(None), SessionRequest::default()); + + let Err(error) = Arguments::try_parse_from(["agentctl", verb, "session/s1", "--model", ""]) else { + panic!("an empty model is rejected before reaching the daemon"); + }; + assert!(error.to_string().contains("model must be 1-128"), "{error}"); + assert!(Arguments::try_parse_from(["agentctl", verb, "session/s1", "--effort", "very high"]).is_err()); + } + } + + #[test] + fn resource_references_follow_kubectl_shapes_and_aliases() { + assert_eq!( + resource_reference("agents", None).expect("Agent collection"), + (Resource::Agent, None) + ); + assert_eq!( + resource_reference("ag/worker", None).expect("Agent reference"), + (Resource::Agent, Some("worker".into())) + ); + assert_eq!( + resource_reference("session", Some("s1".into())).expect("Session reference"), + (Resource::Session, Some("s1".into())) + ); + assert!(resource_reference("agent/worker", Some("other".into())).is_err()); + assert!(resource_reference("pods", None).is_err()); + } + + #[test] + fn exec_accepts_kubectl_style_interactive_and_inferred_shapes() { + let explicit = Arguments::try_parse_from(["agentctl", "exec", "-it", "agent/worker", "--", "bash", "-l"]) + .expect("interactive exec arguments"); + let Command::Exec { + stdin, + tty, + resource, + agent, + command, + .. + } = explicit.command + else { + panic!("expected exec command"); + }; + assert!(stdin); + assert!(tty); + assert_eq!(resource.as_deref(), Some("agent/worker")); + assert!(agent.is_none()); + assert_eq!(command, ["bash", "-l"]); + + let inferred = Arguments::try_parse_from(["agentctl", "exec", "--", "pwd"]).expect("inferred exec arguments"); + let Command::Exec { resource, command, .. } = inferred.command else { + panic!("expected exec command"); + }; + assert!(resource.is_none()); + assert_eq!(command, ["pwd"]); + } + + #[test] + fn port_forward_accepts_kubectl_shapes_and_inference() { + let explicit = Arguments::try_parse_from(["agentctl", "port-forward", "agent/worker", "9090:80", ":5432"]) + .expect("explicit port-forward arguments"); + let Command::PortForward { agent, arguments, .. } = explicit.command else { + panic!("expected port-forward command"); + }; + assert!(agent.is_none()); + assert_eq!( + split_forward_arguments(&arguments), + (Some("agent/worker".into()), &arguments[1..]) + ); + + let inferred = + Arguments::try_parse_from(["agentctl", "port-forward", "8080"]).expect("inferred port-forward arguments"); + let Command::PortForward { arguments, .. } = inferred.command else { + panic!("expected port-forward command"); + }; + assert_eq!(split_forward_arguments(&arguments), (None, arguments.as_slice())); + + let flagged = Arguments::try_parse_from(["agentctl", "port-forward", "--agent", "worker", "0.0.0.0:80:80"]) + .expect("flagged port-forward arguments"); + let Command::PortForward { agent, arguments, .. } = flagged.command else { + panic!("expected port-forward command"); + }; + assert_eq!(agent.as_deref(), Some("worker")); + assert_eq!(split_forward_arguments(&arguments), (None, arguments.as_slice())); + } + + #[test] + fn vnc_commands_accept_kubectl_shapes_and_a_chosen_local_port() { + let explicit = Arguments::try_parse_from(["agentctl", "vnc", "agent/worker"]).expect("vnc"); + let Command::Vnc { + agent, + variant, + resource, + port, + web, + open, + } = explicit.command + else { + panic!("expected vnc command"); + }; + assert_eq!(resource.as_deref(), Some("agent/worker")); + assert!(agent.is_none() && variant.is_none()); + assert!(port.is_none(), "a free local port is chosen unless one is asked for"); + assert!(!web, "the raw RFB port is forwarded unless a browser is asked for"); + assert!(!open, "a viewer is launched only when asked for"); + + let chosen = Arguments::try_parse_from(["agentctl", "vnc", "--port", "5901", "--open"]).expect("chosen port"); + assert!( + matches!( + chosen.command, + Command::Vnc { + port: Some(5901), + open: true, + resource: None, + .. + } + ), + "the Agent is inferred and a chosen local port is kept" + ); + + let browser = Arguments::try_parse_from(["agentctl", "vnc", "--web"]).expect("web vnc"); + assert!( + matches!( + browser.command, + Command::Vnc { + web: true, + port: None, + .. + } + ), + "--web forwards the viewer port instead of the RFB port" + ); + + // The Agent is named once, the same rule the ssh commands follow. + assert!(Arguments::try_parse_from(["agentctl", "vnc", "--agent", "worker", "agent/other"]).is_err()); + + let proxy = Arguments::try_parse_from(["agentctl", "vnc-proxy", "agent/worker"]).expect("vnc-proxy"); + assert!(matches!(proxy.command, Command::VncProxy { resource } if resource == "agent/worker")); + assert!(Arguments::try_parse_from(["agentctl", "vnc-proxy"]).is_err()); + + let info = Arguments::try_parse_from(["agentctl", "vnc-info", "worker", "-o", "json"]).expect("vnc-info"); + assert!(matches!( + info.command, + Command::VncInfo { + resource: Some(resource), + output: OutputFormat::Json, + .. + } if resource == "worker" + )); + } + + #[test] + fn ssh_commands_accept_kubectl_shapes_and_remote_commands() { + let explicit = Arguments::try_parse_from(["agentctl", "ssh", "agent/worker", "--", "uptime", "-p"]) + .expect("ssh with a remote command"); + let Command::Ssh { + agent, + resource, + command, + .. + } = explicit.command + else { + panic!("expected ssh command"); + }; + assert!(agent.is_none()); + assert_eq!(resource.as_deref(), Some("agent/worker")); + assert_eq!(command, ["uptime", "-p"]); + + let inferred = Arguments::try_parse_from(["agentctl", "ssh"]).expect("inferred ssh"); + assert!(matches!( + inferred.command, + Command::Ssh { + agent: None, + resource: None, + command, + .. + } if command.is_empty() + )); + assert!(Arguments::try_parse_from(["agentctl", "ssh", "--agent", "worker", "agent/other"]).is_err()); + + let proxy = Arguments::try_parse_from(["agentctl", "ssh-proxy", "agent/worker"]).expect("ssh-proxy"); + assert!(matches!(proxy.command, Command::SshProxy { resource } if resource == "agent/worker")); + assert!(Arguments::try_parse_from(["agentctl", "ssh-proxy"]).is_err()); + + let install = Arguments::try_parse_from(["agentctl", "ssh-config", "install"]).expect("ssh-config install"); + assert!(matches!( + install.command, + Command::SshConfig { + command: SshConfigCommand::Install + } + )); + + let info = Arguments::try_parse_from(["agentctl", "ssh-info", "worker", "-o", "json"]).expect("ssh-info"); + assert!(matches!( + info.command, + Command::SshInfo { + resource: Some(resource), + agent: None, + output: OutputFormat::Json, + .. + } if resource == "worker" + )); + } + + #[test] + fn codex_login_uses_an_isolated_chatgpt_grant() { + let arguments = + Arguments::try_parse_from(["agentctl", "codex", "login"]).expect("Codex ChatGPT login arguments"); + assert!(matches!( + arguments.command, + Command::Codex { + command: CodexCommand::Login { from_stdin: false } + } + )); + assert!(Arguments::try_parse_from(["agentctl", "codex", "login", "--with-api-key"]).is_err()); + let nested = Arguments::try_parse_from(["agentctl", "codex", "login", "--from-stdin"]) + .expect("Codex credential-file login arguments"); + assert!(matches!( + nested.command, + Command::Codex { + command: CodexCommand::Login { from_stdin: true } + } + )); + } + + #[test] + fn claude_login_accepts_a_token_on_standard_input() { + let arguments = + Arguments::try_parse_from(["agentctl", "claude", "login", "--from-stdin"]).expect("Claude login arguments"); + assert!(matches!( + arguments.command, + Command::Claude { + command: ClaudeCommand::Login { from_stdin: true } + } + )); + } + + #[test] + fn apply_accepts_a_secret_file_outside_the_manifest_directory() { + let arguments = Arguments::try_parse_from([ + "agentctl", + "apply", + "-f", + "agent.yaml", + "--env-file", + "/srv/secrets/worker.env", + ]) + .expect("apply arguments"); + assert!(matches!( + arguments.command, + Command::Apply { env_file: Some(path), .. } if path == Path::new("/srv/secrets/worker.env") + )); + } + + #[test] + fn apply_wait_is_opt_in_and_owns_the_timeout() { + let plain = Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml"]).expect("plain apply"); + assert!(matches!(plain.command, Command::Apply { wait: false, .. })); + let waited = Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml", "--wait", "--timeout", "2m"]) + .expect("apply --wait"); + assert!(matches!( + waited.command, + Command::Apply { wait: true, timeout, .. } if timeout == Duration::from_mins(2) + )); + assert!(Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml", "--timeout", "2m"]).is_err()); + } + + #[test] + fn apply_defaults_to_agent_yaml_and_accepts_only_variant_names() { + let default = Arguments::try_parse_from(["agentctl", "apply"]).expect("default apply"); + let Command::Apply { filename, variant, .. } = default.command else { + panic!("expected apply command"); + }; + assert!(filename.is_none()); + assert!(variant.is_none()); + assert_eq!( + apply_manifest_path(filename, variant).expect("default path"), + Path::new("agent.yaml") + ); + + let nested = + Arguments::try_parse_from(["agentctl", "apply", "--variant", "nested-build"]).expect("variant apply"); + let Command::Apply { filename, variant, .. } = nested.command else { + panic!("expected apply command"); + }; + assert_eq!( + apply_manifest_path(filename, variant).expect("variant path"), + Path::new("agent.nested-build.yaml") + ); + assert!(Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml", "--variant", "nested"]).is_err()); + assert!(Arguments::try_parse_from(["agentctl", "apply", "--variant", "../nested"]).is_err()); + assert!( + Arguments::try_parse_from([ + "agentctl", + "exec", + "--agent", + "worker", + "--variant", + "nested", + "--", + "true" + ]) + .is_err() + ); + } + + #[test] + fn wait_durations_are_bounded_and_explicit() { + assert_eq!(parse_duration("30s").expect("seconds"), Duration::from_secs(30)); + assert_eq!(parse_duration("10m").expect("minutes"), Duration::from_mins(10)); + assert_eq!(parse_duration("2h").expect("hours"), Duration::from_hours(2)); + assert!(parse_duration("0s").is_err()); + assert!(parse_duration("forever").is_err()); + } + + #[test] + fn wait_timeout_reports_the_last_ready_diagnostic() { + let condition = agent::Condition { + kind: "Ready".into(), + status: agent::ConditionStatus::False, + reason: "SecretMissing".into(), + message: ".env does not define required variable \"GITHUB_TOKEN\"".into(), + last_transition_time: None, + }; + + assert_eq!( + wait_timeout_message("worker", Some(&condition)), + "timed out waiting for Agent \"worker\" to become Ready: SecretMissing: .env does not define required variable \"GITHUB_TOKEN\"" + ); + } + + #[test] + fn inference_errors_have_one_actionable_message() { + let ambiguous = inference_error(Error::Rpc(agent::control_api::ResponseError { + code: -32602, + message: "multiple Agents were applied from this directory; specify --agent or --variant".into(), + })); + assert_eq!( + ambiguous.to_string(), + "multiple Agents were applied from this directory; specify --agent or --variant" + ); + + let missing = inference_error(Error::Rpc(agent::control_api::ResponseError { + code: -32004, + message: "Agent not found".into(), + })); + assert_eq!( + missing.to_string(), + "no Agent was applied from the current directory; specify --agent or --variant" + ); + } + + #[test] + fn apply_source_is_resolved_in_the_client_working_directory() { + let directory = tempfile::tempdir().expect("temporary directory"); + let manifest_path = directory.path().join("agent.yaml"); + std::fs::copy( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/minimal/agent.yaml"), + &manifest_path, + ) + .expect("copy example manifest"); + let original_directory = std::env::current_dir().expect("current directory"); + std::env::set_current_dir(directory.path()).expect("enter temporary directory"); + + let result = read_apply_request(PathBuf::from("agent.yaml"), None); + + std::env::set_current_dir(original_directory).expect("restore current directory"); + let request = result.expect("read apply request"); + let actual_directory = std::fs::canonicalize(&request.source_directory).expect("canonical source directory"); + let expected_directory = std::fs::canonicalize(directory.path()).expect("canonical temporary directory"); + assert_eq!(actual_directory, expected_directory); + } + + #[test] + fn daemon_binary_is_resolved_beside_agentctl() { + let directory = Path::new("opt").join("agent").join("bin"); + let agentctl = directory.join(format!("agentctl{}", std::env::consts::EXE_SUFFIX)); + let agentd = directory.join(format!("agentd{}", std::env::consts::EXE_SUFFIX)); + assert_eq!(daemon_executable(&agentctl), agentd); + } +} diff --git a/agentctl/src/bin/agentctl/progress.rs b/agentctl/src/bin/agentctl/progress.rs new file mode 100644 index 0000000..4ac3cec --- /dev/null +++ b/agentctl/src/bin/agentctl/progress.rs @@ -0,0 +1,870 @@ +//! Following an Agent's provisioning from a plain terminal. +//! +//! [`Wait`] runs one Control API call while it follows the Agent's progress +//! with `agents.v1.progress` and renders it to stderr. On a terminal the +//! current phase and step live on one updating line and only work that took +//! noticeable time leaves a permanent line, so a warm ensure prints nothing. +//! Without a terminal every completion and output line is printed once. + +use std::{ + cell::RefCell, + collections::VecDeque, + io::{self, IsTerminal as _, Write}, + pin::pin, + time::{Duration, Instant}, +}; + +use agent::{ + FailureKind, + control_api::Client, + progress::{AgentProgress, OutputPosition}, + resources::Revision, +}; +use sandbox::progress::{Measurement, OperationStatus, Outcome, ProgressCursor, ProgressUnit, Update}; + +/// Minimum interval between redraws of the updating line. +const REDRAW_INTERVAL: Duration = Duration::from_millis(50); +/// Completed phases faster than this leave no line on a terminal. +const NOTABLE_PHASE_MS: u64 = 100; +/// Completed steps faster than this leave no line on a terminal. +const NOTABLE_STEP_MS: u64 = 500; +const FALLBACK_WIDTH: usize = 80; +/// Output lines of the failed step shown in the failure report. +const RECENT_OUTPUT_LINES: usize = 5; +/// Longest wait for the state reached when the followed call returned. +const FINAL_READ_TIMEOUT: Duration = Duration::from_millis(500); +/// Pause before following again after a failed progress read. +const RETRY_INTERVAL: Duration = Duration::from_millis(250); + +/// One followed call: owns the renderer for its duration. +/// +/// Ctrl-C is deliberately not handled here. The default SIGINT disposition ends +/// `agentctl` with status 130, the daemon keeps reconciling regardless, and the +/// renderer already tells the user so on the first failure. A handler would +/// outlive the wait (tokio cannot uninstall it) and every later phase of the +/// process would have to emulate the default by hand. +/// +/// ```ignore +/// let wait = Wait::start(); +/// let target = wait +/// .until(client, &agent, client.ensure_execution(&agent, WaitPolicy::UntilConverged)) +/// .await?; +/// ``` +pub(crate) struct Wait { + renderer: RefCell, +} + +impl Wait { + pub(crate) fn start() -> Self { + Self { + renderer: RefCell::new(Renderer::stderr()), + } + } + + /// Runs `call` to completion while rendering `agent`'s progress, then + /// renders the final state and settles the terminal. + pub(crate) async fn until(&self, client: &Client, agent: &str, call: impl Future) -> T { + let mut call = pin!(call); + loop { + let (after, output) = self.renderer.borrow().position(); + let follow = async { + let progress = client.agent_progress(agent, after, output).await.ok(); + if progress.is_none() { + // The Agent may not be stored yet, or the daemon is restarting. + tokio::time::sleep(RETRY_INTERVAL).await; + } + progress + }; + tokio::select! { + biased; + result = &mut call => { + let (_, output) = self.renderer.borrow().position(); + let latest = tokio::time::timeout(FINAL_READ_TIMEOUT, client.agent_progress(agent, None, output)); + if let Ok(Ok(progress)) = latest.await { + self.renderer.borrow_mut().render(&progress); + } + self.renderer.borrow_mut().finish(); + return result; + } + progress = follow => { + if let Some(progress) = progress { + self.renderer.borrow_mut().render(&progress); + } + } + } + } + } +} + +/// Where the renderer writes, which decides between an updating line and plain lines. +#[derive(Clone, Copy)] +enum Mode { + /// An interactive terminal of the given width. + Terminal { width: usize }, + /// A pipe or file: every completion is printed once, nothing is redrawn. + Plain, +} + +pub(crate) struct Renderer { + output: W, + mode: Mode, + active_line: bool, + last_redraw: Option, + last_error: Option, + /// Consecutive failed passes with the current error. + failures: u32, + revision: Option, + /// Pass being rendered and the position in its progress. + pass: Option, + cursor: ProgressCursor, + /// Pass whose failure was already reported. + reported_failure: Option, + /// A step of the current phase left a permanent line on the terminal. + printed_step: bool, + /// Output of the step in progress, shown when it fails. + recent_output: VecDeque, + /// Ready condition already considered, so a standing failure is shown once. + seen_condition: Option, + started: bool, +} + +impl Renderer { + pub(crate) fn stderr() -> Self { + let output = io::stderr(); + let mode = if output.is_terminal() { + Mode::Terminal { + width: crossterm::terminal::size().map_or(FALLBACK_WIDTH, |(columns, _)| usize::from(columns)), + } + } else { + Mode::Plain + }; + Self::new(output, mode) + } +} + +impl Renderer { + const fn new(output: W, mode: Mode) -> Self { + Self { + output, + mode, + active_line: false, + last_redraw: None, + last_error: None, + failures: 0, + revision: None, + pass: None, + cursor: ProgressCursor::new(), + reported_failure: None, + printed_step: false, + recent_output: VecDeque::new(), + seen_condition: None, + started: false, + } + } + + /// Revision to follow from and the output already rendered. + fn position(&self) -> (Option, Option) { + let output = self.pass.map(|pass| OutputPosition { + pass, + sequence: self.cursor.output_sequence(), + }); + (self.revision, output) + } + + pub(crate) fn render(&mut self, progress: &AgentProgress) { + let _ignored = self.render_inner(progress); + } + + pub(crate) fn finish(&mut self) { + let _ignored = self.clear_active_line(); + } + + const fn interactive(&self) -> bool { + matches!(self.mode, Mode::Terminal { .. }) + } + + fn render_inner(&mut self, progress: &AgentProgress) -> io::Result<()> { + let following_began = !self.started; + self.revision = Some(progress.revision); + self.condition(progress)?; + let Some(provisioning) = &progress.provisioning else { + return Ok(()); + }; + if self.pass != Some(provisioning.pass) { + self.pass = Some(provisioning.pass); + self.cursor = ProgressCursor::new(); + self.printed_step = false; + self.recent_output.clear(); + // The latest pass may have ended long before following began. It is + // history, not progress: take its position without printing it. The + // command asks for a pass of its own, which reports its outcome. + if following_began + && matches!( + provisioning.progress.status(), + OperationStatus::Succeeded | OperationStatus::Failed { .. } + ) + { + let _ = self.cursor.updates(&provisioning.progress); + self.reported_failure = Some(provisioning.pass); + return Ok(()); + } + } + let pass = &provisioning.progress; + let mut latest_output = None; + for update in self.cursor.updates(pass) { + match update { + Update::OutputSkipped(count) => self.skipped(count)?, + Update::Output(line) => { + self.step_output(&line.text)?; + latest_output = Some(line.text.trim()); + } + Update::StepFinished(step) => { + latest_output = None; + // A failed step keeps its output for the failure report. + if step.outcome != Outcome::Failed { + self.recent_output.clear(); + self.step_completed(&step.name, step.elapsed_ms)?; + } + } + Update::PhaseFinished(phase) if phase.outcome != Outcome::Failed => { + self.phase_completed(&phase.phase.label, phase.outcome, phase.elapsed_ms)?; + } + // A failed phase is reported with its pass's failure below. + Update::PhaseFinished(_) => {} + } + } + match pass.status() { + OperationStatus::Failed { detail } if self.reported_failure != Some(provisioning.pass) => { + self.reported_failure = Some(provisioning.pass); + let (phase, elapsed_ms) = pass + .finished() + .iter() + .rfind(|phase| phase.outcome == Outcome::Failed) + .map_or(("Provision Sandbox", 0), |phase| { + (phase.phase.label.as_ref(), phase.elapsed_ms) + }); + // A stalled guest ends the wait like an invalid Agent does; the + // command reports both itself. + let retried = progress.status.failure.unwrap_or(FailureKind::Transient) == FailureKind::Transient + && progress.status.unresponsive().is_none(); + self.phase_failed(phase, detail, retried, elapsed_ms) + } + OperationStatus::Running => { + let Some(current) = pass.current() else { + return Ok(()); + }; + let mut line = format!("→ {}", current.phase.label); + let Some(step) = pass.current_step() else { + return self.show_line(&line); + }; + line.push_str(": "); + line.push_str(&step.name); + let detail = step + .measurement + .map(format_measurement) + .or_else(|| latest_output.map(str::to_owned)); + match detail { + Some(detail) => self.show_line_throttled(&format!("{line}: {detail}")), + None => self.show_line(&line), + } + } + _ => Ok(()), + } + } + + /// Reports a transient failure recorded outside a Sandbox pass, including + /// one that already stood when following started. Readiness itself is the + /// command's outcome and reported by the command. + fn condition(&mut self, progress: &AgentProgress) -> io::Result<()> { + let started = std::mem::replace(&mut self.started, true); + let Some(ready) = progress.status.ready_condition() else { + return Ok(()); + }; + let detail = ready.detail(); + if self.seen_condition.as_deref() == Some(detail.as_str()) { + return Ok(()); + } + self.seen_condition = Some(detail.clone()); + let failing = progress.status.failure == Some(FailureKind::Transient); + // A failed pass reports the failure itself, also when following begins after it. + let explained_by_pass = progress.provisioning.as_ref().is_some_and(|provisioning| { + started || matches!(provisioning.progress.status(), OperationStatus::Failed { .. }) + }); + if failing && !explained_by_pass { + self.clear_active_line()?; + self.error(&detail)?; + } + Ok(()) + } + + fn phase_completed(&mut self, label: &str, outcome: Outcome, elapsed_ms: u64) -> io::Result<()> { + self.clear_active_line()?; + let printed_step = std::mem::take(&mut self.printed_step); + if !self.interactive() { + return if outcome == Outcome::Reused { + writeln!(self.output, "✓ {label} (reused)") + } else { + writeln!(self.output, "✓ {label} ({})", duration(elapsed_ms)) + }; + } + if outcome != Outcome::Reused && (elapsed_ms >= NOTABLE_PHASE_MS || printed_step) { + writeln!(self.output, "✓ {label} ({})", duration(elapsed_ms))?; + } + Ok(()) + } + + fn step_completed(&mut self, name: &str, elapsed_ms: u64) -> io::Result<()> { + self.clear_active_line()?; + if !self.interactive() || elapsed_ms >= NOTABLE_STEP_MS { + self.printed_step = true; + writeln!(self.output, " ✓ {name} ({})", duration(elapsed_ms))?; + } + Ok(()) + } + + /// Closes the failed phase. + /// + /// An invalid configuration fails the command, which reports the error + /// itself. A transient failure is explained once, with the failed step's + /// last output; on a terminal further identical failures only advance a + /// counter on the updating line, because the daemon retries every pass. + fn phase_failed(&mut self, label: &str, detail: &str, retried: bool, elapsed_ms: u64) -> io::Result<()> { + self.clear_active_line()?; + if !retried { + return writeln!(self.output, "✗ {label} ({})", duration(elapsed_ms)); + } + // The first failed pass this command observes is always explained in full, + // even when the standing condition already named the error. + let explain = self.failures == 0 || self.last_error.as_deref() != Some(detail); + if explain { + self.failures = 0; + writeln!(self.output, "✗ {label} ({})", duration(elapsed_ms))?; + self.last_error = None; + self.error(detail)?; + let recent = std::mem::take(&mut self.recent_output); + let skip = recent.len().saturating_sub(RECENT_OUTPUT_LINES); + for line in recent.iter().skip(skip) { + writeln!(self.output, " {line}")?; + } + writeln!( + self.output, + " agentd keeps retrying in the background; press Ctrl-C to stop waiting" + )?; + } + self.failures += 1; + if !self.interactive() { + if !explain { + writeln!(self.output, "✗ {label} ({})", duration(elapsed_ms))?; + } + return Ok(()); + } + let count = self.failures; + self.show_line(&format!( + "✗ {label} failed {count}× (last {}); waiting for the next retry", + duration(elapsed_ms) + )) + } + + fn step_output(&mut self, line: &str) -> io::Result<()> { + self.recent_output.push_back(line.trim().to_owned()); + if self.interactive() { + return Ok(()); + } + writeln!(self.output, "{line}") + } + + fn skipped(&mut self, count: u64) -> io::Result<()> { + if self.interactive() { + return Ok(()); + } + writeln!(self.output, "… {count} output lines skipped") + } + + /// Prints one `error:` line, suppressing a repeat of the previous diagnostic. + fn error(&mut self, diagnostic: &str) -> io::Result<()> { + if self.last_error.as_deref() == Some(diagnostic) { + return Ok(()); + } + self.last_error = Some(diagnostic.to_owned()); + writeln!(self.output, "error: {diagnostic}") + } + + fn show_line_throttled(&mut self, line: &str) -> io::Result<()> { + if self.last_redraw.is_some_and(|last| last.elapsed() < REDRAW_INTERVAL) { + return Ok(()); + } + self.show_line(line) + } + + /// Replaces the updating line, truncated to the terminal width; no-op without a terminal. + fn show_line(&mut self, line: &str) -> io::Result<()> { + let Mode::Terminal { width } = self.mode else { + return Ok(()); + }; + let line = truncate(line, width.saturating_sub(1)); + write!(self.output, "\r\x1b[2K{line}")?; + self.output.flush()?; + self.active_line = true; + self.last_redraw = Some(Instant::now()); + Ok(()) + } + + fn clear_active_line(&mut self) -> io::Result<()> { + if self.interactive() && self.active_line { + write!(self.output, "\r\x1b[2K")?; + self.output.flush()?; + self.active_line = false; + } + Ok(()) + } +} + +fn truncate(text: &str, width: usize) -> String { + let count = text.chars().count(); + if count <= width || width < 2 { + return text.to_owned(); + } + let mut kept: String = text.chars().take(width - 1).collect(); + kept.push('…'); + kept +} + +pub(crate) fn format_measurement(Measurement { unit, completed, total }: Measurement) -> String { + match (unit, total) { + (ProgressUnit::Bytes, Some(total)) => format!("{} / {}", bytes(completed), bytes(total)), + (ProgressUnit::Bytes, None) => bytes(completed), + (ProgressUnit::Items, Some(total)) => format!("{completed} / {total}"), + _ => completed.to_string(), + } +} + +fn bytes(value: u64) -> String { + const KIB: u64 = 1_024; + const MIB: u64 = KIB * 1_024; + const GIB: u64 = MIB * 1_024; + if value >= GIB { + scaled(value, GIB, "GiB") + } else if value >= MIB { + scaled(value, MIB, "MiB") + } else if value >= KIB { + scaled(value, KIB, "KiB") + } else { + format!("{value} B") + } +} + +fn scaled(value: u64, unit: u64, suffix: &str) -> String { + let whole = value / unit; + let decimal = (value % unit).saturating_mul(10) / unit; + format!("{whole}.{decimal} {suffix}") +} + +pub(crate) fn duration(milliseconds: u64) -> String { + if milliseconds >= 60_000 { + format!("{}m {:02}s", milliseconds / 60_000, milliseconds % 60_000 / 1_000) + } else if milliseconds >= 1_000 { + let seconds = milliseconds / 1_000; + let tenths = milliseconds % 1_000 / 100; + format!("{seconds}.{tenths}s") + } else { + format!("{milliseconds}ms") + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, time::Duration}; + + use agent::{Condition, ConditionStatus, Status, progress::Provisioning, resources::Changes}; + use sandbox::{OutputStream, ProgressEvent, SandboxPhase, StepId, progress::Progress}; + + use super::*; + + /// Builds successive snapshots of one Agent's progress, as the daemon reports them. + struct Daemon { + changes: Changes, + pass: Revision, + progress: Progress, + status: Status, + steps: HashMap<&'static str, StepId>, + } + + impl Daemon { + fn new() -> Self { + let changes = Changes::new(); + Self { + pass: changes.revision(), + changes, + progress: Progress::new(), + status: Status::default(), + steps: HashMap::new(), + } + } + + fn apply(&mut self, event: &ProgressEvent) -> &mut Self { + self.progress.apply(event); + self + } + + fn phase(&mut self, phase: SandboxPhase) -> &mut Self { + self.apply(&ProgressEvent::PhaseStarted { phase: phase.phase() }) + } + + fn end_phase(&mut self, phase: SandboxPhase, outcome: Outcome, elapsed_ms: u64) -> &mut Self { + self.apply(&ProgressEvent::PhaseEnded { + phase: phase.phase(), + outcome, + elapsed: Duration::from_millis(elapsed_ms), + }) + } + + fn step(&mut self, name: &'static str) -> &mut Self { + let id = StepId::generate(); + self.steps.insert(name, id.clone()); + self.apply(&ProgressEvent::StepStarted { + id, + name: name.into(), + unit: None, + total: None, + }) + } + + fn output(&mut self, name: &'static str, text: &str) -> &mut Self { + let id = self.steps[name].clone(); + self.apply(&ProgressEvent::StepOutput { + id, + stream: OutputStream::Stderr, + bytes: text.as_bytes().to_vec().into(), + }) + } + + fn end_step(&mut self, name: &'static str, elapsed_ms: u64) -> &mut Self { + let id = self.steps[name].clone(); + self.apply(&ProgressEvent::StepEnded { + id, + outcome: Outcome::Completed, + elapsed: Duration::from_millis(elapsed_ms), + }) + } + + fn fail(&mut self, detail: &str) -> &mut Self { + // End the phase in progress at a fixed time: left to the fold, its + // duration is measured on the clock and differs between machines. + if let Some(phase) = self.progress.current().map(|current| current.phase.clone()) { + self.apply(&ProgressEvent::PhaseEnded { + phase, + outcome: Outcome::Failed, + elapsed: Duration::ZERO, + }); + } + self.progress.fail(detail); + self.status.failure = Some(FailureKind::Transient); + self.status.conditions = vec![Condition { + kind: Condition::READY.into(), + status: ConditionStatus::False, + reason: "SandboxReconcileFailed".into(), + message: detail.into(), + last_transition_time: None, + }]; + self + } + + fn succeed(&mut self) -> &mut Self { + self.progress.succeed(); + self.status.failure = None; + self.status.conditions = vec![Condition { + kind: Condition::READY.into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }]; + self + } + + fn retry(&mut self) -> &mut Self { + self.changes.bump(); + self.pass = self.changes.revision(); + self.progress = Progress::new(); + self + } + + fn snapshot(&self) -> AgentProgress { + self.changes.bump(); + AgentProgress { + revision: self.changes.revision(), + status: self.status.clone(), + provisioning: Some(Provisioning { + pass: self.pass, + progress: self.progress.clone(), + }), + } + } + } + + fn lines(renderer: Renderer>) -> Vec { + String::from_utf8(renderer.output) + .expect("utf-8") + .split(['\r', '\n']) + .map(|part| part.trim_start_matches("\x1b[2K")) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect() + } + + fn renderer(interactive: bool) -> Renderer> { + let mode = if interactive { + Mode::Terminal { width: 80 } + } else { + Mode::Plain + }; + Renderer::new(Vec::new(), mode) + } + + #[test] + fn a_warm_ensure_leaves_no_permanent_lines_on_a_terminal() { + let mut renderer = renderer(true); + let mut daemon = Daemon::new(); + renderer.render(&daemon.phase(SandboxPhase::Lookup).snapshot()); + renderer.render( + &daemon + .end_phase(SandboxPhase::Lookup, Outcome::Reused, 0) + .phase(SandboxPhase::Inspect) + .snapshot(), + ); + renderer.render( + &daemon + .end_phase(SandboxPhase::Inspect, Outcome::Completed, 3) + .snapshot(), + ); + renderer.finish(); + assert_eq!( + lines(renderer), + vec!["→ Look up Sandbox", "→ Inspect Sandbox"], + "only transient redraws" + ); + } + + #[test] + fn noticeable_work_and_failures_leave_lines_on_a_terminal() { + let mut renderer = renderer(true); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::ImageResolve) + .step("Check Docker Engine") + .end_step("Check Docker Engine", 2) + .step("Build Docker image"); + renderer.render(&daemon.snapshot()); + daemon + .end_step("Build Docker image", 74_000) + .end_phase(SandboxPhase::ImageResolve, Outcome::Completed, 87_000) + .phase(SandboxPhase::SandboxStart) + .step("Start Microsandbox VM") + .output("Start Microsandbox VM", "opening disk\nno such file\n"); + renderer.render(&daemon.snapshot()); + daemon.fail("VMDK missing"); + renderer.render(&daemon.snapshot()); + daemon.retry().phase(SandboxPhase::SandboxStart).fail("VMDK missing"); + renderer.render(&daemon.snapshot()); + renderer.finish(); + + let lines = lines(renderer); + let permanent: Vec<_> = lines.iter().filter(|line| !line.starts_with('→')).collect(); + assert_eq!( + permanent, + vec![ + " ✓ Build Docker image (1m 14s)", + "✓ Resolve Sandbox Image (1m 27s)", + "✗ Start Sandbox (0ms)", + "error: VMDK missing", + " opening disk", + " no such file", + " agentd keeps retrying in the background; press Ctrl-C to stop waiting", + "✗ Start Sandbox failed 1× (last 0ms); waiting for the next retry", + "✗ Start Sandbox failed 2× (last 0ms); waiting for the next retry", + ] + ); + } + + #[test] + fn without_a_terminal_every_completion_and_output_line_is_printed_once() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + renderer.render(&daemon.phase(SandboxPhase::Lookup).snapshot()); + daemon + .end_phase(SandboxPhase::Lookup, Outcome::Reused, 0) + .phase(SandboxPhase::SandboxStart) + .step("Create Microsandbox VM") + .output("Create Microsandbox VM", "created\n"); + renderer.render(&daemon.snapshot()); + renderer.render(&daemon.snapshot()); + daemon + .end_step("Create Microsandbox VM", 366) + .end_phase(SandboxPhase::SandboxStart, Outcome::Completed, 367); + renderer.render(&daemon.snapshot()); + renderer.finish(); + assert_eq!( + lines(renderer), + vec![ + "✓ Look up Sandbox (reused)", + "created", + " ✓ Create Microsandbox VM (366ms)", + "✓ Start Sandbox (367ms)", + ] + ); + } + + #[test] + fn a_pass_that_succeeded_before_following_began_is_not_replayed() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::SandboxStart) + .step("Create Microsandbox VM") + .output("Create Microsandbox VM", "created\n") + .end_step("Create Microsandbox VM", 366) + .end_phase(SandboxPhase::SandboxStart, Outcome::Completed, 367) + .succeed(); + renderer.render(&daemon.snapshot()); + renderer.render(&daemon.snapshot()); + daemon + .retry() + .phase(SandboxPhase::SandboxStart) + .step("Create Microsandbox VM") + .end_step("Create Microsandbox VM", 12) + .end_phase(SandboxPhase::SandboxStart, Outcome::Completed, 13); + renderer.render(&daemon.snapshot()); + renderer.finish(); + assert_eq!( + lines(renderer), + vec![" ✓ Create Microsandbox VM (12ms)", "✓ Start Sandbox (13ms)"], + "only the pass that ran while following is printed" + ); + } + + #[test] + fn a_pass_that_failed_before_following_began_is_not_replayed() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon.phase(SandboxPhase::SandboxStart).fail("VMDK missing"); + renderer.render(&daemon.snapshot()); + daemon + .retry() + .phase(SandboxPhase::SandboxStart) + .fail("VMDK still missing"); + renderer.render(&daemon.snapshot()); + renderer.finish(); + let lines = lines(renderer); + assert!(!lines.iter().any(|line| line == "error: VMDK missing"), "{lines:#?}"); + assert_eq!( + lines.first().map(String::as_str), + Some("✗ Start Sandbox (0ms)"), + "the command's own pass reports its failure: {lines:#?}" + ); + } + + #[test] + fn a_stalled_guest_ends_the_wait_without_a_retry_hint() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + renderer.render(&daemon.snapshot()); + daemon + .retry() + .phase(SandboxPhase::SandboxStart) + .fail("the guest has not reported progress for more than 15s"); + daemon.status.conditions.push(Condition { + kind: Condition::SANDBOX_RESPONSIVE.into(), + status: ConditionStatus::False, + reason: "HeartbeatStale".into(), + message: "the guest has not reported progress for more than 15s".into(), + last_transition_time: None, + }); + renderer.render(&daemon.snapshot()); + renderer.finish(); + assert_eq!(lines(renderer), ["✗ Start Sandbox (0ms)"]); + } + + #[test] + fn a_standing_failure_is_reported_once_when_following_starts() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon.fail("registry unavailable"); + let mut standing = daemon.snapshot(); + standing.provisioning = None; + renderer.render(&standing); + renderer.render(&standing); + assert_eq!(lines(renderer), vec!["error: registry unavailable"]); + } + + #[test] + fn the_updating_line_is_truncated_to_the_terminal_width() { + let mut renderer = Renderer::new(Vec::new(), Mode::Terminal { width: 24 }); + let mut daemon = Daemon::new(); + daemon.phase(SandboxPhase::ImageResolve).step("Build Docker image"); + renderer.render(&daemon.snapshot()); + let lines = lines(renderer); + assert_eq!(lines.last().map(String::as_str), Some("→ Resolve Sandbox Imag…")); + } + + #[test] + fn replies_trimmed_to_unseen_output_render_nothing_twice_over_the_wire() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::ImageResolve) + .step("Build") + .output("Build", "one\n") + .end_step("Build", 700) + .step("Import"); + renderer.render(&daemon.snapshot()); + for completed in [1_u64, 2, 3] { + let id = daemon.steps["Import"].clone(); + daemon.apply(&ProgressEvent::StepOutput { + id, + stream: OutputStream::Stdout, + bytes: format!("layer {completed}\n").into_bytes().into(), + }); + let (_, output) = renderer.position(); + let mut reply = daemon.snapshot(); + if let (Some(provisioning), Some(output)) = (reply.provisioning.as_mut(), output) { + provisioning.progress = provisioning.progress.output_from(output.sequence); + } + // Replies reach the renderer through the wire format. + let reply = serde_json::from_value(serde_json::to_value(&reply).expect("reply JSON")).expect("reply"); + renderer.render(&reply); + let (_, output) = renderer.position(); + let mut quiet = daemon.snapshot(); + if let (Some(provisioning), Some(output)) = (quiet.provisioning.as_mut(), output) { + provisioning.progress = provisioning.progress.output_from(output.sequence); + } + let quiet = serde_json::from_value(serde_json::to_value(&quiet).expect("reply JSON")).expect("reply"); + renderer.render(&quiet); + } + assert_eq!( + lines(renderer), + vec!["one", " ✓ Build (700ms)", "layer 1", "layer 2", "layer 3"] + ); + } + + #[test] + fn the_follow_position_names_the_rendered_pass_and_output() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::ImageResolve) + .step("Build") + .output("Build", "one\ntwo\n"); + let snapshot = daemon.snapshot(); + renderer.render(&snapshot); + assert_eq!( + renderer.position(), + ( + Some(snapshot.revision), + Some(OutputPosition { + pass: daemon.pass, + sequence: 2 + }) + ) + ); + } +} diff --git a/agentctl/src/bin/agentctl/self_update.rs b/agentctl/src/bin/agentctl/self_update.rs new file mode 100644 index 0000000..a4a47b6 --- /dev/null +++ b/agentctl/src/bin/agentctl/self_update.rs @@ -0,0 +1,526 @@ +use std::{ + path::{Path, PathBuf}, + process::{Command as ProcessCommand, Stdio}, + time::{Duration, Instant}, +}; + +use agent::{ + Error, + control_api::{Client, DaemonInfo, PROTOCOL_VERSION}, + local::home::{ControlPlaneHome, Lock}, + upgrade::{self, InstallMetadata, InstallPaths, Release, UpdateJournal, UpdatePhase}, +}; + +use super::CommandResult; + +const DEFAULT_REPOSITORY: &str = "Altinn/altinn-studio"; +const DAEMON_STOP_TIMEOUT: Duration = Duration::from_secs(65); +const LIFECYCLE_REQUEST_TIMEOUT: Duration = Duration::from_secs(65); +const TARGET_VERIFY_TIMEOUT: Duration = Duration::from_secs(75); + +struct Completion { + paths: InstallPaths, + target_release: PathBuf, + target_version: String, + previous_release: Option, + repository: String, +} + +impl Completion { + fn from_journal(paths: InstallPaths, journal: UpdateJournal) -> Result { + let repository = repository(&paths)?; + Ok(Self { + paths, + target_release: journal.target_release, + target_version: journal.target_version, + previous_release: journal.previous_release, + repository, + }) + } + + fn run_target(&self, home: &ControlPlaneHome) -> Result<(), Error> { + let mut command = ProcessCommand::new( + self.target_release + .join(format!("agentctl{}", std::env::consts::EXE_SUFFIX)), + ); + command + .arg("--home") + .arg(home.path()) + .args(["self", "__complete-update", "--install-root"]) + .arg(self.paths.root()) + .arg("--bin-directory") + .arg(self.paths.bin()) + .arg("--target-release") + .arg(&self.target_release) + .arg("--target-version") + .arg(&self.target_version) + .arg("--repository") + .arg(&self.repository); + if let Some(previous) = &self.previous_release { + command.arg("--previous-release").arg(previous); + } + let status = command.status()?; + if !status.success() { + return Err(Error::Daemon(format!("target updater exited with {status}"))); + } + Ok(()) + } +} + +#[derive(clap::Subcommand)] +pub(super) enum SelfCommand { + /// Check for or install a released Agent update. + Update { + /// Install this exact release version. + #[arg(long)] + version: Option, + /// Report whether an update is available without downloading it. + #[arg(long)] + check: bool, + }, + /// Complete a target-owned package and state transition. + #[command(name = "__complete-update", hide = true)] + CompleteUpdate { + #[arg(long)] + install_root: PathBuf, + #[arg(long)] + bin_directory: PathBuf, + #[arg(long)] + target_release: PathBuf, + #[arg(long)] + target_version: String, + #[arg(long)] + previous_release: Option, + #[arg(long, default_value = DEFAULT_REPOSITORY)] + repository: String, + }, + /// Publish an extracted release through the platform install lock. + #[command(name = "__publish-release", hide = true)] + PublishRelease { + #[arg(long)] + install_root: PathBuf, + #[arg(long)] + bin_directory: PathBuf, + #[arg(long)] + source_release: PathBuf, + #[arg(long)] + target_version: String, + }, +} + +pub(super) async fn execute(command: SelfCommand, home: &ControlPlaneHome) -> CommandResult<()> { + match command { + SelfCommand::Update { version, check } => update(home, version.as_deref(), check).await, + SelfCommand::CompleteUpdate { + install_root, + bin_directory, + target_release, + target_version, + previous_release, + repository, + } => { + complete( + Completion { + paths: InstallPaths::new(install_root, bin_directory)?, + target_release, + target_version, + previous_release, + repository, + }, + home, + ) + .await + } + SelfCommand::PublishRelease { + install_root, + bin_directory, + source_release, + target_version, + } => { + validate_source_process(&source_release)?; + upgrade::publish_release( + &InstallPaths::new(install_root, bin_directory)?, + &source_release, + &target_version, + ) + .await?; + Ok(()) + } + } +} + +pub(super) fn resume_pending_before_command(home: &ControlPlaneHome) -> CommandResult<()> { + let paths = InstallPaths::resolve()?; + let Some(journal) = UpdateJournal::read(&paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) else { + return Ok(()); + }; + let target_version = journal.target_version.clone(); + Completion::from_journal(paths, journal)?.run_target(home)?; + if !same_version(agent::build_version(), &target_version)? { + return Err(Error::Daemon(format!( + "Agent update to {target_version} completed; rerun this command with the current agentctl" + )) + .into()); + } + Ok(()) +} + +async fn update(home: &ControlPlaneHome, version: Option<&str>, check: bool) -> CommandResult<()> { + let paths = InstallPaths::resolve()?; + if let Some(journal) = UpdateJournal::read(&paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) { + Completion::from_journal(paths, journal)?.run_target(home)?; + return Ok(()); + } + if version.is_none() && agent::release_version().is_none() { + return Err(Error::Invalid("this development build needs an explicit self update --version".into()).into()); + } + let repository = repository(&paths)?; + println!("Resolve release"); + let release = Release::resolve(version, repository).await?; + let previous = upgrade::current_release(&paths)?; + let current_version = previous + .as_deref() + .map(upgrade::managed_release_version) + .transpose()? + .unwrap_or_else(|| agent::build_version().to_owned()); + compare_versions(¤t_version, &release.version)?; + if previous.is_some() && same_version(¤t_version, &release.version)? { + println!("Agent {} is already installed", release.version); + return Ok(()); + } + if check { + println!("Agent {} is available (current {})", release.version, current_version); + return Ok(()); + } + + println!("Download and verify package"); + let staged = upgrade::stage_release(&paths, release).await?; + Completion { + paths, + target_release: staged.path, + target_version: staged.release.version, + previous_release: previous, + repository: staged.release.repository, + } + .run_target(home) + .map_err(Into::into) +} + +async fn complete(completion: Completion, home: &ControlPlaneHome) -> CommandResult<()> { + // Release ordering belongs to `self update`. Installers enter here directly + // so a local development build may replace a higher published preview. + let Completion { + paths, + target_release, + target_version, + previous_release, + repository, + } = completion; + let _install_lock = paths.lock().await?; + validate_target_process(&paths, &target_release, &target_version)?; + let (mut journal, journal_is_new) = + if let Some(journal) = UpdateJournal::read(&paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) { + if journal.target_release != target_release + || journal.target_version != target_version + || journal.previous_release != previous_release + { + return Err(Error::Invalid("arguments do not match the unfinished Agent update".into()).into()); + } + (journal, false) + } else { + ( + UpdateJournal::new(previous_release.clone(), target_release.clone(), target_version.clone()), + true, + ) + }; + let client = Client::for_path(home.socket_path()); + if journal.phase < UpdatePhase::Migrated { + println!("Check Agent activity"); + match tokio::time::timeout(Duration::from_secs(2), client.health()).await { + Ok(Ok(info)) => { + if is_preview_1(&info) { + return Err(Error::Daemon(preview_stop_instruction().into()).into()); + } + println!("Stop agentd"); + let warnings = tokio::time::timeout(LIFECYCLE_REQUEST_TIMEOUT, client.shutdown_for_upgrade()) + .await + .map_err(|_| Error::Daemon("timed out waiting for agentd to prepare for upgrade".into()))??; + for warning in warnings { + eprintln!("Warning: {warning}"); + } + } + Err(_) => { + return Err(Error::Daemon( + "agentd did not answer its health check; retry the update or stop agentd".into(), + ) + .into()); + } + Ok(Err(_)) => {} + } + } + let home_lock = if journal.phase < UpdatePhase::Activated { + Some(acquire_home_lock(home).await?) + } else { + None + }; + if journal_is_new { + journal.advance(&paths, UpdatePhase::Prepared)?; + } + if journal.phase < UpdatePhase::Migrated { + println!("Migrate Agent state"); + if let Err(error) = migrate_state(&paths, &journal, &home.path().join("agent.db")) { + drop(home_lock); + if let Some(previous_release) = &previous_release { + let _ = start_daemon(previous_release, home); + } + return Err(error.into()); + } + upgrade::create_session_relaunch_marker(home, &target_version)?; + journal.advance(&paths, UpdatePhase::Migrated)?; + } + + if journal.phase < UpdatePhase::Activated { + println!("Activate target package"); + InstallMetadata::new(repository, paths.bin().to_path_buf()).write(&paths)?; + upgrade::activate_release(&paths, &target_release)?; + journal.advance(&paths, UpdatePhase::Activated)?; + } + drop(home_lock); + + println!("Start and verify target daemon"); + if !target_ready(&client, home, &target_version).await { + start_daemon(&target_release, home)?; + } + verify_target(&client, home, &target_version).await?; + journal.advance(&paths, UpdatePhase::Complete)?; + upgrade::prune_releases(&paths, previous_release.as_deref())?; + println!("Agent updated to {target_version}"); + Ok(()) +} + +fn migrate_state(paths: &InstallPaths, journal: &UpdateJournal, database: &Path) -> Result<(), Error> { + match agent::persistence::Database::migrate(database) { + Ok(()) => Ok(()), + Err(error) => { + if let Err(discard_error) = journal.discard_before_migration(paths) { + return Err(Error::Database(format!( + "{error}; failed to discard the pre-migration update journal: {discard_error}" + ))); + } + Err(error) + } + } +} + +fn validate_target_process(paths: &InstallPaths, target: &Path, version: &str) -> Result<(), Error> { + let target = canonical_target(paths, target)?; + upgrade::validate_release_directory(&target, version)?; + validate_release_process(&target, "update completion must run from the target release") +} + +fn canonical_target(paths: &InstallPaths, target: &Path) -> Result { + if !target.is_absolute() { + return Err(Error::Invalid("target updater paths are inconsistent".into())); + } + let releases = std::fs::canonicalize(paths.releases())?; + let target = std::fs::canonicalize(target)?; + if target.parent() != Some(releases.as_path()) { + return Err(Error::Invalid("target updater paths are inconsistent".into())); + } + Ok(target) +} + +fn validate_source_process(source: &Path) -> Result<(), Error> { + validate_release_process(source, "release publication must run from the source package") +} + +fn validate_release_process(release: &Path, mismatch: &str) -> Result<(), Error> { + let executable = std::fs::canonicalize(std::env::current_exe()?)?; + let expected = std::fs::canonicalize(release.join(format!("agentctl{}", std::env::consts::EXE_SUFFIX)))?; + if executable != expected { + return Err(Error::Invalid(mismatch.into())); + } + Ok(()) +} + +async fn acquire_home_lock(home: &ControlPlaneHome) -> Result { + let deadline = Instant::now() + DAEMON_STOP_TIMEOUT; + loop { + match home.acquire_lock() { + Ok(lock) => return Ok(lock), + Err(Error::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(error) => return Err(error), + } + } +} + +fn start_daemon(release: &Path, home: &ControlPlaneHome) -> Result<(), Error> { + let log = home.open_daemon_log()?; + let mut command = ProcessCommand::new(release.join(format!("agentd{}", std::env::consts::EXE_SUFFIX))); + command + .arg("--home") + .arg(home.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log); + agent::local::process::configure_detached(&mut command); + agent::local::process::configure_logging(&mut command); + command.spawn()?; + Ok(()) +} + +async fn verify_target(client: &Client, home: &ControlPlaneHome, target_version: &str) -> Result<(), Error> { + let deadline = Instant::now() + TARGET_VERIFY_TIMEOUT; + while Instant::now() < deadline { + if target_ready(client, home, target_version).await { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(Error::Daemon(format!( + "target agentd {target_version:?} did not become healthy and finish Session relaunch within {} seconds; {}", + TARGET_VERIFY_TIMEOUT.as_secs(), + super::daemon_startup_diagnostics(home) + ))) +} + +async fn target_ready(client: &Client, home: &ControlPlaneHome, target_version: &str) -> bool { + matches!( + tokio::time::timeout(Duration::from_secs(1), client.health()).await, + Ok(Ok(info)) + if info.protocol_version.as_deref() == Some(PROTOCOL_VERSION) + && info.build_version.as_deref() == Some(target_version) + && !home.pending_session_relaunch_path().exists() + ) +} + +fn compare_versions(current: &str, target: &str) -> Result<(), Error> { + let current = parse_version(current); + let target = parse_version(target).map_err(|error| Error::Invalid(format!("invalid target version: {error}")))?; + if let Ok(ref current) = current + && target < *current + { + return Err(Error::Invalid(format!( + "downgrading Agent from v{current} to v{target} is not supported" + ))); + } + Ok(()) +} + +fn same_version(current: &str, target: &str) -> Result { + let current = + parse_version(current).map_err(|error| Error::Invalid(format!("invalid current version: {error}")))?; + let target = parse_version(target).map_err(|error| Error::Invalid(format!("invalid target version: {error}")))?; + Ok(current == target) +} + +fn parse_version(version: &str) -> Result { + semver::Version::parse(version.strip_prefix('v').unwrap_or(version)) +} + +fn repository(paths: &InstallPaths) -> Result { + match InstallMetadata::read(paths) { + Ok(metadata) => Ok(metadata.repository().to_owned()), + Err(Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(std::env::var("AGENT_GITHUB_REPOSITORY").unwrap_or_else(|_| DEFAULT_REPOSITORY.into())) + } + Err(error) => Err(error), + } +} + +const fn preview_stop_instruction() -> &'static str { + if cfg!(windows) { + "preview 1 agentd cannot stop itself; finish active turns, run `Stop-Process -Name agentd` in PowerShell, and rerun the installer" + } else { + "preview 1 agentd cannot stop itself; finish active turns, run `pkill -x agentd`, and rerun the installer" + } +} + +fn is_preview_1(info: &DaemonInfo) -> bool { + info.protocol_version.as_deref() == Some("v1") && info.build_version.is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_comparison_rejects_downgrade() { + assert!(compare_versions("v2.0.0", "v1.0.0").is_err()); + assert!( + compare_versions("v0.1.0-preview.2", "v0.0.1-dev.20260914000000").is_err(), + "the released-update policy also rejects a lower development build" + ); + assert!(same_version("1.0.0", "v1.0.0").expect("version")); + } + + #[test] + fn failed_migration_discards_the_prepared_journal() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = + InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("install paths"); + let target = paths.releases().join(format!("v2.0.0-{}", upgrade::package_target())); + let mut journal = UpdateJournal::new(None, target, "v2.0.0".into()); + journal + .advance(&paths, UpdatePhase::Prepared) + .expect("prepared journal"); + let database = temporary.path().join("agent.db"); + rusqlite::Connection::open(&database) + .expect("database") + .execute_batch("PRAGMA user_version = 999;") + .expect("future schema"); + + let error = migrate_state(&paths, &journal, &database).expect_err("migration failure"); + + assert!(error.to_string().contains("newer than the supported schema"), "{error}"); + assert!( + !paths.journal().exists(), + "failed migration must not poison later commands" + ); + } + + #[test] + fn only_preview_1_requires_manual_daemon_shutdown() { + assert!(is_preview_1(&DaemonInfo { + protocol_version: Some("v1".into()), + build_version: None, + })); + assert!(!is_preview_1(&DaemonInfo { + protocol_version: Some("v1".into()), + build_version: Some("v0.2.0".into()), + })); + assert!(!is_preview_1(&DaemonInfo { + protocol_version: Some("v2".into()), + build_version: Some("v0.3.0".into()), + })); + } + + #[cfg(unix)] + #[test] + fn target_validation_accepts_an_installation_alias() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let real = temporary.path().join("real"); + let release = real.join("releases/v0.2.0-linux-x86_64"); + std::fs::create_dir_all(&release).expect("release"); + let alias = temporary.path().join("alias"); + symlink(&real, &alias).expect("alias"); + let paths = InstallPaths::new(real, temporary.path().join("bin")).expect("paths"); + + assert_eq!( + canonical_target(&paths, &alias.join("releases/v0.2.0-linux-x86_64")).expect("target"), + std::fs::canonicalize(release).expect("canonical release") + ); + } + + #[test] + fn self_help_hides_completion_command() { + use clap::CommandFactory as _; + let help = super::super::Arguments::command().render_long_help().to_string(); + assert!(!help.contains("__complete-update")); + assert!(!help.contains("__publish-release")); + } +} diff --git a/agentctl/src/bin/agentctl/tui/app.rs b/agentctl/src/bin/agentctl/tui/app.rs new file mode 100644 index 0000000..d007d33 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/app.rs @@ -0,0 +1,4964 @@ +use std::{ + cell::Cell, + collections::HashSet, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use agent::{ + Agent, Condition, ConditionStatus, Effort, FailureKind, Harness, HarnessSpec, Model, ModelSelection, RunState, + sessions::{Session, SessionName, State, Turn}, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use sandbox::progress::{OperationStatus, Progress}; +use time::OffsetDateTime; + +use super::open::{Environment, MenuEntry, OpenMenu, OpenTarget, SshSetup}; +use crate::{format, forward::ForwardSpec}; + +/// Output lines of a failed pass the Agent side panel shows. +const AGENT_PANEL_OUTPUT_LINES: usize = 10; + +/// How long the header shows the outcome of a Session change. +const NOTICE_DURATION: Duration = Duration::from_secs(5); + +/// A displayed key hint and, when unambiguous, the key emitted by a click. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Hint { + pub(crate) label: &'static str, + pub(crate) description: &'static str, + pub(crate) key: Option<(KeyCode, KeyModifiers)>, +} + +impl Hint { + pub(crate) const fn key(label: &'static str, description: &'static str, code: KeyCode) -> Self { + Self { + label, + description, + key: Some((code, KeyModifiers::NONE)), + } + } + + pub(crate) const fn modified( + label: &'static str, + description: &'static str, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Self { + Self { + label, + description, + key: Some((code, modifiers)), + } + } + + pub(crate) const fn display(label: &'static str, description: &'static str) -> Self { + Self { + label, + description, + key: None, + } + } +} + +/// Key hints of the new Session form, shared by the modal and the footer. +pub(crate) const NEW_SESSION_HINTS: [Hint; 4] = [ + Hint::key("enter", "create", KeyCode::Enter), + Hint::display("tab/↑/↓", "field"), + Hint::display("←/→", "harness"), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +/// Key hints of the create Agent form, shared by the modal and the footer. +pub(crate) const CREATE_AGENT_HINTS: [Hint; 4] = [ + Hint::key("enter", "create", KeyCode::Enter), + Hint::display("tab/↑/↓", "field"), + Hint::display("←/→", "select"), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +pub(crate) const CONFIRM_HINTS: [Hint; 2] = [ + Hint::key("y", "confirm", KeyCode::Char('y')), + Hint::key("n", "cancel", KeyCode::Char('n')), +]; + +/// Key hints while a prompt is typed in the footer. +pub(crate) const PROMPT_HINTS: [Hint; 2] = [ + Hint::key("enter", "send", KeyCode::Enter), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +/// Key hints while the filter is edited in the footer. +pub(crate) const FILTER_HINTS: [Hint; 2] = [ + Hint::key("enter", "keep", KeyCode::Enter), + Hint::key("esc", "clear", KeyCode::Esc), +]; + +/// Key hints of the open menu, shared by the modal and the footer. +pub(crate) const OPEN_HINTS: [Hint; 3] = [ + Hint::key("enter", "open", KeyCode::Enter), + Hint::display("↑/↓", "select"), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +/// Adding the line takes Enter, not `y`: in the open menu `y` copies the alias, +/// so a repeated `y` must not write the user's configuration. +pub(crate) const CONFIRM_SSH_SETUP_HINTS: [Hint; 2] = [ + Hint::key("enter", "add", KeyCode::Enter), + Hint::key("esc", "back", KeyCode::Esc), +]; + +/// The SSH setup question for something waiting to open, which can also open +/// without the line: an editor may reach Agents through a configuration of its own. +pub(crate) const CONFIRM_SSH_SETUP_THEN_HINTS: [Hint; 3] = [ + Hint::key("enter", "add", KeyCode::Enter), + Hint::key("o", "open anyway", KeyCode::Char('o')), + Hint::key("esc", "back", KeyCode::Esc), +]; + +pub(crate) const PORT_FORWARD_HINTS: [Hint; 3] = [ + Hint::key("enter", "forward", KeyCode::Enter), + Hint::key("tab", "field", KeyCode::Tab), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +const DETAIL_HINTS: [Hint; 2] = [ + Hint::display("j/k", "scroll"), + Hint::key("q", "back", KeyCode::Char('q')), +]; + +const FORWARD_VIEW_HINTS: [Hint; 4] = [ + Hint::key("o", "open", KeyCode::Char('o')), + Hint::key("e", "edit", KeyCode::Char('e')), + Hint::modified("ctrl-d", "delete", KeyCode::Char('d'), KeyModifiers::CONTROL), + Hint::key("q", "back", KeyCode::Char('q')), +]; + +// Selection hints come most used first, so a footer too narrow for all of +// them drops the rarest. +const AGENT_HINTS: [Hint; 12] = [ + Hint::key("enter", "fold", KeyCode::Enter), + Hint::key("n", "new session", KeyCode::Char('n')), + Hint::key("o", "open…", KeyCode::Char('o')), + Hint::key("e", "exec", KeyCode::Char('e')), + Hint::key("f", "forward", KeyCode::Char('f')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("p", "provisioning", KeyCode::Char('p')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("x", "stop", KeyCode::Char('x')), + Hint::key("z", "all", KeyCode::Char('z')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +/// An Agent whose stop is not recorded yet: starting it now would cancel the stop. +const STOPPING_AGENT_HINTS: [Hint; 7] = [ + Hint::key("enter", "fold", KeyCode::Enter), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("p", "provisioning", KeyCode::Char('p')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("z", "all", KeyCode::Char('z')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +/// A stopped Agent runs nothing, so only what works without its Sandbox is offered. +const STOPPED_AGENT_HINTS: [Hint; 8] = [ + Hint::key("x", "start", KeyCode::Char('x')), + Hint::key("enter", "fold", KeyCode::Enter), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("p", "provisioning", KeyCode::Char('p')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("z", "all", KeyCode::Char('z')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const SESSION_HINTS: [Hint; 9] = [ + Hint::key("enter", "attach", KeyCode::Enter), + Hint::key("p", "prompt", KeyCode::Char('p')), + Hint::key("o", "open…", KeyCode::Char('o')), + Hint::key("a", "archive", KeyCode::Char('a')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("n", "new session", KeyCode::Char('n')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const ARCHIVED_SESSION_HINTS: [Hint; 7] = [ + Hint::key("a", "unarchive", KeyCode::Char('a')), + Hint::key("o", "open…", KeyCode::Char('o')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("n", "new session", KeyCode::Char('n')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +/// A stopped Agent's Session can only be put away or inspected until the Agent starts. +const STOPPED_SESSION_HINTS: [Hint; 5] = [ + Hint::key("a", "archive", KeyCode::Char('a')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const STOPPED_ARCHIVED_SESSION_HINTS: [Hint; 5] = [ + Hint::key("a", "unarchive", KeyCode::Char('a')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const EMPTY_HINTS: [Hint; 1] = [Hint::key("c", "new agent", KeyCode::Char('c'))]; + +/// Every hint set the tree shows for its selection. The footer is sized for +/// the widest, so moving the selection never moves the tree. +pub(crate) const SELECTION_HINTS: [&[Hint]; 8] = [ + &AGENT_HINTS, + &STOPPING_AGENT_HINTS, + &STOPPED_AGENT_HINTS, + &SESSION_HINTS, + &ARCHIVED_SESSION_HINTS, + &STOPPED_SESSION_HINTS, + &STOPPED_ARCHIVED_SESSION_HINTS, + &EMPTY_HINTS, +]; + +pub(crate) const HELP_HINTS: [Hint; 1] = [Hint::key("esc", "close", KeyCode::Esc)]; + +/// A titled group of keys in the help overlay. +pub(crate) type HelpSection = (&'static str, &'static [(&'static str, &'static str)]); + +/// Every key the terminal UI accepts, in the help overlay's two columns. +pub(crate) const HELP: [&[HelpSection]; 2] = [ + &[ + ( + "Fleet", + &[ + ("tab", "next Session needing you"), + ("j / k", "move"), + ("enter", "fold, or attach a Session"), + ("z", "fold or unfold all"), + ("A", "show or hide archived Sessions"), + ("/", "filter by name or state"), + ("c", "create an Agent"), + ("F", "port forwards"), + ("q", "quit"), + ], + ), + ( + "Views and forms", + &[ + ("j / k", "scroll a detail view"), + ("o", "open, in forwards"), + ("q / esc", "back, or close a form"), + ("ctrl-b d", "detach from a Session"), + ], + ), + ], + &[ + ( + "Selected Agent", + &[ + ("p", "follow provisioning"), + ("s / y", "describe, or show YAML"), + ("n", "new Session"), + ("o", "open in editor, desktop…"), + ("e", "shell in its Sandbox"), + ("f", "forward a port"), + ("x", "stop, or start"), + ("d", "delete"), + ], + ), + ( + "Selected Session", + &[ + ("p", "prompt without attaching"), + ("s / y", "describe, or show YAML"), + ("n", "new Session on its Agent"), + ("o", "open its Agent"), + ("a", "archive, or unarchive"), + ("d", "delete"), + ], + ), + ], +]; + +#[allow( + clippy::struct_excessive_bools, + reason = "independent display switches of one screen, not a state machine" +)] +pub(crate) struct App { + pub(crate) agents: Vec, + pub(crate) sessions: Vec, + pub(crate) groups: Vec, + pub(crate) rows: Vec, + pub(crate) collapsed: HashSet, + /// Shows only Agents and Sessions whose name, state, harness or model + /// contains it, ignoring case; an Agent that matches keeps all its Sessions. + pub(crate) filter: String, + /// Selected tree row, kept by identity so a snapshot that reorders or + /// reshapes the tree leaves it on the same Agent or Session. + pub(crate) selection: Option, + pub(crate) loaded: bool, + /// Why the daemon cannot be watched; the last state it reported stays shown. + pub(crate) connection_error: Option, + /// A failed action, shown until dismissed. + pub(crate) error: Option, + pub(crate) detail: Option, + pub(crate) modal: Option, + pub(crate) forwards: Vec, + pub(crate) view: View, + pub(crate) forward_selected: usize, + pub(crate) creating: usize, + /// Prompts being sent. + pub(crate) prompting: usize, + pub(crate) transcript: Option, + /// The Session whose turns are being loaded and its turn count when they + /// were requested; one load runs at a time. + turns_loading: Option<(String, SessionName, u64)>, + /// The terminal is wide enough for the panel beside the tree, which shows + /// the selected Session's turns or the selected Agent's status. + pub(crate) side_panel: bool, + /// Lists archived Sessions, which are hidden otherwise. + pub(crate) show_archived: bool, + /// The outcome of a Session change and when it was shown. + pub(crate) notice: Option<(String, Instant)>, + pub(crate) discovering: bool, + pub(crate) queued_candidates: Option>, + /// Editors and whether the terminal is remote, which the open menu depends on. + pub(crate) environment: Environment, + /// Whether OpenSSH reaches Agents through the generated configuration, as + /// it resolved the alias of the Agent checked last. + pub(crate) ssh_setup: SshSetup, + /// Whether the SSH setup is to be checked again once an Agent can be. + pub(crate) ssh_check_due: bool, + /// Whether an SSH setup check is running. + pub(crate) ssh_checking: bool, + /// The `Include` SSH setup adds, when the user's home is known. + pub(crate) ssh_include: Option, + /// Opens waiting in the background, at most one per Agent and target. + pub(crate) opening: Vec<(String, OpenTarget)>, +} + +/// Display state of one process-owned port forward. +pub(crate) struct ForwardEntry { + pub(crate) id: u64, + pub(crate) agent: String, + pub(crate) local: String, + pub(crate) guest_port: u16, + pub(crate) status: Option, + /// The forward stopped serving, so its address no longer works. + pub(crate) finished: bool, +} + +impl ForwardEntry { + /// Renders the mapping as `LOCAL:GUEST`, keeping a non-loopback address. + pub(crate) fn mapping(&self) -> String { + let local = self.local.strip_prefix("127.0.0.1:").unwrap_or(&self.local); + let mapping = format!("{local}:{}", self.guest_port); + match self.label() { + Some(label) => format!("{label} {mapping}"), + None => mapping, + } + } + + /// Names a forward to the desktop. + pub(crate) const fn label(&self) -> Option<&'static str> { + crate::launch::forward_label(self.guest_port) + } + + /// The address that opens the forward: a VNC client for the RFB port, a browser otherwise. + pub(crate) fn url(&self) -> String { + crate::launch::forward_url(&self.local, self.guest_port) + } +} + +/// Which main screen the TUI is showing. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum View { + Tree, + Forwards, +} + +pub(crate) struct Group { + pub(crate) agent: usize, + pub(crate) sessions: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Row { + Agent(usize), + Session { group: usize, position: usize }, +} + +pub(crate) struct Detail { + pub(crate) title: String, + pub(crate) lines: Vec, + pub(crate) scroll: usize, + /// Agent whose provisioning the detail follows; its lines are replaced as + /// progress arrives. + pub(crate) follows: Option, + /// Furthest scroll that still fills the view, recorded by the last draw, + /// which wraps long lines into more rows than `lines` has. + pub(crate) scroll_limit: Cell>, +} + +impl Detail { + /// Moves the scroll by `delta` rows within what the last draw can show. + /// Before the first draw, every line may start the view. + fn scroll_by(&mut self, delta: isize) { + let limit = self + .scroll_limit + .get() + .unwrap_or_else(|| self.lines.len().saturating_sub(1)); + self.scroll = offset_clamped(self.scroll.min(limit), limit, delta); + } + + pub(crate) const fn text(title: String, lines: Vec) -> Self { + Self { + title, + lines, + scroll: 0, + follows: None, + scroll_limit: Cell::new(None), + } + } + + fn provisioning(agent: String) -> Self { + Self { + title: format!("agent/{agent} provisioning"), + lines: vec!["Waiting for agentd…".to_owned()], + scroll: 0, + follows: Some(agent), + scroll_limit: Cell::new(None), + } + } +} + +pub(crate) enum Modal { + ConfirmDelete { + agent: String, + sessions: usize, + }, + ConfirmStop { + agent: String, + }, + ConfirmDeleteSession { + agent: String, + session: SessionName, + }, + NewSession(SessionForm), + CreateAgent(CreateForm), + PortForward(ForwardForm), + Filter, + Prompt(PromptForm), + Help, + Open(OpenMenu), + /// Asks before quitting closes the forwards this TUI holds open. + ConfirmQuit, + /// Asks before adding `include` to the user's OpenSSH configuration, then + /// opens `then` in `agent`. + ConfirmSshSetup { + agent: String, + include: agent::ssh::UserInclude, + then: Option, + }, +} + +/// A prompt for a running Session, sent without attaching to it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PromptForm { + pub(crate) agent: String, + pub(crate) session: SessionName, + pub(crate) input: String, + /// Why the last attempt to send the input failed. + pub(crate) error: Option, +} + +impl PromptForm { + /// Applies one key press; a submitted or cancelled form returns its Action. + fn key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter if self.input.trim().is_empty() => self.error = Some("type a prompt to send".into()), + KeyCode::Enter => return Some(Action::Prompt(self.clone())), + KeyCode::Backspace => { + self.input.pop(); + self.error = None; + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() && !character.is_control() => + { + self.input.push(character); + self.error = None; + } + _ => {} + } + None + } +} + +/// The selected Session's most recent turns, shown beside the tree. +pub(crate) struct Transcript { + pub(crate) agent: String, + pub(crate) session: SessionName, + /// The Session's turn count when the turns were last requested, if they + /// were; a change reloads them. + requested_at: Option, + pub(crate) turns: Vec, + pub(crate) loading: bool, + pub(crate) error: Option, + /// The Session's Agent is stopped, so its turns, which live in the guest, are not read. + pub(crate) stopped: bool, +} + +/// Text field of the new Session form that typing edits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SessionField { + Name, + Model, + Effort, +} + +impl SessionField { + const ORDER: [Self; 3] = [Self::Name, Self::Model, Self::Effort]; + + fn next(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + 1) % Self::ORDER.len()] + } + + fn previous(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + Self::ORDER.len() - 1) % Self::ORDER.len()] + } +} + +/// New Session form state: a name, optional model and effort, and a harness picker. +/// +/// Empty model and effort fields leave the choice to the daemon, which applies +/// the selected installation's manifest defaults and then the harness's own. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SessionForm { + pub(crate) agent: String, + pub(crate) name: String, + pub(crate) model: String, + pub(crate) effort: String, + pub(crate) field: SessionField, + pub(crate) harnesses: Vec, + pub(crate) harness: usize, + pub(crate) error: Option, +} + +impl SessionForm { + /// The installation the harness picker currently selects. + pub(crate) fn installation(&self) -> Option<&HarnessSpec> { + self.harnesses.get(self.harness) + } + + /// Manifest default that applies while the model field is empty. + pub(crate) fn model_default(&self) -> Option<&str> { + self.installation() + .and_then(|installation| installation.defaults.model_str()) + } + + /// Manifest default that applies while the effort field is empty. + pub(crate) fn effort_default(&self) -> Option<&str> { + self.installation() + .and_then(|installation| installation.defaults.effort_str()) + } + + /// Applies one key; `Some` closes the form with the returned action. + fn key(&mut self, key: KeyEvent, sessions: &[Session]) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter => match self.submit(sessions) { + Ok(action) => return Some(action), + Err(invalid) => self.error = Some(invalid), + }, + KeyCode::Tab | KeyCode::Down => self.field = self.field.next(), + KeyCode::BackTab | KeyCode::Up => self.field = self.field.previous(), + KeyCode::Right => self.harness = (self.harness + 1) % self.harnesses.len().max(1), + KeyCode::Left => { + self.harness = self + .harness + .checked_sub(1) + .unwrap_or_else(|| self.harnesses.len().saturating_sub(1)); + } + KeyCode::Backspace => { + self.value_mut().pop(); + self.error = None; + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() && self.accepts(character) => + { + self.value_mut().push(character); + self.error = None; + } + _ => {} + } + None + } + + /// Validates the form. An existing name is rejected, because ensuring it + /// would attach to that Session instead of creating one. + fn submit(&self, sessions: &[Session]) -> Result { + let session = SessionName::new(self.name.clone()).map_err(|invalid| invalid.to_string())?; + if sessions + .iter() + .any(|existing| existing.agent == self.agent && existing.name == session) + { + return Err(format!("session {:?} already exists", session.as_str())); + } + let Some(installation) = self.installation() else { + return Ok(Action::None); + }; + let model = (!self.model.is_empty()) + .then(|| Model::new(self.model.clone())) + .transpose() + .map_err(|invalid| invalid.to_string())?; + let effort = (!self.effort.is_empty()) + .then(|| Effort::new(self.effort.clone())) + .transpose() + .map_err(|invalid| invalid.to_string())?; + Ok(Action::CreateSession { + agent: self.agent.clone(), + session, + harness: installation.kind, + model_selection: ModelSelection { model, effort }, + }) + } + + const fn value_mut(&mut self) -> &mut String { + match self.field { + SessionField::Name => &mut self.name, + SessionField::Model => &mut self.model, + SessionField::Effort => &mut self.effort, + } + } + + /// Whether typing `character` into the focused field is accepted. The name + /// field admits only valid characters; a model or effort keeps whatever was + /// typed, so an invalid value is reported on submission instead of being + /// silently reshaped into a different valid one. + fn accepts(&self, character: char) -> bool { + match self.field { + SessionField::Name => { + (character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) && self.name.len() < 64 + } + SessionField::Model => !character.is_control() && self.model.chars().count() < 128, + SessionField::Effort => !character.is_control() && self.effort.chars().count() < 128, + } + } +} + +/// One manifest source offered by the create-agent picker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ManifestCandidate { + /// Full path of the manifest file. + pub(crate) path: PathBuf, + /// Decoded `metadata.name`, or why the manifest cannot be used. + pub(crate) name: Result, + /// Other path spellings discovered for the same canonical file. + equivalent_paths: Vec, +} + +impl ManifestCandidate { + pub(crate) const fn new(path: PathBuf, name: Result) -> Self { + Self { + path, + name, + equivalent_paths: Vec::new(), + } + } + + pub(crate) fn add_equivalent_path(&mut self, path: PathBuf) { + if self.path != path && !self.equivalent_paths.contains(&path) { + self.equivalent_paths.push(path); + } + } + + fn matches_path(&self, path: &Path) -> bool { + self.path == path || self.equivalent_paths.iter().any(|candidate| candidate == path) + } +} + +/// One Agent's default manifest and variant leaves. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AgentDefinition { + /// Directory containing the Agent's `agent.yaml`. + pub(crate) directory: PathBuf, + /// Manifest leaves in picker order, with `agent.yaml` first. + pub(crate) variants: Vec, +} + +impl AgentDefinition { + /// User-facing Agent label, taken from the expanded default when possible. + pub(crate) fn label(&self) -> String { + self.variants + .iter() + .find(|candidate| { + candidate + .path + .file_name() + .is_some_and(|name| name == agent::manifest::MANIFEST_FILE) + }) + .or_else(|| self.variants.first()) + .and_then(|candidate| candidate.name.as_ref().ok()) + .cloned() + .or_else(|| { + self.directory + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| self.directory.display().to_string()) + } +} + +/// Focused field of the create-Agent form. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CreateField { + Agent, + Variant, + Name, + EnvironmentFile, +} + +impl CreateField { + const ORDER: [Self; 4] = [Self::Agent, Self::Variant, Self::Name, Self::EnvironmentFile]; + + fn next(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + 1) % Self::ORDER.len()] + } + + fn previous(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + Self::ORDER.len() - 1) % Self::ORDER.len()] + } +} + +/// Create-agent form state: independent Agent and variant pickers plus apply overrides. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CreateForm { + pub(crate) agents: Vec, + pub(crate) agent: usize, + pub(crate) variant: usize, + pub(crate) field: CreateField, + pub(crate) name: String, + pub(crate) env_file: String, + pub(crate) error: Option, +} + +impl CreateForm { + /// Groups discovered leaves by sibling directory and preselects exact provenance. + pub(crate) fn new(candidates: Vec, selected_path: Option<&Path>) -> Self { + let mut agents = Vec::::new(); + for candidate in candidates { + let directory = candidate.path.parent().unwrap_or_else(|| Path::new("")).to_path_buf(); + if let Some(agent) = agents.iter_mut().find(|agent| agent.directory == directory) { + agent.variants.push(candidate); + } else { + agents.push(AgentDefinition { + directory, + variants: vec![candidate], + }); + } + } + for agent in &mut agents { + agent.variants.sort_by_key(|candidate| { + ( + candidate + .path + .file_name() + .is_none_or(|name| name != agent::manifest::MANIFEST_FILE), + candidate.path.clone(), + ) + }); + } + let selected = selected_path.and_then(|selected| { + agents.iter().enumerate().find_map(|(agent_index, agent)| { + agent + .variants + .iter() + .position(|candidate| candidate.matches_path(selected)) + .map(|variant| (agent_index, variant)) + }) + }); + let (agent, variant) = selected.unwrap_or_default(); + Self { + agents, + agent, + variant, + field: CreateField::Agent, + name: String::new(), + env_file: String::new(), + error: None, + } + } + + pub(crate) fn agent(&self) -> Option<&AgentDefinition> { + self.agents.get(self.agent) + } + + pub(crate) fn candidate(&self) -> Option<&ManifestCandidate> { + self.agent()?.variants.get(self.variant) + } + + pub(crate) fn variant_label(&self) -> Option { + let path = &self.candidate()?.path; + if path + .file_name() + .is_some_and(|name| name == agent::manifest::MANIFEST_FILE) + { + Some("default".into()) + } else { + agent::manifest::variant_from_filename(path) + .map(String::from) + .or_else(|| path.file_name().map(|name| name.to_string_lossy().into_owned())) + } + } + + /// Returns the selected manifest's name, shown grayed while nothing is typed. + pub(crate) fn placeholder(&self) -> Option<&str> { + self.candidate()?.name.as_deref().ok() + } + + /// Applies one key press; a submitted or cancelled form returns its Action. + fn key(&mut self, key: KeyEvent, agents: &[Agent]) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter => match self.submission(agents) { + Ok(action) => return Some(action), + Err(invalid) => self.error = Some(invalid), + }, + KeyCode::Tab | KeyCode::Down => { + self.field = self.field.next(); + self.error = None; + } + KeyCode::BackTab | KeyCode::Up => { + self.field = self.field.previous(); + self.error = None; + } + KeyCode::Right => self.select(1), + KeyCode::Left => self.select(-1), + KeyCode::Backspace if matches!(self.field, CreateField::Name | CreateField::EnvironmentFile) => { + match self.field { + CreateField::Name => { + self.name.pop(); + } + CreateField::EnvironmentFile => { + self.env_file.pop(); + } + CreateField::Agent | CreateField::Variant => {} + } + self.error = None; + } + KeyCode::Char(character) + if self.field == CreateField::Name + && key.modifiers.difference(KeyModifiers::SHIFT).is_empty() + && ::sandbox::SandboxName::accepts(character) + && self.name.len() < ::sandbox::MAX_SANDBOX_NAME_BYTES => + { + self.name.push(character); + self.error = None; + } + KeyCode::Char(character) + if self.field == CreateField::EnvironmentFile + && key.modifiers.difference(KeyModifiers::SHIFT).is_empty() + && !character.is_control() + && self.env_file.len() < 4096 => + { + self.env_file.push(character); + self.error = None; + } + _ => {} + } + None + } + + fn select(&mut self, delta: isize) { + match self.field { + CreateField::Agent => { + self.agent = wrapped_index(self.agent, self.agents.len(), delta); + self.variant = 0; + } + CreateField::Variant => { + let length = self.agent().map_or(0, |agent| agent.variants.len()); + self.variant = wrapped_index(self.variant, length, delta); + } + CreateField::Name | CreateField::EnvironmentFile => return, + } + self.error = None; + } + + fn submission(&self, agents: &[Agent]) -> Result { + let candidate = self + .candidate() + .ok_or_else(|| "no Agent manifests found; apply one with agentctl apply".to_owned())?; + let manifest_name = candidate.name.as_ref().map_err(Clone::clone)?; + let name = if self.name.is_empty() { + manifest_name.clone() + } else { + self.name.clone() + }; + ::sandbox::SandboxName::new(name.clone()).map_err(|invalid| format!("name: {invalid}"))?; + if agents.iter().any(|agent| agent.metadata.name == name) { + return Err(format!("agent {name:?} already exists")); + } + Ok(Action::CreateAgent { + manifest: candidate.path.clone(), + name, + env_file: (!self.env_file.is_empty()).then(|| PathBuf::from(&self.env_file)), + form: self.clone(), + }) + } +} + +fn wrapped_index(current: usize, length: usize, delta: isize) -> usize { + if length == 0 { + return 0; + } + let length = isize::try_from(length).unwrap_or(1); + let current = isize::try_from(current).unwrap_or_default(); + usize::try_from((current + delta).rem_euclid(length)).unwrap_or_default() +} + +/// k9s-style port-forward form state. +pub(crate) struct ForwardForm { + pub(crate) agent: String, + pub(crate) address: String, + pub(crate) local: String, + pub(crate) guest: String, + pub(crate) field: ForwardField, + pub(crate) error: Option, + pub(crate) replace: Option, +} + +impl ForwardForm { + /// Reopens the form for a mapping the runtime rejected, keeping its values. + pub(crate) fn rejected(agent: String, spec: &ForwardSpec, replace: Option, error: String) -> Self { + Self { + agent, + address: spec.address.to_string(), + local: if spec.local_port == 0 { + String::new() + } else { + spec.local_port.to_string() + }, + guest: spec.guest_port.to_string(), + field: ForwardField::Address, + error: Some(bind_hint(spec, error)), + replace, + } + } + + /// Applies one key press; a submitted or cancelled form returns its Action. + fn key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter => { + let local = if self.local.is_empty() { + &self.guest + } else { + &self.local + }; + match ForwardSpec::parse(&format!("{}:{local}:{}", self.address, self.guest)) { + Ok(spec) => { + return Some(Action::CreateForward { + agent: self.agent.clone(), + spec, + replace: self.replace, + }); + } + Err(invalid) => self.error = Some(invalid), + } + } + KeyCode::Tab | KeyCode::Down => self.field = self.field.next(), + KeyCode::BackTab | KeyCode::Up => self.field = self.field.previous(), + KeyCode::Backspace => { + self.field_text().pop(); + self.error = None; + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() + && forward_field_accepts(self.field, character) => + { + let text = self.field_text(); + if text.len() < 45 { + text.push(character); + self.error = None; + } + } + _ => {} + } + None + } + + const fn field_text(&mut self) -> &mut String { + match self.field { + ForwardField::Address => &mut self.address, + ForwardField::LocalPort => &mut self.local, + ForwardField::GuestPort => &mut self.guest, + } + } +} + +/// One editable field of the port-forward form. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ForwardField { + Address, + LocalPort, + GuestPort, +} + +/// A semantic interaction emitted by the renderer's hit map. +/// +/// Mouse input uses these instead of terminal coordinates so layout remains +/// entirely owned by the renderer. Keyboard-shaped controls deliberately flow +/// back through `on_key` to keep both input methods equivalent. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum MouseAction { + Key(KeyCode, KeyModifiers), + Select(RowTarget), + Primary(RowTarget), + FoldTree(String), + MoveTree(isize), + MoveForward(isize), + ScrollDetail(isize), + FocusSessionField(SessionField), + SelectHarness(usize), + FocusCreateField(CreateField), + SelectCreate { + field: CreateField, + delta: isize, + }, + FocusForwardField(ForwardField), + /// Chooses the open menu's item at this index. + ChooseOpen(usize), +} + +/// A rendered row whose selection is owned by the application. +/// +/// Targets name the resource rather than its position, so a click acts on the +/// row that was drawn even when a newer snapshot has moved it since. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RowTarget { + Tree(TreeRowId), + /// A port forward by its stable ID. + Forward(u64), +} + +/// Identity of one tree row. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TreeRowId { + Agent(String), + Session { agent: String, session: SessionName }, +} + +impl ForwardField { + const fn next(self) -> Self { + match self { + Self::Address => Self::LocalPort, + Self::LocalPort => Self::GuestPort, + Self::GuestPort => Self::Address, + } + } + + const fn previous(self) -> Self { + match self { + Self::Address => Self::GuestPort, + Self::LocalPort => Self::Address, + Self::GuestPort => Self::LocalPort, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum Action { + None, + Quit, + Attach { + agent: String, + session: SessionName, + }, + CreateSession { + agent: String, + session: SessionName, + harness: Harness, + model_selection: ModelSelection, + }, + OpenCreate, + CreateAgent { + manifest: PathBuf, + name: String, + env_file: Option, + form: CreateForm, + }, + Exec { + agent: String, + }, + Prompt(PromptForm), + Delete { + agent: String, + }, + SetRunState { + agent: String, + state: RunState, + }, + DeleteSession { + agent: String, + session: SessionName, + }, + SetArchived { + agent: String, + session: SessionName, + archived: bool, + }, + CreateForward { + agent: String, + spec: ForwardSpec, + replace: Option, + }, + DeleteForward { + id: u64, + }, + Open { + agent: String, + target: OpenTarget, + }, + /// Adds `include`, then opens `then`. + SetUpSsh { + include: agent::ssh::UserInclude, + then: Option<(String, OpenTarget)>, + }, + /// Opens an address on this machine. + OpenUrl(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Tone { + Green, + Yellow, + Cyan, + Gray, + Red, +} + +/// One tree row as the renderer draws it. +pub(crate) struct RowView { + pub(crate) agent: bool, + /// A Session waits for input, or an Agent has one that does. + pub(crate) attention: bool, + /// An Agent's fold marker or a Session's state glyph, readable without color. + pub(crate) marker: &'static str, + pub(crate) name: String, + pub(crate) state: &'static str, + pub(crate) tone: Tone, + /// How long the row has been in its state, when known. + pub(crate) since: String, + pub(crate) detail: String, + /// The detail is a failure message; when it does not fit, its end, where + /// the cause is, is kept. + pub(crate) detail_keeps_end: bool, + pub(crate) age: String, +} + +/// Sessions by state and Agents provisioning, for the header. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct TriageCounts { + pub(crate) needs_you: usize, + pub(crate) working: usize, + pub(crate) starting: usize, + pub(crate) idle: usize, + pub(crate) failed: usize, + pub(crate) provisioning: usize, + pub(crate) archived: usize, +} + +impl App { + pub(crate) fn new() -> Self { + Self { + agents: Vec::new(), + sessions: Vec::new(), + groups: Vec::new(), + rows: Vec::new(), + collapsed: HashSet::new(), + filter: String::new(), + selection: None, + loaded: false, + connection_error: None, + error: None, + detail: None, + modal: None, + forwards: Vec::new(), + view: View::Tree, + forward_selected: 0, + creating: 0, + prompting: 0, + transcript: None, + turns_loading: None, + side_panel: false, + show_archived: false, + notice: None, + discovering: false, + queued_candidates: None, + environment: Environment::default(), + ssh_setup: SshSetup::Unknown, + ssh_check_due: true, + ssh_checking: false, + ssh_include: None, + opening: Vec::new(), + } + } + + pub(crate) fn apply_snapshot(&mut self, mut agents: Vec, mut sessions: Vec) { + agents.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); + sessions.sort_by(|left, right| left.agent.cmp(&right.agent).then_with(|| left.name.cmp(&right.name))); + // The selection's position is read before the rows it indexes are replaced. + let fallback = self.selected_index().unwrap_or_default(); + self.agents = agents; + self.sessions = sessions; + self.loaded = true; + self.rebuild_from(fallback); + } + + /// Rebuilds the tree. A selected Session hidden by folding its Agent leaves + /// the selection on that Agent; any other selection whose row disappeared + /// falls back to the row now at its former position. + pub(crate) fn rebuild(&mut self) { + self.rebuild_from(self.selected_index().unwrap_or_default()); + } + + fn rebuild_from(&mut self, fallback: usize) { + self.groups = self + .agents + .iter() + .enumerate() + .map(|(index, agent)| Group { + agent: index, + sessions: self + .sessions + .iter() + .enumerate() + .filter(|(_, session)| session.agent == agent.metadata.name) + .map(|(session_index, _)| session_index) + .collect(), + }) + .collect(); + self.rows = self + .groups + .iter() + .enumerate() + .flat_map(|(group_index, group)| { + let Some(agent) = self.agents.get(group.agent) else { + return Vec::new(); + }; + let agent_matches = self.agent_matches(agent); + let sessions = self.listed_positions(group, agent_matches); + if !agent_matches && sessions.is_empty() { + return Vec::new(); + } + let mut rows = vec![Row::Agent(group_index)]; + // Folding hides Sessions only while nothing is filtered, so a match is never hidden. + if self.filter.is_empty() && self.collapsed.contains(&agent.metadata.name) { + return rows; + } + rows.extend(sessions.into_iter().map(|position| Row::Session { + group: group_index, + position, + })); + rows + }) + .collect(); + if self.selected_index().is_none() { + let agent = match &self.selection { + Some(TreeRowId::Session { agent, session }) => self.session_or_agent_near(agent, session), + _ => None, + }; + self.selection = agent.or_else(|| self.tree_id_at(fallback.min(self.rows.len().saturating_sub(1)))); + } + } + + /// Where the selection goes when its Session's row disappeared: the listed + /// Session of the same Agent that follows it by name, the one before it + /// when it was the last, or else the Agent. Archiving or deleting one + /// Session after another then needs no move between them, however many + /// rows a snapshot, the filter or showing archived Sessions removed. + fn session_or_agent_near(&self, agent: &str, name: &SessionName) -> Option { + let listed = (0..self.rows.len()) + .filter_map(|index| match self.tree_id_at(index)? { + TreeRowId::Session { agent: owner, session } if owner == agent => Some(session), + _ => None, + }) + .collect::>(); + listed + .iter() + .find(|session| *session > name) + .or_else(|| listed.iter().rev().find(|session| *session < name)) + .map(|session| TreeRowId::Session { + agent: agent.to_owned(), + session: session.clone(), + }) + .or_else(|| Some(TreeRowId::Agent(agent.to_owned())).filter(|id| self.tree_index(id).is_some())) + } + + fn agent_matches(&self, agent: &Agent) -> bool { + self.matches(&agent.metadata.name) || self.matches(agent_state(agent).label) + } + + /// Positions of the group's Sessions the tree lists, folded or not: the + /// shown ones, and while filtered, those matching or of a matching Agent. + fn listed_positions(&self, group: &Group, agent_matches: bool) -> Vec { + (0..group.sessions.len()) + .filter(|position| { + self.sessions + .get(group.sessions[*position]) + .is_some_and(|session| self.lists(session) && (agent_matches || self.session_matches(session))) + }) + .collect() + } + + /// Whether the tree lists this Session when nothing is filtered or folded. + const fn lists(&self, session: &Session) -> bool { + self.show_archived || !session.is_archived() + } + + fn matches(&self, value: &str) -> bool { + value.to_lowercase().contains(&self.filter.to_lowercase()) + } + + fn session_matches(&self, session: &Session) -> bool { + let (_, _, state) = session_state(session.status.state); + [ + session.name.as_str(), + state, + harness_label(session.harness), + session.model_selection.model_str().unwrap_or_default(), + ] + .into_iter() + .any(|value| self.matches(value)) + } + + /// Selects the next Session waiting for input in tree order, after the + /// selection and wrapping around. The header counts every such Session, so + /// one in a folded Agent is unfolded and one the filter hides clears it. + /// An archived Session is neither counted nor selected, as nobody answers it. + fn select_next_needing_input(&mut self) { + let order = self + .groups + .iter() + .filter_map(|group| { + let agent = self.agents.get(group.agent)?; + let sessions = group.sessions.iter().filter_map(|index| self.sessions.get(*index)); + Some( + std::iter::once((TreeRowId::Agent(agent.metadata.name.clone()), None)).chain(sessions.map( + |session| { + ( + TreeRowId::Session { + agent: session.agent.clone(), + session: session.name.clone(), + }, + Some(session), + ) + }, + )), + ) + }) + .flatten() + .collect::>(); + let start = self + .selection + .as_ref() + .and_then(|selection| order.iter().position(|(id, _)| id == selection)) + .map_or(0, |index| index + 1); + let Some((target, session)) = (0..order.len()) + .map(|step| &order[(start + step) % order.len()]) + .find(|(_, session)| session.is_some_and(needs_you)) + else { + return; + }; + if let Some(session) = session { + if !self.session_matches(session) && !self.matches(&session.agent) { + self.filter.clear(); + } + self.collapsed.remove(&session.agent); + } + self.selection = Some(target.clone()); + self.rebuild(); + } + + pub(crate) fn selected_row(&self) -> Option { + self.rows.get(self.selected_index()?).copied() + } + + pub(crate) fn selected_index(&self) -> Option { + self.tree_index(self.selection.as_ref()?) + } + + /// Identity of the row at `index`. + pub(crate) fn tree_id_at(&self, index: usize) -> Option { + match *self.rows.get(index)? { + Row::Agent(group) => Some(TreeRowId::Agent(self.group_agent(group)?.metadata.name.clone())), + Row::Session { group, position } => { + let session = self.group_session(group, position)?; + Some(TreeRowId::Session { + agent: session.agent.clone(), + session: session.name.clone(), + }) + } + } + } + + fn tree_index(&self, target: &TreeRowId) -> Option { + (0..self.rows.len()).find(|index| self.tree_id_at(*index).as_ref() == Some(target)) + } + + pub(crate) fn select_index(&mut self, index: usize) { + if let Some(target) = self.tree_id_at(index) { + self.selection = Some(target); + } + } + + pub(crate) fn triage_counts(&self) -> TriageCounts { + let mut counts = TriageCounts { + provisioning: self + .agents + .iter() + .filter(|agent| agent_state(agent).label == "Provisioning") + .count(), + ..TriageCounts::default() + }; + for session in &self.sessions { + let count = match session.status.state { + State::WaitingForInput => &mut counts.needs_you, + State::Working => &mut counts.working, + State::Starting => &mut counts.starting, + State::Idle => &mut counts.idle, + State::Failed => &mut counts.failed, + State::Archiving | State::Archived => &mut counts.archived, + }; + *count += 1; + } + counts + } + + pub(crate) const fn idle(&self) -> bool { + self.modal.is_none() && self.detail.is_none() + } + + pub(crate) fn on_key(&mut self, key: KeyEvent) -> Action { + if self.modal.is_some() { + return self.modal_key(key); + } + if self.detail.is_some() { + self.detail_key(key); + return Action::None; + } + if self.error.is_some() { + return self.error_key(key); + } + if self.view == View::Forwards { + return self.forwards_key(key); + } + self.main_key(key) + } + + /// The error screen renders over the tree or forwards view until dismissed. + fn error_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc | KeyCode::Enter => self.error = None, + KeyCode::Char('q') => return self.quit(), + _ => {} + } + Action::None + } + + /// Quits, asking first while forwards would close with the TUI. + fn quit(&mut self) -> Action { + if self.forwards.is_empty() { + return Action::Quit; + } + self.modal = Some(Modal::ConfirmQuit); + Action::None + } + + pub(crate) fn on_mouse(&mut self, action: MouseAction) -> Action { + match action { + MouseAction::Key(code, modifiers) => self.on_key(KeyEvent::new(code, modifiers)), + MouseAction::Select(target) => { + self.select_row(&target); + Action::None + } + MouseAction::Primary(target) => { + if !self.select_row(&target) { + return Action::None; + } + let code = match target { + RowTarget::Tree(_) => KeyCode::Enter, + RowTarget::Forward(_) => KeyCode::Char('e'), + }; + self.on_key(KeyEvent::new(code, KeyModifiers::NONE)) + } + MouseAction::FoldTree(agent) => { + if !self.select_row(&RowTarget::Tree(TreeRowId::Agent(agent))) { + return Action::None; + } + self.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + } + MouseAction::MoveTree(delta) => { + self.move_selection_clamped(delta); + Action::None + } + MouseAction::MoveForward(delta) => { + self.move_forward_selection_clamped(delta); + Action::None + } + MouseAction::ScrollDetail(delta) => { + let Some(detail) = self.detail.as_mut() else { + return Action::None; + }; + detail.scroll_by(delta); + Action::None + } + MouseAction::FocusSessionField(field) => { + if let Some(Modal::NewSession(form)) = &mut self.modal { + form.field = field; + } + Action::None + } + MouseAction::SelectHarness(index) => { + if let Some(Modal::NewSession(form)) = &mut self.modal + && index < form.harnesses.len() + { + form.harness = index; + } + Action::None + } + MouseAction::FocusCreateField(field) => { + if let Some(Modal::CreateAgent(form)) = &mut self.modal { + form.field = field; + form.error = None; + } + Action::None + } + MouseAction::SelectCreate { field, delta } => { + if let Some(Modal::CreateAgent(form)) = &mut self.modal { + form.field = field; + form.select(delta); + } + Action::None + } + MouseAction::FocusForwardField(field) => { + if let Some(Modal::PortForward(form)) = &mut self.modal { + form.field = field; + } + Action::None + } + MouseAction::ChooseOpen(index) => { + let Some(Modal::Open(menu)) = self.modal.take() else { + return Action::None; + }; + let chosen = menu + .items + .get(index) + .filter(|item| item.unavailable.is_none()) + .map(|item| item.entry); + if let Some(entry) = chosen { + return self.choose(menu.agent, entry); + } + self.modal = Some(Modal::Open(menu)); + Action::None + } + } + } + + /// Selects the targeted row; `false` when it no longer exists. + fn select_row(&mut self, target: &RowTarget) -> bool { + match target { + RowTarget::Tree(target) => { + if self.tree_index(target).is_none() { + return false; + } + self.selection = Some(target.clone()); + } + RowTarget::Forward(id) => { + let Some(index) = self.forwards.iter().position(|entry| entry.id == *id) else { + return false; + }; + self.forward_selected = index; + } + } + true + } + + fn main_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc if !self.filter.is_empty() => { + self.filter.clear(); + self.rebuild(); + } + KeyCode::Esc | KeyCode::Char('q') => return self.quit(), + KeyCode::Char('/') => self.modal = Some(Modal::Filter), + KeyCode::Char('?') => self.modal = Some(Modal::Help), + KeyCode::Tab => self.select_next_needing_input(), + KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), + KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), + KeyCode::Char('z') => self.toggle_all(), + KeyCode::Char('A') => { + self.show_archived = !self.show_archived; + // Showing or hiding archived Sessions answers a notice about + // archiving, "A to show" included; any other is short-lived. + self.notice = None; + self.rebuild(); + } + KeyCode::Char('F') => self.view = View::Forwards, + KeyCode::Char('c') => return Action::OpenCreate, + _ => { + return match self.selected_row() { + Some(Row::Agent(group)) => self.agent_key(key, group), + Some(Row::Session { group, position }) => self.session_key(key, group, position), + None => Action::None, + }; + } + } + Action::None + } + + fn forwards_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc | KeyCode::Char('q' | 'F') => self.view = View::Tree, + KeyCode::Char('?') => self.modal = Some(Modal::Help), + KeyCode::Down | KeyCode::Char('j') => self.move_forward_selection(1), + KeyCode::Up | KeyCode::Char('k') => self.move_forward_selection(-1), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(entry) = self.forwards.get(self.forward_selected) { + return Action::DeleteForward { id: entry.id }; + } + } + KeyCode::Char('o') => { + if let Some(entry) = self.forwards.get(self.forward_selected) { + return Action::OpenUrl(entry.url()); + } + } + KeyCode::Char('e') => { + if let Some(entry) = self.forwards.get(self.forward_selected) { + let (address, local) = entry + .local + .rsplit_once(':') + .map_or((String::new(), String::new()), |(address, local)| { + (address.to_owned(), local.to_owned()) + }); + self.modal = Some(Modal::PortForward(ForwardForm { + agent: entry.agent.clone(), + address, + local, + guest: entry.guest_port.to_string(), + field: ForwardField::LocalPort, + error: None, + replace: Some(entry.id), + })); + } + } + _ => {} + } + Action::None + } + + fn agent_key(&mut self, key: KeyEvent, group: usize) -> Action { + let Some(agent) = self.group_agent(group) else { + return Action::None; + }; + let name = agent.metadata.name.clone(); + // Nothing runs in a stopped Agent until it is started. + if agent.spec.is_stopped() && matches!(key.code, KeyCode::Char('n' | 'o' | 'e' | 'f')) { + return Action::None; + } + match key.code { + KeyCode::Enter | KeyCode::Char(' ') => self.toggle_fold(&name), + KeyCode::Right => { + if self.collapsed.remove(&name) { + self.rebuild(); + } + } + KeyCode::Left => { + if self.collapsed.insert(name) { + self.rebuild(); + } + } + KeyCode::Char('p') => self.detail = Some(Detail::provisioning(name)), + KeyCode::Char('s') => { + self.detail = Some(Detail::text( + format!("agent/{name}"), + format::describe_agent_lines(agent), + )); + } + KeyCode::Char('y') => self.detail = Some(Detail::text(format!("agent/{name} yaml"), yaml_lines(agent))), + KeyCode::Char('d') => { + let sessions = self.groups.get(group).map_or(0, |group| group.sessions.len()); + self.modal = Some(Modal::ConfirmDelete { agent: name, sessions }); + } + // Starting before the stop is recorded would cancel it, so wait for it. + KeyCode::Char('x') if stop_pending(agent) => {} + KeyCode::Char('x') if agent.spec.is_stopped() => { + return Action::SetRunState { + agent: name, + state: RunState::Running, + }; + } + KeyCode::Char('x') => self.modal = Some(Modal::ConfirmStop { agent: name }), + KeyCode::Char('n') => self.open_new_session(group), + KeyCode::Char('o') => self.open_menu(&name), + KeyCode::Char('e') => return Action::Exec { agent: name }, + KeyCode::Char('f') => { + self.modal = Some(Modal::PortForward(ForwardForm { + agent: name, + address: "127.0.0.1".into(), + local: String::new(), + guest: String::new(), + field: ForwardField::GuestPort, + error: None, + replace: None, + })); + } + _ => {} + } + Action::None + } + + fn session_key(&mut self, key: KeyEvent, group: usize, position: usize) -> Action { + let Some(session) = self.group_session(group, position) else { + return Action::None; + }; + // An archived Session cannot be attached or prompted until it is unarchived. + if session.is_archived() && matches!(key.code, KeyCode::Enter | KeyCode::Char('p')) { + return Action::None; + } + // Nothing runs in a stopped Agent until it is started. + if self.group_agent(group).is_some_and(|agent| agent.spec.is_stopped()) + && matches!(key.code, KeyCode::Enter | KeyCode::Char('p' | 'n' | 'o')) + { + return Action::None; + } + match key.code { + KeyCode::Enter => { + return Action::Attach { + agent: session.agent.clone(), + session: session.name.clone(), + }; + } + KeyCode::Left => { + let agent = session.agent.clone(); + self.collapsed.insert(agent.clone()); + self.selection = Some(TreeRowId::Agent(agent)); + self.rebuild(); + } + KeyCode::Char('s') => self.detail = Some(session_detail(session)), + KeyCode::Char('y') => { + self.detail = Some(Detail::text( + format!("session/{}/{} yaml", session.agent, session.name.as_str()), + yaml_lines(session), + )); + } + KeyCode::Char('a') => { + return Action::SetArchived { + agent: session.agent.clone(), + session: session.name.clone(), + archived: !session.is_archived(), + }; + } + KeyCode::Char('d') => { + self.modal = Some(Modal::ConfirmDeleteSession { + agent: session.agent.clone(), + session: session.name.clone(), + }); + } + KeyCode::Char('n') => self.open_new_session(group), + KeyCode::Char('o') => { + let agent = session.agent.clone(); + self.open_menu(&agent); + } + KeyCode::Char('p') => { + self.modal = Some(Modal::Prompt(PromptForm { + agent: session.agent.clone(), + session: session.name.clone(), + input: String::new(), + error: None, + })); + } + _ => {} + } + Action::None + } + + fn detail_key(&mut self, key: KeyEvent) { + let Some(detail) = self.detail.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.detail = None, + KeyCode::Down | KeyCode::Char('j') => detail.scroll_by(1), + KeyCode::Up | KeyCode::Char('k') => detail.scroll_by(-1), + KeyCode::PageDown => detail.scroll_by(10), + KeyCode::PageUp => detail.scroll_by(-10), + _ => {} + } + } + + fn modal_key(&mut self, key: KeyEvent) -> Action { + match self.modal.take() { + Some(Modal::Help) => { + if !matches!(key.code, KeyCode::Esc | KeyCode::Char('q' | '?')) { + self.modal = Some(Modal::Help); + } + Action::None + } + Some(Modal::ConfirmDelete { agent, sessions }) => match key.code { + KeyCode::Char('y') => Action::Delete { agent }, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmDelete { agent, sessions }); + Action::None + } + }, + Some(Modal::ConfirmStop { agent }) => match key.code { + KeyCode::Char('y') => Action::SetRunState { + agent, + state: RunState::Stopped, + }, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmStop { agent }); + Action::None + } + }, + Some(Modal::ConfirmDeleteSession { agent, session }) => match key.code { + KeyCode::Char('y') => Action::DeleteSession { agent, session }, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmDeleteSession { agent, session }); + Action::None + } + }, + Some(Modal::NewSession(mut form)) => { + if let Some(action) = form.key(key, &self.sessions) { + return action; + } + self.modal = Some(Modal::NewSession(form)); + Action::None + } + Some(Modal::CreateAgent(mut form)) => { + if let Some(action) = form.key(key, &self.agents) { + return action; + } + self.modal = Some(Modal::CreateAgent(form)); + Action::None + } + Some(Modal::PortForward(mut form)) => { + if let Some(action) = form.key(key) { + return action; + } + self.modal = Some(Modal::PortForward(form)); + Action::None + } + Some(Modal::Prompt(mut form)) => { + if let Some(action) = form.key(key) { + return action; + } + self.modal = Some(Modal::Prompt(form)); + Action::None + } + Some(Modal::Open(menu)) => self.open_menu_key(menu, key), + Some(Modal::ConfirmQuit) => match key.code { + KeyCode::Char('y') => Action::Quit, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmQuit); + Action::None + } + }, + Some(Modal::ConfirmSshSetup { agent, include, then }) => { + self.confirm_ssh_setup_key(agent, include, then, key) + } + Some(Modal::Filter) => { + match key.code { + KeyCode::Enter => return Action::None, + KeyCode::Esc => { + self.filter.clear(); + self.rebuild(); + return Action::None; + } + KeyCode::Backspace => { + self.filter.pop(); + self.rebuild(); + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() && !character.is_control() => + { + self.filter.push(character); + self.rebuild(); + } + _ => {} + } + self.modal = Some(Modal::Filter); + Action::None + } + None => Action::None, + } + } + + /// Opens the create-agent modal for finished discovery, or queues the + /// candidates while another view is open. + pub(crate) fn manifests_discovered(&mut self, candidates: Vec) { + if !std::mem::take(&mut self.discovering) { + return; + } + if self.idle() { + self.open_create(candidates); + } else { + self.queued_candidates = Some(candidates); + } + } + + /// Opens the create-agent modal for candidates queued behind another view once it closes. + pub(crate) fn open_queued_create(&mut self) { + if self.idle() + && let Some(candidates) = self.queued_candidates.take() + { + self.open_create(candidates); + } + } + + /// Opens the create-agent modal, preselecting the highlighted Agent's manifest. + pub(crate) fn open_create(&mut self, candidates: Vec) { + let manifest = match self.selected_row() { + Some(Row::Agent(group) | Row::Session { group, .. }) => self + .group_agent(group) + .and_then(|agent| agent.status.provenance.as_ref()) + .map(agent::Provenance::manifest_or_default), + None => None, + }; + self.modal = Some(Modal::CreateAgent(CreateForm::new(candidates, manifest.as_deref()))); + } + + /// Shows an Agent this TUI just created, ahead of the watch reply that will + /// report it, selects it and follows its provisioning. + pub(crate) fn agent_applied(&mut self, agent: Agent) { + let name = agent.metadata.name.clone(); + let mut agents = std::mem::take(&mut self.agents); + agents.retain(|existing| existing.metadata.name != name); + agents.push(agent); + let sessions = std::mem::take(&mut self.sessions); + self.apply_snapshot(agents, sessions); + self.selection = Some(TreeRowId::Agent(name.clone())); + self.detail = Some(Detail::provisioning(name)); + } + + /// The selected Session whose turns need loading: newly selected, or with + /// more turns than when they were last requested. + /// The selected Session whose turns the side panel needs loaded, if any. + /// + /// Turns are read from the Session's Sandbox, so they load only while the + /// panel is shown, one load at a time: moving through Sessions loads the one + /// the selection rests on. They reload when the Session finishes a turn. + pub(crate) fn transcript_request(&mut self) -> Option<(String, SessionName)> { + let Some(TreeRowId::Session { agent, session }) = self.selection.as_ref().filter(|_| self.side_panel) else { + self.transcript = None; + return None; + }; + let turns = self + .sessions + .iter() + .find(|candidate| candidate.agent == *agent && candidate.name == *session) + .map_or(0, |session| session.status.reported.activity.turns); + let stopped = self + .agents + .iter() + .any(|candidate| candidate.metadata.name == *agent && candidate.spec.is_stopped()); + let transcript = match &mut self.transcript { + Some(transcript) if transcript.agent == *agent && transcript.session == *session => transcript, + _ => self.transcript.insert(Transcript { + agent: agent.clone(), + session: session.clone(), + requested_at: None, + turns: Vec::new(), + loading: true, + error: None, + stopped, + }), + }; + transcript.stopped = stopped; + if stopped || self.turns_loading.is_some() || transcript.requested_at == Some(turns) { + return None; + } + transcript.requested_at = Some(turns); + self.turns_loading = Some((agent.clone(), session.clone(), turns)); + Some((agent.clone(), session.clone())) + } + + pub(crate) fn transcript_loaded(&mut self, agent: &str, session: &SessionName, turns: Result, String>) { + let requested_at = self.turns_loading.take().map(|(_, _, turns)| turns); + let Some(transcript) = self + .transcript + .as_mut() + .filter(|transcript| transcript.agent == agent && transcript.session == *session) + else { + return; + }; + // The selection may have left the Session and come back while it loaded. + transcript.requested_at = requested_at; + transcript.loading = false; + match turns { + Ok(turns) => { + transcript.turns = turns; + transcript.error = None; + } + Err(error) => transcript.error = Some(error), + } + } + + /// Reopens a prompt that could not be sent with its input and the reason, + /// unless another form is open by now. + pub(crate) fn prompt_failed(&mut self, mut form: PromptForm, error: String) { + if self.modal.is_some() { + self.error = Some(error); + } else { + form.error = Some(error); + self.modal = Some(Modal::Prompt(form)); + } + } + + /// Applies an archive or unarchive as the daemon recorded it, so the tree + /// changes with the notice instead of on the next watch reply, which + /// confirms it. The notice tells what happened, since an archived Session + /// leaves the tree while archived Sessions are hidden. It is kept short to + /// fit the header; the row reads Archiving while the harness still runs. + pub(crate) fn archive_changed(&mut self, session: Session, now: Instant) { + let name = session.name.as_str(); + let notice = match (session.is_archived(), self.show_archived) { + (false, _) => format!("{name} unarchived"), + (true, true) => format!("{name} archived"), + (true, false) => format!("{name} archived · A to show"), + }; + self.notice = Some((notice, now)); + if let Some(listed) = self + .sessions + .iter_mut() + .find(|listed| listed.agent == session.agent && listed.name == session.name) + { + *listed = session; + self.rebuild(); + } + } + + pub(crate) fn expire_notice(&mut self, now: Instant) { + if self + .notice + .as_ref() + .is_some_and(|(_, shown)| now.saturating_duration_since(*shown) >= NOTICE_DURATION) + { + self.notice = None; + } + } + + /// The selected Agent's status for the side panel: readiness, and the pass + /// in progress or the one that failed with its last output. + pub(crate) fn agent_panel_lines(&self, name: &str) -> Vec { + let Some(agent) = self.agents.iter().find(|agent| agent.metadata.name == name) else { + return Vec::new(); + }; + let mut lines = format::readiness_lines(&agent.status); + if let Some(provisioning) = &agent.status.progress { + let progress = &provisioning.progress; + lines.extend(format::provisioning_lines( + progress, + progress + .output() + .tail(AGENT_PANEL_OUTPUT_LINES) + .map(|line| line.text.as_str()), + )); + } + lines.extend([String::new(), "Connect · o open…".to_owned()]); + let desktop = self + .forwards + .iter() + .find(|entry| entry.agent == name && entry.label().is_some() && !entry.finished) + .map(ForwardEntry::url); + lines.extend(super::open::connect_lines( + agent, + &self.environment, + self.ssh_setup, + desktop.as_deref(), + )); + lines + } + + /// The Agent whose provisioning the open detail follows. + pub(crate) fn followed_agent(&self) -> Option<&str> { + self.detail.as_ref()?.follows.as_deref() + } + + /// Replaces the lines of the detail following `agent`'s provisioning. + pub(crate) fn provisioning_followed(&mut self, agent: &str, lines: Vec) { + if let Some(detail) = self + .detail + .as_mut() + .filter(|detail| detail.follows.as_deref() == Some(agent)) + { + detail.scroll = detail.scroll.min(lines.len().saturating_sub(1)); + detail.lines = lines; + // The next draw measures the new lines. + detail.scroll_limit.set(None); + } + } + + /// Applies one key to the open menu: moving, choosing by row or by the item's own key. + fn open_menu_key(&mut self, mut menu: OpenMenu, key: KeyEvent) -> Action { + let chosen = match key.code { + KeyCode::Esc | KeyCode::Char('q') => return Action::None, + KeyCode::Enter => menu.chosen(), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => { + menu.move_selection(1); + None + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => { + menu.move_selection(-1); + None + } + KeyCode::Char(character) => menu.by_key(character), + _ => None, + }; + if let Some(entry) = chosen { + return self.choose(menu.agent, entry); + } + self.modal = Some(Modal::Open(menu)); + Action::None + } + + /// Applies one key to the SSH setup question; declining returns to the + /// menu, and `o` opens what waits without the line. + fn confirm_ssh_setup_key( + &mut self, + agent: String, + include: agent::ssh::UserInclude, + then: Option, + key: KeyEvent, + ) -> Action { + match (key.code, then) { + (KeyCode::Enter, then) => Action::SetUpSsh { + include, + then: then.map(|target| (agent, target)), + }, + (KeyCode::Char('o'), Some(target)) => Action::Open { agent, target }, + (KeyCode::Esc | KeyCode::Char('n' | 'q'), _) => { + self.open_menu(&agent); + Action::None + } + _ => { + self.modal = Some(Modal::ConfirmSshSetup { agent, include, then }); + Action::None + } + } + } + + /// Opens the open menu for the named Agent, and checks the SSH setup + /// again, since the user may have changed their configuration since. + fn open_menu(&mut self, agent: &str) { + if let Some(agent) = self.agents.iter().find(|candidate| candidate.metadata.name == agent) { + self.modal = Some(Modal::Open(OpenMenu::new(agent, &self.environment, self.ssh_setup))); + self.ssh_check_due = true; + } + } + + /// The Agent whose alias the SSH setup is to be checked with next, when a + /// check is due and none is running: the open menu's Agent, or else any + /// Agent with SSH access, since OpenSSH resolves only aliases it has. + pub(crate) fn ssh_check_request(&mut self) -> Option { + if self.ssh_checking || !self.ssh_check_due { + return None; + } + let menu = match &self.modal { + Some(Modal::Open(menu)) => Some(menu.agent.as_str()), + _ => None, + }; + let agent = self + .agents + .iter() + .filter(|agent| agent.spec.ssh_access()) + .min_by_key(|agent| Some(agent.metadata.name.as_str()) != menu)? + .metadata + .name + .clone(); + self.ssh_check_due = false; + self.ssh_checking = true; + Some(agent) + } + + /// Records how OpenSSH resolved `agent`'s alias, and updates the open + /// menu, whose SSH setup row follows it. + pub(crate) fn ssh_checked(&mut self, agent: &str, setup: SshSetup) { + self.ssh_checking = false; + self.ssh_setup = setup; + let Some(Modal::Open(menu)) = &self.modal else { + return; + }; + if menu.agent != agent { + return; + } + let chosen = menu.chosen(); + let Some(listed) = self.agents.iter().find(|candidate| candidate.metadata.name == agent) else { + return; + }; + let mut rebuilt = OpenMenu::new(listed, &self.environment, setup); + if let Some(index) = rebuilt + .items + .iter() + .position(|item| Some(item.entry) == chosen && item.unavailable.is_none()) + { + rebuilt.selected = index; + } + self.modal = Some(Modal::Open(rebuilt)); + } + + /// Chooses `entry` for `agent`, asking first for the SSH setup the entry + /// needs while it is missing. Setup is only ever missing once the + /// include is known, so there is always a line to ask about. + fn choose(&mut self, agent: String, entry: MenuEntry) -> Action { + let (target, missing) = match entry { + MenuEntry::SetUpSsh => (None, true), + MenuEntry::Open(target) => ( + Some(target), + target.needs_include() && self.ssh_setup == SshSetup::Missing, + ), + }; + match (target, self.ssh_include.clone().filter(|_| missing)) { + (target, Some(include)) => { + self.modal = Some(Modal::ConfirmSshSetup { + agent, + include, + then: target, + }); + Action::None + } + (Some(target), None) => Action::Open { agent, target }, + (None, None) => Action::None, + } + } + + /// Records the SSH setup's outcome, the configuration it wrote or why it + /// could not, and continues to what it was set up for. + pub(crate) fn ssh_set_up( + &mut self, + result: Result, + then: Option<(String, OpenTarget)>, + now: Instant, + ) -> Option { + match result { + Ok(user_config) => { + self.ssh_setup = SshSetup::Installed; + // A check still running began before the line was added. + self.ssh_check_due = true; + self.notice = Some((format!("SSH set up in {}", user_config.display()), now)); + then.map(|(agent, target)| Action::Open { agent, target }) + } + Err(error) => { + self.error = Some(error); + None + } + } + } + + /// Starts waiting for `target` in `agent`, unless it already waits. + pub(crate) fn start_opening(&mut self, agent: &str, target: OpenTarget, now: Instant) -> bool { + if self + .opening + .iter() + .any(|(listed, waiting)| listed == agent && *waiting == target) + { + self.notice = Some((format!("already opening {} on {agent}", target.name()), now)); + return false; + } + self.opening.push((agent.to_owned(), target)); + true + } + + /// Shows how a background open ended, ending the wait for what it opened. + pub(crate) fn opened( + &mut self, + waiting: Option<(String, OpenTarget)>, + result: Result, + now: Instant, + ) { + if let Some(waiting) = waiting { + self.opening.retain(|listed| *listed != waiting); + } + match result { + Ok(notice) => self.notice = Some((notice, now)), + Err(error) => self.error = Some(error), + } + } + + fn open_new_session(&mut self, group: usize) { + let Some(agent) = self.group_agent(group) else { + return; + }; + let harness = agent + .spec + .harnesses + .iter() + .position(|spec| spec.default) + .unwrap_or_default(); + self.modal = Some(Modal::NewSession(SessionForm { + agent: agent.metadata.name.clone(), + name: String::new(), + model: String::new(), + effort: String::new(), + field: SessionField::Name, + harnesses: agent.spec.harnesses.clone(), + harness, + error: None, + })); + } + + fn toggle_fold(&mut self, name: &str) { + if !self.collapsed.remove(name) { + self.collapsed.insert(name.to_owned()); + } + self.rebuild(); + } + + fn toggle_all(&mut self) { + if self.collapsed.len() == self.agents.len() { + self.collapsed.clear(); + } else { + self.collapsed = self.agents.iter().map(|agent| agent.metadata.name.clone()).collect(); + } + self.rebuild(); + } + + fn move_forward_selection(&mut self, delta: isize) { + if self.forwards.is_empty() { + return; + } + let length = isize::try_from(self.forwards.len()).unwrap_or(1); + let current = isize::try_from(self.forward_selected).unwrap_or_default(); + self.forward_selected = usize::try_from((current + delta).rem_euclid(length)).unwrap_or_default(); + } + + fn move_forward_selection_clamped(&mut self, delta: isize) { + if !self.forwards.is_empty() { + self.forward_selected = offset_clamped(self.forward_selected, self.forwards.len() - 1, delta); + } + } + + /// Replaces the forward display list, keeping the selected forward by its ID. + pub(crate) fn set_forwards(&mut self, forwards: Vec) { + let selected = self.forwards.get(self.forward_selected).map(|entry| entry.id); + self.forwards = forwards; + self.forward_selected = selected + .and_then(|id| self.forwards.iter().position(|entry| entry.id == id)) + .unwrap_or(self.forward_selected) + .min(self.forwards.len().saturating_sub(1)); + } + + fn move_selection(&mut self, delta: isize) { + if self.rows.is_empty() { + return; + } + let length = self.rows.len(); + let current = isize::try_from(self.selected_index().unwrap_or_default()).unwrap_or_default(); + let next = (current + delta).rem_euclid(isize::try_from(length).unwrap_or(1)); + self.select_index(usize::try_from(next).unwrap_or_default()); + } + + fn move_selection_clamped(&mut self, delta: isize) { + if !self.rows.is_empty() { + let current = self.selected_index().unwrap_or_default(); + self.select_index(offset_clamped(current, self.rows.len() - 1, delta)); + } + } + + fn group_agent(&self, group: usize) -> Option<&Agent> { + self.agents.get(self.groups.get(group)?.agent) + } + + fn group_session(&self, group: usize, position: usize) -> Option<&Session> { + self.sessions.get(*self.groups.get(group)?.sessions.get(position)?) + } + + pub(crate) fn render_rows(&self) -> Vec { + self.rows + .iter() + .filter_map(|row| match *row { + Row::Agent(group) => { + let agent = self.group_agent(group)?; + let attention = self + .groups + .get(group)? + .sessions + .iter() + .filter_map(|index| self.sessions.get(*index)) + .any(needs_you); + let marker = if self.collapsed.contains(&agent.metadata.name) { + "▸" + } else { + "▾" + }; + let AgentState { + tone, + label: state, + detail: status, + failure, + since, + } = agent_state(agent); + // The Sessions listed under it, as unfolding would show them. + let listed = self.listed_positions(self.groups.get(group)?, self.agent_matches(agent)); + let count = match listed.len() { + 0 => String::new(), + 1 => "1 session".to_owned(), + count => format!("{count} sessions"), + }; + let forwards = self + .forwards + .iter() + .filter(|entry| entry.agent == agent.metadata.name) + .map(ForwardEntry::mapping) + .collect::>(); + let ports = if forwards.is_empty() { + String::new() + } else { + format!("ports: {}", forwards.join(" ")) + }; + let detail = [status, count, ports] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" · "); + Some(RowView { + agent: true, + attention, + marker, + name: agent.metadata.name.clone(), + state, + tone, + since: since.map_or_else(String::new, format::format_age), + detail, + detail_keeps_end: failure, + age: String::new(), + }) + } + Row::Session { group, position } => { + let session = self.group_session(group, position)?; + let (tone, marker, state) = session_state(session.status.state); + let harness = harness_label(session.harness); + let identity = session + .model_selection + .model_str() + .map_or_else(|| harness.to_owned(), |model| format!("{harness} · {model}")); + // A Session held or failed says why, such as an Agent whose guest stalled. + let detail = match (&session.status.state, &session.status.lifecycle.failure) { + (State::Starting | State::Failed, Some(reason)) => format!("{identity} · {reason}"), + _ => identity, + }; + Some(RowView { + agent: false, + attention: needs_you(session), + marker, + name: session.name.as_str().to_owned(), + state, + tone, + since: session.status.state_since.map_or_else(String::new, format::format_age), + detail, + detail_keeps_end: false, + age: format::format_age(session.created_at), + }) + } + }) + .collect() + } + + pub(crate) fn hints(&self) -> &'static [Hint] { + if let Some(modal) = &self.modal { + return match modal { + Modal::ConfirmDelete { .. } + | Modal::ConfirmStop { .. } + | Modal::ConfirmDeleteSession { .. } + | Modal::ConfirmQuit => &CONFIRM_HINTS, + Modal::NewSession(_) => &NEW_SESSION_HINTS, + Modal::CreateAgent { .. } => &CREATE_AGENT_HINTS, + Modal::PortForward { .. } => &PORT_FORWARD_HINTS, + Modal::Filter => &FILTER_HINTS, + Modal::Prompt(_) => &PROMPT_HINTS, + Modal::Help => &HELP_HINTS, + Modal::Open(_) => &OPEN_HINTS, + Modal::ConfirmSshSetup { then: Some(_), .. } => &CONFIRM_SSH_SETUP_THEN_HINTS, + Modal::ConfirmSshSetup { then: None, .. } => &CONFIRM_SSH_SETUP_HINTS, + }; + } + if self.detail.is_some() { + return &DETAIL_HINTS; + } + if self.view == View::Forwards { + return &FORWARD_VIEW_HINTS; + } + match self.selected_row() { + Some(Row::Agent(group)) => match self.group_agent(group) { + Some(agent) if stop_pending(agent) => &STOPPING_AGENT_HINTS, + Some(agent) if agent.spec.is_stopped() => &STOPPED_AGENT_HINTS, + _ => &AGENT_HINTS, + }, + Some(Row::Session { group, position }) => { + let archived = self.group_session(group, position).is_some_and(Session::is_archived); + let stopped = self.group_agent(group).is_some_and(|agent| agent.spec.is_stopped()); + match (stopped, archived) { + (false, false) => &SESSION_HINTS, + (false, true) => &ARCHIVED_SESSION_HINTS, + (true, false) => &STOPPED_SESSION_HINTS, + (true, true) => &STOPPED_ARCHIVED_SESSION_HINTS, + } + } + None => &EMPTY_HINTS, + } + } +} + +fn offset_clamped(current: usize, limit: usize, delta: isize) -> usize { + if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta.unsigned_abs()).min(limit) + } +} + +/// An Agent row's state, the detail beside it and when it entered the state. +struct AgentState { + tone: Tone, + label: &'static str, + detail: String, + /// The detail is a failure, whose cause is at its end. + failure: bool, + since: Option, +} + +/// Reads an Agent's state from its typed status: deletion first, then the +/// class of the last failure, the pass in progress and readiness. +/// +/// A failure holds while its generation is current, so an Agent that is +/// retrying stays Retrying through each retry, and time in state is how long +/// `Ready` has been in its current state. A pass for a newer generation is +/// provisioning the change, and its time in state is the pass's own. +fn agent_state(agent: &Agent) -> AgentState { + let state = |tone, label, detail, since| AgentState { + tone, + label, + detail, + failure: false, + since, + }; + let failed = |tone, label, detail, since| AgentState { + failure: true, + ..state(tone, label, detail, since) + }; + if let Some(deleted) = agent.metadata.deletion_timestamp { + return state(Tone::Red, "Terminating", String::new(), Some(deleted)); + } + let ready = agent.status.ready_condition(); + let entered = ready.and_then(|ready| ready.last_transition_time); + let message = || ready.map_or_else(String::new, |ready| ready.detail().trim_end().to_owned()); + let failure = agent + .status + .failure + .filter(|_| agent.status.observed_generation == agent.metadata.generation); + let changing = if agent.spec.is_stopped() { + if !stop_pending(agent) { + return state(Tone::Gray, "Stopped", String::new(), entered); + } + // A failed stop reads as Retrying below, like any other failed pass. + failure.is_none().then_some("Stopping") + } else { + ready + .is_some_and(|ready| ready.reason == Condition::REASON_STARTING) + .then_some("Starting") + }; + if let Some(label) = changing { + let detail = provisioning(agent).map_or_else(String::new, progress_summary); + return state(Tone::Cyan, label, detail, entered); + } + // Retried like any transient failure, but nothing reaches the guest until it responds again. + if agent.status.unresponsive().is_some() { + return failed(Tone::Red, "Unresponsive", message(), entered); + } + match (failure, provisioning(agent)) { + (Some(FailureKind::Invalid), _) => failed(Tone::Red, "Failed", message(), entered), + (Some(FailureKind::Transient), Some(progress)) => { + state(Tone::Yellow, "Retrying", progress_summary(progress), entered) + } + (Some(FailureKind::Transient), None) => failed(Tone::Yellow, "Retrying", message(), entered), + (None, Some(progress)) => state( + Tone::Cyan, + "Provisioning", + progress_summary(progress), + Some(pass_started(progress)), + ), + (None, None) => match ready.map(|ready| ready.status) { + None => state(Tone::Gray, "Pending", String::new(), None), + Some(ConditionStatus::True) => state(Tone::Green, "Ready", String::new(), entered), + Some(_) => failed(Tone::Cyan, "Starting", message(), entered), + }, + } +} + +/// Whether the Agent was asked to stop and no pass for that generation has +/// recorded it stopped yet. +fn stop_pending(agent: &Agent) -> bool { + agent.spec.is_stopped() + && !(agent.status.observed_generation == agent.metadata.generation && agent.status.is_stopped()) +} + +/// When a pass started: before the phase in progress by the time its +/// finished phases took. +fn pass_started(progress: &Progress) -> OffsetDateTime { + let finished = progress.finished().iter().map(|phase| phase.elapsed_ms).sum::(); + let end = progress + .current() + .map_or_else(OffsetDateTime::now_utc, |phase| phase.started_at); + end - time::Duration::milliseconds(i64::try_from(finished).unwrap_or(i64::MAX)) +} + +/// The Agent's pass while it is running. +fn provisioning(agent: &Agent) -> Option<&Progress> { + let progress = &agent.status.progress.as_ref()?.progress; + (*progress.status() == OperationStatus::Running).then_some(progress) +} + +/// The phase in progress and its current step, with the step's measurement. +fn progress_summary(progress: &Progress) -> String { + let Some(phase) = progress.current() else { + return String::new(); + }; + let mut summary = phase.phase.label.to_string(); + if let Some(step) = progress.current_step() { + summary.push_str(" · "); + summary.push_str(&step.name); + if let Some(measurement) = step.measurement { + summary.push_str(": "); + summary.push_str(&crate::progress::format_measurement(measurement)); + } + } + summary +} + +/// Whether a Session waits for its user. An archived one never does: it reads +/// Archiving until its harness stops, as nobody answers it. +const fn needs_you(session: &Session) -> bool { + matches!(session.status.state, State::WaitingForInput) +} + +/// A Session state's tone, glyph and label. +const fn session_state(state: State) -> (Tone, &'static str, &'static str) { + match state { + State::WaitingForInput => (Tone::Yellow, "!", "Needs you"), + State::Working => (Tone::Green, "*", "Working"), + State::Starting => (Tone::Cyan, "~", "Starting"), + State::Idle => (Tone::Gray, "-", "Idle"), + State::Archiving => (Tone::Gray, "_", "Archiving"), + State::Archived => (Tone::Gray, "_", "Archived"), + State::Failed => (Tone::Red, "x", "Failed"), + } +} + +pub(crate) const fn harness_label(harness: Harness) -> &'static str { + match harness { + Harness::ClaudeCode => "Claude Code", + Harness::Codex => "Codex", + } +} + +fn bind_hint(spec: &ForwardSpec, error: String) -> String { + let low_port_on_specific_address = spec.local_port != 0 && spec.local_port < 1024 && !spec.address.is_unspecified(); + if cfg!(target_os = "macos") && low_port_on_specific_address && error.contains("Permission denied") { + format!("{error} — macOS allows ports below 1024 only on 0.0.0.0") + } else { + error + } +} + +const fn forward_field_accepts(field: ForwardField, character: char) -> bool { + match field { + ForwardField::Address => character.is_ascii_digit() || character == '.', + ForwardField::LocalPort | ForwardField::GuestPort => character.is_ascii_digit(), + } +} + +fn yaml_lines(value: &T) -> Vec { + serde_yaml_ng::to_string(value).map_or_else( + |error| vec![format!("failed to render YAML: {error}")], + |yaml| yaml.lines().map(str::to_owned).collect(), + ) +} + +fn session_detail(session: &Session) -> Detail { + let lines = vec![ + format!("Name: {}", session.name.as_str()), + format!("Agent: {}", session.agent), + format!("Harness: {}", session.harness.as_str()), + format!("Model: {}", session.model_selection.model_str().unwrap_or("-")), + format!("Effort: {}", session.model_selection.effort_str().unwrap_or("-")), + format!("State: {}", format::session_state(session.status.state)), + format!("Turns: {}", session.status.reported.activity.turns), + format!("Age: {}", format::format_age(session.created_at)), + format!( + "Failure: {}", + session.status.lifecycle.failure.as_deref().unwrap_or("-") + ), + format!( + "Harness ID: {}", + session.status.reported.harness_session_id.as_deref().unwrap_or("-") + ), + format!("ID: {}", session.id), + ]; + Detail::text(format!("session/{}/{}", session.agent, session.name.as_str()), lines) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn agent_named(name: &str, harnesses: &str) -> Agent { + let yaml = format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: {name}\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + {harnesses}\ + \x20 secrets: []\n\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ); + agent::manifest::decode(yaml.as_bytes()).expect("test manifest should decode") + } + + fn agent(name: &str) -> Agent { + agent_named( + name, + "\x20 - type: claudeCode\n\x20 version: \"1.0.0\"\n\x20 auth: mediated\n", + ) + } + + fn ready_agent(name: &str) -> Agent { + let mut agent = agent(name); + agent.status.conditions.push(agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }); + agent + } + + fn session(agent: &str, name: &str, state: &str) -> Session { + let lifecycle = match state { + "working" | "waitingForInput" => "running", + other => other, + }; + serde_json::from_value(serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "agentId": "00000000-0000-0000-0000-000000000002", + "agent": agent, + "name": name, + "harness": "claudeCode", + "createdAt": "2026-08-25T00:00:00Z", + "status": {"state": state, "lifecycle": {"state": lifecycle}} + })) + .expect("test session should deserialize") + } + + fn populated() -> App { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker"), agent("builder")], + vec![ + session("worker", "s2", "working"), + session("worker", "s1", "idle"), + session("builder", "b1", "starting"), + ], + ); + app + } + + #[test] + fn snapshot_groups_sessions_under_sorted_agents() { + let app = populated(); + assert_eq!( + app.rows, + vec![ + Row::Agent(0), + Row::Session { group: 0, position: 0 }, + Row::Agent(1), + Row::Session { group: 1, position: 0 }, + Row::Session { group: 1, position: 1 }, + ] + ); + let views = app.render_rows(); + assert_eq!(views[0].name, "builder"); + assert_eq!(views[1].name, "b1"); + assert_eq!(views[2].name, "worker"); + assert_eq!(views[3].name, "s1"); + assert_eq!(views[4].name, "s2"); + assert_eq!( + app.triage_counts(), + TriageCounts { + working: 1, + starting: 1, + idle: 1, + ..TriageCounts::default() + } + ); + } + + fn type_text(app: &mut App, text: &str) { + for character in text.chars() { + app.on_key(key(KeyCode::Char(character))); + } + } + + #[test] + fn the_filter_matches_an_agents_state() { + let mut app = App::new(); + app.apply_snapshot( + vec![ready_agent("alive"), failed_agent("broken", FailureKind::Transient)], + Vec::new(), + ); + app.on_key(key(KeyCode::Char('/'))); + type_text(&mut app, "retry"); + assert_eq!( + app.render_rows() + .iter() + .map(|row| row.name.as_str()) + .collect::>(), + ["broken"] + ); + } + + #[test] + fn the_filter_matches_names_states_harnesses_and_models_and_shows_folded_matches() { + let mut app = populated(); + app.on_key(key(KeyCode::Enter)); + assert_eq!(app.rows.len(), 4, "builder is folded"); + app.on_key(key(KeyCode::Char('/'))); + + type_text(&mut app, "B1"); + assert_eq!(app.rows, [Row::Agent(0), Row::Session { group: 0, position: 0 }]); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + type_text(&mut app, "idle"); + assert_eq!( + app.render_rows() + .iter() + .map(|row| row.name.as_str()) + .collect::>(), + ["worker", "s1"] + ); + app.filter.clear(); + type_text(&mut app, "work"); + assert_eq!(app.rows.len(), 3, "an Agent that matches keeps all its Sessions"); + + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert!(app.modal.is_none()); + assert_eq!(app.filter, "work", "enter keeps the filter"); + assert_eq!( + app.on_key(key(KeyCode::Esc)), + Action::None, + "esc clears the filter first" + ); + assert!(app.filter.is_empty()); + assert_eq!(app.rows.len(), 4, "folding applies again"); + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::Quit); + } + + #[test] + fn tab_selects_the_next_session_needing_input_and_wraps() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("first"), agent("second")], + vec![ + session("first", "a", "waitingForInput"), + session("first", "b", "working"), + session("second", "c", "waitingForInput"), + ], + ); + app.on_key(key(KeyCode::Tab)); + assert_eq!(app.selected_index(), Some(1)); + app.on_key(key(KeyCode::Tab)); + assert_eq!(app.selected_index(), Some(4)); + app.on_key(key(KeyCode::Tab)); + assert_eq!(app.selected_index(), Some(1), "the jump wraps around"); + } + + #[test] + fn tab_unfolds_and_clears_the_filter_to_reach_every_session_the_header_counts() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("first"), agent("second")], + vec![ + session("first", "a", "working"), + session("second", "c", "waitingForInput"), + ], + ); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!(app.rows.len(), 2, "both Agents are folded"); + app.on_key(key(KeyCode::Tab)); + assert_eq!( + app.selection, + Some(TreeRowId::Session { + agent: "second".into(), + session: SessionName::new("c").expect("name"), + }) + ); + assert!(!app.collapsed.contains("second"), "the Agent holding it unfolds"); + + app.filter = "first".into(); + app.select_index(0); + app.rebuild(); + app.on_key(key(KeyCode::Tab)); + assert!(app.filter.is_empty(), "a filter hiding it is cleared"); + assert_eq!(app.selected_row(), Some(Row::Session { group: 1, position: 0 })); + } + + #[test] + fn a_removed_row_leaves_the_selection_on_its_neighbour() { + let mut app = App::new(); + app.apply_snapshot(vec![agent("a"), agent("b"), agent("c")], Vec::new()); + app.select_index(2); + app.apply_snapshot(vec![agent("a"), agent("b")], Vec::new()); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("b".into())), + "the last row falls back to the one above" + ); + + app.apply_snapshot(vec![agent("a"), agent("b"), agent("c")], Vec::new()); + app.select_index(1); + app.apply_snapshot(vec![agent("a"), agent("c")], Vec::new()); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("c".into())), + "a middle row falls back to the one now in its place" + ); + } + + #[test] + fn folding_hides_sessions_and_expand_all_restores_them() { + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert_eq!(app.rows.len(), 4); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!(app.rows.len(), 2); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!(app.rows.len(), 5); + } + + #[test] + fn folding_moves_the_selection_from_a_hidden_session_to_its_agent() { + let mut app = populated(); + app.select_index(1); + assert_eq!( + app.selection, + Some(TreeRowId::Session { + agent: "builder".into(), + session: SessionName::new("b1").expect("name"), + }) + ); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("builder".into())), + "not worker, which folding moved to the Session's former position" + ); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("builder".into())), + "unfolding keeps it on the Agent" + ); + } + + #[test] + fn question_mark_opens_help_over_the_tree_and_the_forwards() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('?'))); + assert!(matches!(app.modal, Some(Modal::Help))); + assert_eq!(app.hints(), &HELP_HINTS); + app.on_key(key(KeyCode::Char('j'))); + assert!(matches!(app.modal, Some(Modal::Help)), "other keys leave it open"); + assert_eq!( + app.on_key(key(KeyCode::Char('q'))), + Action::None, + "q closes help instead of quitting" + ); + assert!(app.modal.is_none()); + + app.on_key(key(KeyCode::Char('F'))); + app.on_key(key(KeyCode::Char('?'))); + assert!(matches!(app.modal, Some(Modal::Help))); + app.on_key(key(KeyCode::Esc)); + assert!(app.modal.is_none()); + assert_eq!(app.view, View::Forwards); + } + + #[test] + fn selection_wraps_and_clamps_after_shrink() { + let mut app = populated(); + app.on_key(key(KeyCode::Up)); + assert_eq!(app.selected_index(), Some(4)); + app.on_key(key(KeyCode::Down)); + assert_eq!(app.selected_index(), Some(0)); + app.select_index(4); + app.apply_snapshot(vec![agent("worker")], Vec::new()); + assert_eq!(app.selected_index(), Some(0)); + } + + #[test] + fn enter_on_a_session_attaches_to_it() { + let mut app = populated(); + app.select_index(1); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::Attach { + agent: "builder".into(), + session: SessionName::new("b1").expect("valid name"), + } + ); + } + + fn session_target(agent: &str, session: &str) -> RowTarget { + RowTarget::Tree(TreeRowId::Session { + agent: agent.into(), + session: SessionName::new(session).expect("valid Session name"), + }) + } + + #[test] + fn mouse_row_selection_is_separate_from_primary_actions() { + let mut app = populated(); + + assert_eq!( + app.on_mouse(MouseAction::Select(session_target("worker", "s2"))), + Action::None + ); + assert_eq!(app.selected_index(), Some(4)); + assert_eq!( + app.on_mouse(MouseAction::Primary(session_target("worker", "s2"))), + Action::Attach { + agent: "worker".into(), + session: SessionName::new("s2").expect("valid Session name"), + } + ); + + assert_eq!(app.rows.len(), 5); + assert_eq!(app.on_mouse(MouseAction::FoldTree("builder".into())), Action::None); + assert_eq!(app.rows.len(), 4); + } + + #[test] + fn selection_and_mouse_targets_follow_the_resource_when_rows_move() { + let mut app = populated(); + let rendered = session_target("worker", "s1"); + app.on_mouse(MouseAction::Select(rendered.clone())); + assert_eq!(app.selected_index(), Some(3)); + + app.apply_snapshot( + vec![agent("worker"), agent("builder"), agent("analyst")], + vec![ + session("worker", "s2", "working"), + session("worker", "s1", "idle"), + session("analyst", "a1", "working"), + session("builder", "b1", "starting"), + ], + ); + assert_eq!(app.selected_index(), Some(5), "the new Agent moved the Session down"); + assert_eq!( + app.on_mouse(MouseAction::Primary(rendered)), + Action::Attach { + agent: "worker".into(), + session: SessionName::new("s1").expect("valid Session name"), + } + ); + assert_eq!( + app.on_mouse(MouseAction::Select(session_target("worker", "gone"))), + Action::None + ); + assert_eq!( + app.selected_index(), + Some(5), + "a vanished target leaves the selection alone" + ); + } + + #[test] + fn mouse_wheel_selection_and_detail_scrolling_clamp_at_the_ends() { + let mut app = populated(); + app.select_index(app.rows.len() - 1); + + app.on_mouse(MouseAction::MoveTree(1)); + assert_eq!(app.selected_index(), Some(app.rows.len() - 1)); + app.on_mouse(MouseAction::MoveTree(-100)); + assert_eq!(app.selected_index(), Some(0)); + + app.detail = Some(Detail::text( + "detail".into(), + vec!["one".into(), "two".into(), "three".into()], + )); + app.on_mouse(MouseAction::ScrollDetail(100)); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(2)); + app.on_mouse(MouseAction::ScrollDetail(-100)); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(0)); + } + + #[test] + fn clickable_actions_keep_confirmation_and_terminal_action_semantics() { + let mut app = populated(); + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('e'), KeyModifiers::NONE)), + Action::Exec { + agent: "builder".into() + } + ); + + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('d'), KeyModifiers::NONE)), + Action::None + ); + assert!(matches!(app.modal, Some(Modal::ConfirmDelete { .. }))); + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('y'), KeyModifiers::NONE)), + Action::Delete { + agent: "builder".into() + } + ); + } + + #[test] + fn archived_sessions_are_hidden_until_shown_and_toggle_from_their_row() { + let mut app = populated(); + let b1 = SessionName::new("b1").expect("name"); + let b1_session = app.sessions.iter_mut().find(|session| session.name == b1).expect("b1"); + *b1_session = archived(b1_session.clone()); + app.rebuild(); + let b1_row = TreeRowId::Session { + agent: "builder".into(), + session: b1.clone(), + }; + assert_eq!(app.tree_index(&b1_row), None, "archived Sessions are hidden"); + assert_eq!(app.triage_counts().archived, 1, "but counted"); + assert_eq!(app.triage_counts().starting, 0); + + assert_eq!(app.on_key(key(KeyCode::Char('A'))), Action::None); + assert!(app.tree_index(&b1_row).is_some(), "A shows them"); + app.selection = Some(b1_row); + assert_eq!(app.hints().first().map(|hint| hint.description), Some("unarchive")); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::None, + "an archived Session is not attached" + ); + assert!(app.modal.is_none(), "nor prompted"); + assert_eq!(app.on_key(key(KeyCode::Char('p'))), Action::None); + assert!(app.modal.is_none()); + assert_eq!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { + agent: "builder".into(), + session: b1, + archived: false, + }, + "a unarchives an archived Session" + ); + + app.selection = Some(TreeRowId::Session { + agent: "worker".into(), + session: SessionName::new("s1").expect("name"), + }); + assert!(matches!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { archived: true, .. } + )); + } + + /// Archives a test Session as the daemon reports it: Archiving until its + /// harness has stopped. + fn archived(mut session: Session) -> Session { + session.archived_at = Some(time::OffsetDateTime::UNIX_EPOCH); + if session.status.state != State::Archived { + session.status.state = State::Archiving; + } + session + } + + fn session_row(agent: &str, name: &str) -> TreeRowId { + TreeRowId::Session { + agent: agent.into(), + session: SessionName::new(name).expect("name"), + } + } + + #[test] + fn an_agent_counts_the_sessions_listed_under_it() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "old", "waitingForInput")), + session("worker", "main", "working"), + session("worker", "review", "idle"), + ], + ); + let detail = |app: &App| app.render_rows()[0].detail.clone(); + assert_eq!(detail(&app), "2 sessions", "a hidden archived Session is not counted"); + assert!(!app.render_rows()[0].attention, "nor does it ask for attention"); + + app.on_key(key(KeyCode::Char('A'))); + assert_eq!(detail(&app), "3 sessions"); + + app.filter = "main".into(); + app.rebuild(); + assert_eq!(detail(&app), "1 session", "the filter narrows the count"); + + app.filter.clear(); + app.collapsed.insert("worker".into()); + app.rebuild(); + assert_eq!(detail(&app), "3 sessions", "a folded Agent tells what unfolding shows"); + } + + #[test] + fn a_session_leaving_the_tree_selects_its_neighbour_before_its_agent() { + let mut app = App::new(); + let snapshot = |archived_names: &[&str]| { + ["s1", "s2", "s3"] + .into_iter() + .map(|name| { + let session = session("worker", name, "idle"); + if archived_names.contains(&name) { + archived(session) + } else { + session + } + }) + .collect::>() + }; + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&[])); + app.selection = Some(session_row("worker", "s2")); + + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&["s2"])); + assert_eq!( + app.selection, + Some(session_row("worker", "s3")), + "the next one takes its place" + ); + + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&["s2", "s3"])); + assert_eq!( + app.selection, + Some(session_row("worker", "s1")), + "the last one gives way to the one above" + ); + + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&["s1", "s2", "s3"])); + assert_eq!(app.selection, Some(TreeRowId::Agent("worker".into())), "then its Agent"); + } + + #[test] + fn a_selection_whose_neighbours_also_leave_goes_to_the_nearest_listed_session() { + let mut app = App::new(); + app.show_archived = true; + app.apply_snapshot( + vec![agent("alpha"), agent("worker")], + vec![ + archived(session("alpha", "a1", "archived")), + archived(session("alpha", "a2", "archived")), + archived(session("worker", "s1", "archived")), + archived(session("worker", "s2", "archived")), + archived(session("worker", "s3", "archived")), + session("worker", "s4", "idle"), + ], + ); + app.selection = Some(session_row("worker", "s3")); + app.on_key(key(KeyCode::Char('A'))); + assert_eq!( + app.selection, + Some(session_row("worker", "s4")), + "rows above, of its own and of another Agent, left with it" + ); + + app.selection = Some(session_row("worker", "s4")); + app.on_key(key(KeyCode::Char('A'))); + app.selection = Some(session_row("worker", "s4")); + app.filter = "s1".into(); + app.rebuild(); + assert_eq!( + app.selection, + Some(session_row("worker", "s1")), + "the filter keeps only one before it" + ); + } + + #[test] + fn a_snapshot_that_adds_and_removes_sessions_selects_by_name_not_position() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![session("worker", "s2", "idle"), session("worker", "s3", "idle")], + ); + app.selection = Some(session_row("worker", "s2")); + app.apply_snapshot( + vec![agent("worker")], + vec![ + session("worker", "s1", "idle"), + archived(session("worker", "s2", "archived")), + session("worker", "s3", "idle"), + ], + ); + assert_eq!(app.selection, Some(session_row("worker", "s3"))); + } + + #[test] + fn hiding_archived_sessions_moves_the_selection_to_a_listed_neighbour() { + let mut app = App::new(); + app.show_archived = true; + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "s1", "archived")), + session("worker", "s2", "idle"), + ], + ); + app.selection = Some(session_row("worker", "s1")); + app.on_key(key(KeyCode::Char('A'))); + assert_eq!(app.selection, Some(session_row("worker", "s2"))); + } + + #[test] + fn tab_never_selects_a_hidden_archived_session() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "s1", "waitingForInput")), + session("worker", "s2", "idle"), + ], + ); + app.selection = Some(session_row("worker", "s2")); + app.on_key(key(KeyCode::Tab)); + assert_eq!( + app.selection, + Some(session_row("worker", "s2")), + "nothing listed needs you" + ); + assert_eq!(app.triage_counts().needs_you, 0); + + app.on_key(key(KeyCode::Char('A'))); + app.on_key(key(KeyCode::Tab)); + assert_eq!( + app.selection, + Some(session_row("worker", "s2")), + "nor a shown one, which nobody answers" + ); + } + + #[test] + fn an_archived_session_reads_archiving_until_its_harness_stops() { + let mut app = App::new(); + app.show_archived = true; + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "s1", "waitingForInput")), + archived(session("worker", "s2", "archived")), + ], + ); + let rows = app.render_rows(); + assert_eq!((rows[1].name.as_str(), rows[1].state), ("s1", "Archiving")); + assert!(!rows[1].attention, "an archived Session never asks for attention"); + assert_eq!((rows[2].name.as_str(), rows[2].state), ("s2", "Archived")); + app.filter = "archiving".into(); + app.rebuild(); + assert_eq!(app.render_rows().len(), 2, "the filter matches the shown label"); + } + + #[test] + fn an_archive_applies_to_the_tree_as_recorded_and_a_clears_its_notice() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![session("worker", "s1", "working"), session("worker", "s2", "idle")], + ); + app.selection = Some(session_row("worker", "s1")); + assert_eq!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { + agent: "worker".into(), + session: SessionName::new("s1").expect("name"), + archived: true, + } + ); + app.archive_changed(archived(session("worker", "s1", "working")), Instant::now()); + assert_eq!( + app.tree_index(&session_row("worker", "s1")), + None, + "gone before the watch replies" + ); + assert_eq!(app.selection, Some(session_row("worker", "s2"))); + assert_eq!(app.triage_counts().archived, 1); + + app.on_key(key(KeyCode::Char('A'))); + assert!(app.notice.is_none(), "showing archived Sessions answers the notice"); + assert_eq!(app.render_rows()[1].state, "Archiving"); + + app.archive_changed(archived(session("worker", "gone", "idle")), Instant::now()); + assert_eq!(app.sessions.len(), 2, "a Session the tree does not list is not added"); + } + + #[test] + fn archiving_tells_what_happened_for_a_while() { + let mut app = App::new(); + let now = Instant::now(); + app.archive_changed(archived(session("worker", "s1", "archived")), now); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("s1 archived · A to show") + ); + app.show_archived = true; + app.archive_changed(archived(session("worker", "s2", "working")), now); + assert_eq!(app.notice.as_ref().map(|(text, _)| text.as_str()), Some("s2 archived")); + app.archive_changed(session("worker", "s1", "idle"), now); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("s1 unarchived") + ); + app.expire_notice(now + NOTICE_DURATION.saturating_sub(Duration::from_millis(1))); + assert!(app.notice.is_some()); + app.expire_notice(now + NOTICE_DURATION); + assert!(app.notice.is_none()); + } + + #[test] + fn deleting_a_session_row_confirms_first_and_leaves_the_agent_alone() { + let mut app = populated(); + app.selection = Some(TreeRowId::Session { + agent: "builder".into(), + session: SessionName::new("b1").expect("name"), + }); + + assert_eq!(app.on_key(key(KeyCode::Char('d'))), Action::None); + let Some(Modal::ConfirmDeleteSession { agent, session }) = &app.modal else { + panic!("deleting a Session must ask for confirmation"); + }; + assert_eq!((agent.as_str(), session.as_str()), ("builder", "b1")); + + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None); + assert!(app.modal.is_none(), "cancelling must not delete anything"); + + app.on_key(key(KeyCode::Char('d'))); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::DeleteSession { + agent: "builder".into(), + session: SessionName::new("b1").expect("name"), + } + ); + } + + #[test] + fn mouse_forward_actions_select_edit_and_delete_the_target() { + let mut app = App::new(); + app.view = View::Forwards; + app.set_forwards(vec![ + ForwardEntry { + id: 10, + agent: "first".into(), + local: "127.0.0.1:8000".into(), + guest_port: 80, + status: None, + finished: false, + }, + ForwardEntry { + id: 20, + agent: "second".into(), + local: "127.0.0.1:9000".into(), + guest_port: 90, + status: None, + finished: false, + }, + ]); + + assert_eq!(app.on_mouse(MouseAction::Select(RowTarget::Forward(20))), Action::None); + assert_eq!(app.forward_selected, 1); + assert_eq!(app.on_mouse(MouseAction::Primary(RowTarget::Forward(20))), Action::None); + assert!(matches!( + app.modal, + Some(Modal::PortForward(ForwardForm { replace: Some(20), .. })) + )); + + app.modal = None; + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Action::DeleteForward { id: 20 } + ); + } + + #[test] + fn deleting_an_agent_requires_confirmation() { + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Char('d'))), Action::None); + assert!(matches!(app.modal, Some(Modal::ConfirmDelete { .. }))); + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None); + assert!(app.modal.is_none()); + app.on_key(key(KeyCode::Char('d'))); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::Delete { + agent: "builder".into() + } + ); + assert!(app.modal.is_none()); + } + + #[test] + fn new_session_modal_validates_the_name_and_creates_on_enter() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('n'))); + assert!(matches!(app.modal, Some(Modal::NewSession(_)))); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert!(matches!(&app.modal, Some(Modal::NewSession(form)) if form.error.is_some())); + app.on_key(key(KeyCode::Char('s'))); + app.on_key(key(KeyCode::Char('!'))); + app.on_key(key(KeyCode::Char('1'))); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::CreateSession { + agent: "builder".into(), + session: SessionName::new("s1").expect("valid name"), + harness: Harness::ClaudeCode, + model_selection: ModelSelection::default(), + } + ); + assert!(app.modal.is_none()); + } + + #[test] + fn new_session_modal_rejects_an_existing_name_instead_of_attaching() { + let mut app = populated(); + app.select_index(2); + app.on_key(key(KeyCode::Char('n'))); + type_text(&mut app, "s1"); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert!(matches!( + &app.modal, + Some(Modal::NewSession(form)) if form.error.as_deref() == Some("session \"s1\" already exists") + )); + } + + #[test] + fn new_session_form_types_model_and_effort_and_shows_manifest_defaults() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent_named( + "worker", + "\x20 - type: claudeCode\n\x20 auth: mediated\n\x20 defaults:\n\x20 model: fable\n\x20 effort: high\n", + )], + Vec::new(), + ); + app.on_key(key(KeyCode::Char('n'))); + let form = |app: &App| match &app.modal { + Some(Modal::NewSession(form)) => form.clone(), + _ => panic!("expected the NewSession modal"), + }; + assert_eq!(form(&app).field, SessionField::Name); + assert_eq!(form(&app).model_default(), Some("fable")); + assert_eq!(form(&app).effort_default(), Some("high")); + assert_eq!(app.hints(), &NEW_SESSION_HINTS); + + app.on_key(key(KeyCode::Char('s'))); + app.on_key(key(KeyCode::Tab)); + assert_eq!(form(&app).field, SessionField::Model); + for character in "gpt 5.4".chars() { + app.on_key(key(KeyCode::Char(character))); + } + assert_eq!(form(&app).model, "gpt 5.4", "typed input is kept as typed"); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + let error = form(&app).error.expect("an invalid model is reported, not reshaped"); + assert!(error.contains("model must be 1-128"), "{error}"); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + for character in "5.4".chars() { + app.on_key(key(KeyCode::Char(character))); + } + assert_eq!(form(&app).model, "gpt5.4"); + assert_eq!(form(&app).error, None, "editing clears the error"); + app.on_key(key(KeyCode::Down)); + assert_eq!(form(&app).field, SessionField::Effort); + app.on_key(key(KeyCode::Char('x'))); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::BackTab)); + app.on_key(key(KeyCode::Up)); + assert_eq!(form(&app).field, SessionField::Name); + assert_eq!(form(&app).name, "s"); + + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::CreateSession { + agent: "worker".into(), + session: SessionName::new("s").expect("valid name"), + harness: Harness::ClaudeCode, + model_selection: ModelSelection { + model: Some(Model::new("gpt5.4").expect("model")), + effort: None, + }, + }, + "an empty effort leaves the manifest default to the daemon" + ); + } + + #[test] + fn new_session_preselects_the_default_harness_and_cycles() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent_named( + "worker", + "\x20 - type: claudeCode\n\x20 version: \"1.0.0\"\n\x20 auth: mediated\n\ + \x20 - type: codex\n\x20 version: \"1.0.0\"\n\x20 auth: mediated\n\ + \x20 default: true\n", + )], + Vec::new(), + ); + app.on_key(key(KeyCode::Char('n'))); + let Some(Modal::NewSession(form)) = &app.modal else { + panic!("expected the NewSession modal"); + }; + assert_eq!(form.harness, 1); + app.on_key(key(KeyCode::Right)); + let Some(Modal::NewSession(form)) = &app.modal else { + panic!("expected the NewSession modal"); + }; + assert_eq!(form.harness, 0); + app.on_key(key(KeyCode::Left)); + app.on_key(key(KeyCode::Left)); + let Some(Modal::NewSession(form)) = &app.modal else { + panic!("expected the NewSession modal"); + }; + assert_eq!(form.harness, 0, "the picker wraps in both directions"); + app.on_key(key(KeyCode::Char('s'))); + app.on_key(key(KeyCode::Char('1'))); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::CreateSession { + agent: "worker".into(), + session: SessionName::new("s1").expect("valid name"), + harness: Harness::ClaudeCode, + model_selection: ModelSelection::default(), + } + ); + } + + fn candidates(entries: &[(&str, &str)]) -> Vec { + entries + .iter() + .map(|(directory, name)| { + ManifestCandidate::new(PathBuf::from(directory).join("agent.yaml"), Ok((*name).to_owned())) + }) + .collect() + } + + fn create_form(app: &App) -> &CreateForm { + let Some(Modal::CreateAgent(form)) = &app.modal else { + panic!("expected the CreateAgent modal"); + }; + form + } + + #[test] + fn create_key_requests_manifest_discovery_even_without_agents() { + let mut app = App::new(); + app.apply_snapshot(Vec::new(), Vec::new()); + assert_eq!(app.hints(), &EMPTY_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::OpenCreate); + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::OpenCreate); + app.select_index(1); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::OpenCreate); + } + + #[test] + fn discovery_results_wait_for_an_open_view_to_close() { + let mut app = populated(); + app.manifests_discovered(candidates(&[("/sources/stale", "stale")])); + assert!(app.modal.is_none()); + + app.discovering = true; + app.on_key(key(KeyCode::Char('s'))); + app.manifests_discovered(candidates(&[("/sources/worker", "worker")])); + assert!(!app.discovering); + assert!(app.modal.is_none()); + app.open_queued_create(); + assert!(app.modal.is_none()); + + app.on_key(key(KeyCode::Esc)); + app.open_queued_create(); + assert_eq!(create_form(&app).placeholder(), Some("worker")); + assert!(app.queued_candidates.is_none()); + + let mut app = populated(); + app.discovering = true; + app.manifests_discovered(candidates(&[("/sources/worker", "worker")])); + assert_eq!(create_form(&app).placeholder(), Some("worker")); + } + + #[test] + fn open_create_preselects_the_highlighted_agents_manifest() { + let mut app = populated(); + for agent in &mut app.agents { + if agent.metadata.name == "worker" { + agent.status.provenance = Some(agent::Provenance { + source_directory: PathBuf::from("/sources/worker"), + manifest_path: Some(PathBuf::from("/sources/worker/agent.nested.yaml")), + env_file: None, + }); + } + } + app.select_index(3); + let mut discovered = candidates(&[("/sources/builder", "builder"), ("/sources/worker", "worker")]); + discovered.push(ManifestCandidate::new( + PathBuf::from("/sources/worker/agent.nested.yaml"), + Ok("worker-nested".into()), + )); + app.open_create(discovered); + let form = create_form(&app); + assert_eq!(form.agent, 1); + assert_eq!(form.variant, 1); + assert_eq!(form.variant_label(), Some("nested".into())); + assert_eq!(form.placeholder(), Some("worker-nested")); + assert_eq!(app.hints(), &CREATE_AGENT_HINTS); + } + + #[test] + fn create_form_submits_the_placeholder_name_when_nothing_is_typed() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/fresh", "fresh")])); + let Action::CreateAgent { + manifest, + name, + env_file, + .. + } = app.on_key(key(KeyCode::Enter)) + else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(manifest, PathBuf::from("/sources/fresh/agent.yaml")); + assert_eq!(name, "fresh"); + assert_eq!(env_file, None); + assert!(app.modal.is_none()); + } + + #[test] + fn create_form_accepts_an_environment_file_path() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/fresh", "fresh")])); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + assert_eq!(create_form(&app).field, CreateField::EnvironmentFile); + for character in "../private/fresh.env".chars() { + app.on_key(key(KeyCode::Char(character))); + } + app.on_key(key(KeyCode::Char('x'))); + app.on_key(key(KeyCode::Backspace)); + + let Action::CreateAgent { env_file, .. } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(env_file, Some(PathBuf::from("../private/fresh.env"))); + } + + #[test] + fn create_form_placeholder_follows_selection_and_typed_names_win() { + let mut app = populated(); + app.open_create(candidates(&[("/a", "alpha"), ("/b", "beta")])); + assert_eq!(create_form(&app).placeholder(), Some("alpha")); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).placeholder(), Some("beta")); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('m'))); + app.on_key(key(KeyCode::Char('E'))); + app.on_key(key(KeyCode::Char('y'))); + assert_eq!(create_form(&app).name, "my"); + let Action::CreateAgent { manifest, name, .. } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(manifest, PathBuf::from("/b/agent.yaml")); + assert_eq!(name, "my"); + } + + #[test] + fn create_form_reports_duplicates_and_invalid_names_on_submit() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/worker", "worker")])); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert_eq!( + create_form(&app).error.as_deref(), + Some("agent \"worker\" already exists") + ); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('-'))); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + let error = create_form(&app).error.as_deref().expect("invalid name error"); + assert!(error.starts_with("name:")); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Char('w'))); + app.on_key(key(KeyCode::Char('2'))); + let Action::CreateAgent { name, .. } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(name, "w2"); + } + + #[test] + fn create_form_blocks_unreadable_manifests_and_empty_pickers() { + let mut app = populated(); + app.open_create(vec![ManifestCandidate::new( + PathBuf::from("/gone/agent.yaml"), + Err("manifest cannot be decoded".into()), + )]); + assert_eq!(create_form(&app).placeholder(), None); + app.on_key(key(KeyCode::Enter)); + assert_eq!(create_form(&app).error.as_deref(), Some("manifest cannot be decoded")); + app.on_key(key(KeyCode::Esc)); + assert!(app.modal.is_none()); + app.open_create(Vec::new()); + app.on_key(key(KeyCode::Enter)); + assert!(create_form(&app).error.is_some()); + } + + #[test] + fn create_form_keeps_invalid_variants_visible_but_blocks_submission() { + let mut app = populated(); + app.open_create(vec![ + ManifestCandidate::new(PathBuf::from("/sources/full/agent.yaml"), Ok("full".into())), + ManifestCandidate::new( + PathBuf::from("/sources/full/agent.broken.yaml"), + Err("agent.broken.yaml: missing base".into()), + ), + ]); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Right)); + let form = create_form(&app); + assert_eq!(form.variant_label(), Some("broken".into())); + assert_eq!(form.placeholder(), None); + + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert_eq!( + create_form(&app).error.as_deref(), + Some("agent.broken.yaml: missing base") + ); + } + + #[test] + fn create_form_selection_wraps_and_clears_errors() { + let mut app = populated(); + let mut discovered = candidates(&[("/a", "builder"), ("/b", "beta")]); + discovered.push(ManifestCandidate::new( + PathBuf::from("/a/agent.nested.yaml"), + Ok("builder-nested".into()), + )); + app.open_create(discovered); + app.on_key(key(KeyCode::Enter)); + assert!(create_form(&app).error.is_some()); + app.on_key(key(KeyCode::Left)); + let form = create_form(&app); + assert_eq!(form.agent, 1); + assert_eq!(form.variant, 0); + assert_eq!(form.error, None); + app.on_key(key(KeyCode::Tab)); + assert_eq!(create_form(&app).field, CreateField::Variant); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).variant, 0); + app.on_key(key(KeyCode::BackTab)); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).agent, 0); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).variant, 1); + assert_eq!(create_form(&app).variant_label(), Some("nested".into())); + assert_eq!(create_form(&app).placeholder(), Some("builder-nested")); + } + + #[test] + fn create_form_cycles_fields_with_arrows_and_tab() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/fresh", "fresh")])); + assert_eq!(create_form(&app).field, CreateField::Agent); + + app.on_key(key(KeyCode::Down)); + assert_eq!(create_form(&app).field, CreateField::Variant); + app.on_key(key(KeyCode::Down)); + assert_eq!(create_form(&app).field, CreateField::Name); + app.on_key(key(KeyCode::Up)); + assert_eq!(create_form(&app).field, CreateField::Variant); + app.on_key(key(KeyCode::Tab)); + assert_eq!(create_form(&app).field, CreateField::Name); + app.on_key(key(KeyCode::BackTab)); + assert_eq!(create_form(&app).field, CreateField::Variant); + } + + #[test] + fn a_created_agent_is_shown_and_selected_before_the_watch_reports_it() { + let mut app = populated(); + app.agent_applied(agent("analyst")); + assert_eq!(app.selected_index(), Some(0)); + assert_eq!(app.agents.len(), 3); + + app.apply_snapshot(vec![agent("worker"), agent("builder"), agent("analyst")], Vec::new()); + assert_eq!(app.selected_index(), Some(0), "the watch reply keeps the selection"); + } + + fn failed_agent(name: &str, failure: FailureKind) -> Agent { + let mut agent = agent(name); + agent.status.conditions.push(agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "ImageBuildFailed".into(), + message: "Dockerfile not found".into(), + last_transition_time: None, + }); + agent.status.failure = Some(failure); + agent + } + + fn provisioning_agent(name: &str, failure: Option) -> Agent { + let phase = sandbox::SandboxPhase::ImageResolve.phase(); + let step = sandbox::StepId::generate(); + let mut progress = Progress::new(); + for event in [ + sandbox::ProgressEvent::PhaseStarted { phase }, + sandbox::ProgressEvent::StepStarted { + id: step.clone(), + name: "Pull OCI image".into(), + unit: Some(sandbox::ProgressUnit::Bytes), + total: Some(2048), + }, + sandbox::ProgressEvent::StepProgress { + id: step, + completed: 1024, + total: None, + }, + ] { + progress.apply(&event); + } + let mut agent = failed_agent(name, FailureKind::Transient); + agent.status.failure = failure; + agent.status.progress = Some(agent::progress::Provisioning { + pass: agent::resources::Changes::new().revision(), + progress, + }); + agent + } + + #[test] + fn agent_state_comes_from_typed_status_and_the_pass_in_progress() { + let mut terminating = ready_agent("done"); + terminating.metadata.deletion_timestamp = Some(time::OffsetDateTime::now_utc()); + let mut updating = provisioning_agent("updating", Some(FailureKind::Invalid)); + updating.metadata.generation = 2; + updating.status.observed_generation = 1; + let mut app = App::new(); + app.apply_snapshot( + vec![ + ready_agent("alive"), + terminating, + agent("fresh"), + failed_agent("broken", FailureKind::Invalid), + failed_agent("flaky", FailureKind::Transient), + provisioning_agent("pulling", None), + provisioning_agent("retrying", Some(FailureKind::Transient)), + updating, + ], + Vec::new(), + ); + let states = app + .render_rows() + .into_iter() + .map(|row| (row.name, row.state, row.tone, row.detail)) + .collect::>(); + assert_eq!( + states, + [ + ("alive".into(), "Ready", Tone::Green, String::new()), + ("broken".into(), "Failed", Tone::Red, "Dockerfile not found".into()), + ("done".into(), "Terminating", Tone::Red, String::new()), + ("flaky".into(), "Retrying", Tone::Yellow, "Dockerfile not found".into()), + ("fresh".into(), "Pending", Tone::Gray, String::new()), + ( + "pulling".into(), + "Provisioning", + Tone::Cyan, + "Resolve Sandbox Image · Pull OCI image: 1.0 KiB / 2.0 KiB".into() + ), + ( + "retrying".into(), + "Retrying", + Tone::Yellow, + "Resolve Sandbox Image · Pull OCI image: 1.0 KiB / 2.0 KiB".into() + ), + ( + "updating".into(), + "Provisioning", + Tone::Cyan, + "Resolve Sandbox Image · Pull OCI image: 1.0 KiB / 2.0 KiB".into() + ), + ] + ); + assert_eq!( + app.triage_counts().provisioning, + 2, + "the header counts the rows shown as Provisioning" + ); + } + + #[test] + fn a_held_session_says_why() { + let mut held = session("worker", "s1", "starting"); + held.status.lifecycle.failure = Some("Agent \"worker\" is not ready: the guest stalled".into()); + let mut app = App::new(); + app.apply_snapshot(vec![ready_agent("worker")], vec![held]); + let rows = app.render_rows(); + assert_eq!( + rows[1].detail, + "Claude Code · Agent \"worker\" is not ready: the guest stalled" + ); + } + + #[test] + fn a_stalled_guest_is_shown_as_unresponsive_even_while_a_pass_retries() { + let mut stalled = provisioning_agent("stalled", Some(FailureKind::Transient)); + stalled.status.conditions = vec![ + agent::Condition { + kind: agent::Condition::SANDBOX_RESPONSIVE.into(), + status: ConditionStatus::False, + reason: "HeartbeatStale".into(), + message: "the guest has not reported progress for more than 15s".into(), + last_transition_time: None, + }, + agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "SandboxUnresponsive".into(), + message: "Agent Sandbox is not responding: the guest has not reported progress for more than 15s" + .into(), + last_transition_time: None, + }, + ]; + let mut app = App::new(); + app.apply_snapshot(vec![stalled], Vec::new()); + let rows = app.render_rows(); + assert_eq!( + (rows[0].state, rows[0].tone, rows[0].detail.as_str()), + ( + "Unresponsive", + Tone::Red, + "Agent Sandbox is not responding: the guest has not reported progress for more than 15s" + ) + ); + } + + fn not_ready_agent(name: &str, run_state: RunState, reason: &str) -> Agent { + let mut agent = agent(name); + agent.spec.run_state = Some(run_state); + agent.status.conditions.push(agent::Condition { + kind: agent::Condition::READY.into(), + status: ConditionStatus::False, + reason: reason.into(), + message: String::new(), + last_transition_time: None, + }); + agent + } + + #[test] + fn a_stopped_agent_reads_stopped_and_one_changing_reads_stopping_or_starting() { + let mut app = App::new(); + app.apply_snapshot( + vec![ + not_ready_agent("a-stopped", RunState::Stopped, agent::Condition::REASON_STOPPED), + not_ready_agent("b-stopping", RunState::Stopped, agent::Condition::REASON_STOPPING), + // Asked to stop, but the last pass still recorded it Ready. + { + let mut agent = ready_agent("c-asked"); + agent.spec.run_state = Some(RunState::Stopped); + agent + }, + not_ready_agent("d-starting", RunState::Running, agent::Condition::REASON_STARTING), + ], + Vec::new(), + ); + let states = app + .render_rows() + .into_iter() + .map(|row| (row.name, row.state, row.tone)) + .collect::>(); + assert_eq!( + states, + [ + ("a-stopped".into(), "Stopped", Tone::Gray), + ("b-stopping".into(), "Stopping", Tone::Cyan), + ("c-asked".into(), "Stopping", Tone::Cyan), + ("d-starting".into(), "Starting", Tone::Cyan), + ] + ); + } + + #[test] + fn x_stops_an_agent_once_confirmed_and_starts_a_stopped_one_at_once() { + let mut app = App::new(); + app.apply_snapshot( + vec![ + not_ready_agent("idle", RunState::Stopped, agent::Condition::REASON_STOPPED), + ready_agent("worker"), + ], + Vec::new(), + ); + + app.select_index(1); + assert_eq!(app.hints(), &AGENT_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('x'))), Action::None); + assert!(matches!(&app.modal, Some(Modal::ConfirmStop { agent }) if agent == "worker")); + assert_eq!(app.hints(), &CONFIRM_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None, "declined"); + assert!(app.modal.is_none()); + app.on_key(key(KeyCode::Char('x'))); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::SetRunState { + agent: "worker".into(), + state: RunState::Stopped, + } + ); + + app.select_index(0); + assert_eq!(app.hints(), &STOPPED_AGENT_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('x'))), + Action::SetRunState { + agent: "idle".into(), + state: RunState::Running, + }, + "a start interrupts nothing, so it is not confirmed" + ); + assert_eq!( + app.on_key(key(KeyCode::Char('e'))), + Action::None, + "nothing runs in a stopped Agent" + ); + app.on_key(key(KeyCode::Char('n'))); + assert!(app.modal.is_none(), "no Session is created in a stopped Agent"); + } + + #[test] + fn x_does_not_start_an_agent_whose_stop_is_not_recorded_yet() { + let mut app = App::new(); + app.apply_snapshot( + vec![{ + let mut agent = ready_agent("worker"); + agent.spec.run_state = Some(RunState::Stopped); + agent + }], + Vec::new(), + ); + app.select_index(0); + assert_eq!(app.hints(), &STOPPING_AGENT_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('x'))), + Action::None, + "starting would cancel the stop" + ); + assert!(app.modal.is_none()); + } + + #[test] + fn a_stopped_agents_session_turns_are_not_read() { + let mut app = App::new(); + app.apply_snapshot( + vec![not_ready_agent( + "idle", + RunState::Stopped, + agent::Condition::REASON_STOPPED, + )], + vec![session("idle", "s1", "idle")], + ); + app.side_panel = true; + app.select_index(1); + assert_eq!(app.transcript_request(), None, "its turns live in the stopped guest"); + assert!(app.transcript.as_ref().is_some_and(|transcript| transcript.stopped)); + } + + #[test] + fn a_stopped_agents_session_offers_only_what_works_without_its_sandbox() { + let mut app = App::new(); + app.apply_snapshot( + vec![not_ready_agent( + "idle", + RunState::Stopped, + agent::Condition::REASON_STOPPED, + )], + vec![session("idle", "s1", "idle")], + ); + app.select_index(1); + assert_eq!(app.hints(), &STOPPED_SESSION_HINTS); + for code in [ + KeyCode::Enter, + KeyCode::Char('p'), + KeyCode::Char('n'), + KeyCode::Char('o'), + ] { + assert_eq!(app.on_key(key(code)), Action::None, "{code:?}"); + assert!(app.modal.is_none(), "{code:?} opens nothing"); + } + assert!(matches!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { archived: true, .. } + )); + } + + #[test] + fn time_in_state_is_readys_for_failures_and_the_passs_while_provisioning() { + let entered = OffsetDateTime::now_utc() - time::Duration::minutes(5); + let mut retrying = provisioning_agent("retrying", Some(FailureKind::Transient)); + retrying.status.conditions[0].last_transition_time = Some(entered); + let mut updating = provisioning_agent("updating", None); + updating.status.conditions[0].last_transition_time = Some(entered); + let mut app = App::new(); + app.apply_snapshot(vec![retrying, updating], Vec::new()); + let since = app + .render_rows() + .into_iter() + .map(|row| (row.name, row.since)) + .collect::>(); + assert_eq!( + since, + [("retrying".into(), "5m".into()), ("updating".into(), "0s".into())], + "a retry keeps the time the Agent started failing, and a pass shows its own" + ); + } + + #[test] + fn every_session_state_has_its_own_glyph_and_input_needs_attention() { + let mut app = App::new(); + app.apply_snapshot( + vec![ready_agent("fleet")], + ["waitingForInput", "working", "starting", "idle", "failed"] + .iter() + .enumerate() + .map(|(index, state)| session("fleet", &format!("s{index}"), state)) + .collect(), + ); + let rows = app.render_rows(); + assert!( + rows[0].attention, + "the Agent shows that one of its Sessions needs input" + ); + assert_eq!(rows[0].detail, "5 sessions"); + let sessions = rows[1..] + .iter() + .map(|row| (row.marker, row.state, row.attention)) + .collect::>(); + assert_eq!( + sessions, + [ + ("!", "Needs you", true), + ("*", "Working", false), + ("~", "Starting", false), + ("-", "Idle", false), + ("x", "Failed", false), + ] + ); + assert_eq!(rows[1].detail, "Claude Code"); + } + + #[test] + fn forward_form_mirrors_an_empty_local_port_and_creates_on_enter() { + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Char('f'))), Action::None); + assert!(matches!(app.modal, Some(Modal::PortForward { .. }))); + app.on_key(key(KeyCode::Char('8'))); + app.on_key(key(KeyCode::Char('0'))); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::CreateForward { + agent: "builder".into(), + spec: ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 80, + guest_port: 80, + }, + replace: None, + } + ); + assert!(app.modal.is_none()); + } + + #[test] + fn forward_form_cycles_fields_and_reports_invalid_input() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('f'))); + app.on_key(key(KeyCode::Enter)); + assert!(matches!( + app.modal, + Some(Modal::PortForward(ForwardForm { error: Some(_), .. })) + )); + app.on_key(key(KeyCode::Tab)); + let Some(Modal::PortForward(form)) = &app.modal else { + panic!("expected the PortForward modal"); + }; + assert_eq!(form.field, ForwardField::Address); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('9'))); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('8'))); + app.on_key(key(KeyCode::Char('0'))); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::CreateForward { + agent: "builder".into(), + spec: ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 9, + guest_port: 80, + }, + replace: None, + } + ); + } + + #[test] + fn forwards_view_lists_deletes_and_edits_like_k9s() { + let mut app = populated(); + app.set_forwards(vec![ForwardEntry { + id: 7, + agent: "worker".into(), + local: "127.0.0.1:9090".into(), + guest_port: 80, + status: None, + finished: false, + }]); + app.on_key(key(KeyCode::Char('F'))); + assert_eq!(app.view, View::Forwards); + assert_eq!( + app.on_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Action::DeleteForward { id: 7 } + ); + app.on_key(key(KeyCode::Char('e'))); + let Some(Modal::PortForward(form)) = &app.modal else { + panic!("expected the PortForward modal"); + }; + assert_eq!(form.replace, Some(7)); + assert_eq!(form.local, "9090"); + assert_eq!(form.guest, "80"); + app.on_key(key(KeyCode::Esc)); + assert!(app.modal.is_none()); + app.on_key(key(KeyCode::Char('q'))); + assert_eq!(app.view, View::Tree); + } + + #[test] + fn error_screen_keys_win_over_an_open_forwards_view() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('F'))); + app.error = Some("control plane unreachable".into()); + assert_eq!(app.on_key(key(KeyCode::Char('q'))), Action::Quit); + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::None); + assert!(app.error.is_none()); + assert_eq!(app.view, View::Forwards, "dismissing the error returns to the forwards"); + app.on_key(key(KeyCode::Char('q'))); + assert_eq!(app.view, View::Tree); + } + + #[test] + fn rejected_forwards_reopen_the_form_with_their_values() { + let spec = ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 0, + guest_port: 5432, + }; + let form = ForwardForm::rejected("worker".into(), &spec, Some(3), "boom".into()); + assert_eq!(form.agent, "worker"); + assert_eq!(form.address, "127.0.0.1"); + assert_eq!(form.local, ""); + assert_eq!(form.guest, "5432"); + assert_eq!(form.field, ForwardField::Address); + assert_eq!(form.error.as_deref(), Some("boom")); + assert_eq!(form.replace, Some(3)); + } + + #[cfg(target_os = "macos")] + #[test] + fn low_loopback_ports_get_the_wildcard_bind_hint() { + let spec = ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 80, + guest_port: 80, + }; + let form = ForwardForm::rejected( + "worker".into(), + &spec, + None, + "bind: Permission denied (os error 13)".into(), + ); + let error = form.error.expect("rejected form should keep its error"); + assert!(error.contains("macOS allows ports below 1024 only on 0.0.0.0")); + + let wildcard = ForwardSpec { + address: std::net::IpAddr::from([0, 0, 0, 0]), + ..spec + }; + let form = ForwardForm::rejected("worker".into(), &wildcard, None, "Permission denied".into()); + assert_eq!(form.error.as_deref(), Some("Permission denied")); + } + + #[test] + fn agent_badges_list_forward_mappings() { + let mut app = populated(); + app.set_forwards(vec![ + ForwardEntry { + id: 1, + agent: "worker".into(), + local: "127.0.0.1:9090".into(), + guest_port: 80, + status: None, + finished: false, + }, + ForwardEntry { + id: 2, + agent: "worker".into(), + local: "0.0.0.0:80".into(), + guest_port: 80, + status: None, + finished: false, + }, + ]); + let views = app.render_rows(); + assert!(!views[0].detail.contains("ports:")); + assert!(views[2].detail.contains("ports: 9090:80 0.0.0.0:80:80")); + } + + #[test] + fn a_created_agents_provisioning_is_followed_until_its_detail_closes() { + let mut app = populated(); + app.agent_applied(agent("analyst")); + assert_eq!(app.followed_agent(), Some("analyst")); + + app.provisioning_followed("worker", vec!["stale".into()]); + app.provisioning_followed("analyst", vec!["Ready: True".into()]); + assert_eq!( + app.detail.as_ref().map(|detail| detail.lines.clone()), + Some(vec!["Ready: True".into()]) + ); + + app.on_key(key(KeyCode::Char('q'))); + assert_eq!(app.followed_agent(), None); + app.select_index(1); + app.on_key(key(KeyCode::Char('p'))); + assert_eq!(app.followed_agent(), Some("builder"), "p follows the selected Agent"); + } + + #[test] + fn a_prompt_is_sent_in_place_and_a_failure_brings_its_input_back() { + let mut app = populated(); + app.select_index(1); + app.on_key(key(KeyCode::Char('p'))); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::None, + "an empty prompt is not sent" + ); + assert!(matches!(&app.modal, Some(Modal::Prompt(form)) if form.error.is_some())); + type_text(&mut app, "go on"); + let Action::Prompt(form) = app.on_key(key(KeyCode::Enter)) else { + panic!("expected the prompt to be sent"); + }; + assert_eq!( + (form.agent.as_str(), form.session.as_str(), form.input.as_str()), + ("builder", "b1", "go on") + ); + assert!(app.modal.is_none()); + + app.prompt_failed(form, "session is not running".into()); + assert!(matches!( + &app.modal, + Some(Modal::Prompt(form)) if form.input == "go on" && form.error.as_deref() == Some("session is not running") + )); + } + + #[test] + fn turns_load_for_the_selected_session_and_again_when_it_finishes_a_turn() { + let mut app = populated(); + app.select_index(1); + assert_eq!( + app.transcript_request(), + None, + "no turns load while the panel is hidden" + ); + app.side_panel = true; + app.select_index(0); + assert_eq!(app.transcript_request(), None, "an Agent row has no turns"); + app.select_index(1); + let target = app.transcript_request().expect("the selected Session's turns"); + assert_eq!(app.transcript_request(), None, "one request at a time"); + app.select_index(3); + assert_eq!( + app.transcript_request(), + None, + "another Session waits for the load in flight" + ); + app.select_index(1); + assert_eq!(app.transcript_request(), None, "coming back waits for the same load"); + app.transcript_loaded(&target.0, &target.1, Ok(Vec::new())); + assert_eq!(app.transcript_request(), None, "nothing changed"); + + let mut sessions = app.sessions.clone(); + sessions[0].status.reported.activity.turns += 1; + let agents = app.agents.clone(); + app.apply_snapshot(agents, sessions); + assert_eq!(app.transcript_request(), Some(target)); + + app.select_index(0); + assert_eq!(app.transcript_request(), None); + assert!(app.transcript.is_none()); + } + + #[test] + fn describe_opens_a_detail_view_that_scrolls_and_closes() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('s'))); + let detail = app.detail.as_ref().expect("agent detail"); + assert_eq!(detail.title, "agent/builder"); + app.on_key(key(KeyCode::Char('j'))); + assert_eq!(app.detail.as_ref().expect("agent detail").scroll, 1); + app.on_key(key(KeyCode::Char('q'))); + assert!(app.detail.is_none()); + app.select_index(1); + app.on_key(key(KeyCode::Char('s'))); + assert_eq!(app.detail.as_ref().expect("session detail").title, "session/builder/b1"); + } + + #[test] + fn yaml_views_render_the_full_resource() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('y'))); + let detail = app.detail.as_ref().expect("agent yaml"); + assert_eq!(detail.title, "agent/builder yaml"); + assert!(detail.lines.iter().any(|line| line == "kind: Agent")); + assert!(detail.lines.iter().any(|line| line.contains("apiVersion:"))); + assert!(detail.lines.iter().any(|line| line.contains("harnesses:"))); + app.on_key(key(KeyCode::Char('q'))); + app.select_index(1); + app.on_key(key(KeyCode::Char('y'))); + let detail = app.detail.as_ref().expect("session yaml"); + assert_eq!(detail.title, "session/builder/b1 yaml"); + assert!(detail.lines.iter().any(|line| line.contains("harness: claudeCode"))); + assert!(detail.lines.iter().any(|line| line.contains("name: b1"))); + } + + fn ssh_app(setup: SshSetup) -> App { + let mut worker = ready_agent("worker"); + worker.spec.access = vec![agent::AccessSpec::Ssh {}]; + let mut app = App::new(); + app.ssh_setup = setup; + app.ssh_include = Some(agent::ssh::UserInclude { + user_config: "/tmp/user/.ssh/config".into(), + line: "Include ~/.agent/ssh/config".into(), + }); + app.apply_snapshot(vec![worker], vec![session("worker", "main", "working")]); + app + } + + #[test] + fn o_opens_the_menu_of_the_selected_agent_or_of_a_sessions_agent() { + let mut app = ssh_app(SshSetup::Installed); + app.selection = Some(TreeRowId::Agent("worker".into())); + assert_eq!(app.on_key(key(KeyCode::Char('o'))), Action::None); + assert!(matches!(&app.modal, Some(Modal::Open(menu)) if menu.agent == "worker")); + assert_eq!(app.hints(), &OPEN_HINTS); + + app.modal = None; + app.selection = Some(TreeRowId::Session { + agent: "worker".into(), + session: SessionName::new("main").expect("name"), + }); + app.on_key(key(KeyCode::Char('o'))); + assert!(matches!(&app.modal, Some(Modal::Open(menu)) if menu.agent == "worker")); + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::None); + assert!(app.modal.is_none()); + } + + #[test] + fn choosing_an_item_opens_it_in_the_agent() { + let mut app = ssh_app(SshSetup::Installed); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!( + app.on_key(key(KeyCode::Char('c'))), + Action::Open { + agent: "worker".into(), + target: OpenTarget::Editor(crate::launch::Editor::VsCode), + } + ); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::Open { + agent: "worker".into(), + target: OpenTarget::Shell, + }, + "the shell is selected first" + ); + } + + #[test] + fn an_editor_asks_for_the_missing_ssh_setup_and_then_opens() { + let mut app = ssh_app(SshSetup::Missing); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::None); + let editor = OpenTarget::Editor(crate::launch::Editor::VsCode); + assert!(matches!(&app.modal, Some(Modal::ConfirmSshSetup { then: Some(target), .. }) if *target == editor)); + assert_eq!(app.hints(), &CONFIRM_SSH_SETUP_THEN_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::None, + "y never writes the file" + ); + + // Declining returns to the menu, which still offers the setup. + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::None); + assert!(matches!(&app.modal, Some(Modal::Open(menu)) + if menu.items.iter().any(|item| item.entry == MenuEntry::SetUpSsh))); + + app.on_key(key(KeyCode::Char('c'))); + let then = Some(("worker".to_owned(), editor)); + let Action::SetUpSsh { include, then: next } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected SetUpSsh"); + }; + assert_eq!(include.line, "Include ~/.agent/ssh/config"); + assert_eq!(next, then); + assert_eq!( + app.ssh_set_up(Ok(include.user_config), next, Instant::now()), + Some(Action::Open { + agent: "worker".into(), + target: editor, + }) + ); + assert_eq!(app.ssh_setup, SshSetup::Installed); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("SSH set up in /tmp/user/.ssh/config") + ); + + // Once set up, the editor opens directly. + app.on_key(key(KeyCode::Char('o'))); + assert!(matches!(app.on_key(key(KeyCode::Char('c'))), Action::Open { .. })); + } + + #[test] + fn setting_up_ssh_from_its_own_entry_opens_nothing_after() { + let mut app = ssh_app(SshSetup::Missing); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + let Some(Modal::Open(menu)) = &app.modal else { + panic!("expected the open menu"); + }; + let setup = menu + .items + .iter() + .position(|item| item.entry == MenuEntry::SetUpSsh) + .expect("setup entry"); + app.on_mouse(MouseAction::ChooseOpen(setup)); + assert!(matches!(app.modal, Some(Modal::ConfirmSshSetup { then: None, .. }))); + assert_eq!(app.hints(), &CONFIRM_SSH_SETUP_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::None, + "nothing waits to open" + ); + assert!(matches!(app.modal, Some(Modal::ConfirmSshSetup { then: None, .. }))); + let Action::SetUpSsh { include, then } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected SetUpSsh"); + }; + assert_eq!(then, None); + assert_eq!(app.ssh_set_up(Ok(include.user_config), then, Instant::now()), None); + } + + #[test] + fn what_waits_on_the_ssh_setup_opens_without_it_on_request() { + let mut app = ssh_app(SshSetup::Missing); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + app.on_key(key(KeyCode::Char('y'))); + assert!(matches!(app.modal, Some(Modal::ConfirmSshSetup { .. }))); + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::Open { + agent: "worker".into(), + target: OpenTarget::CopyAlias, + } + ); + assert!(app.modal.is_none()); + assert_eq!(app.ssh_setup, SshSetup::Missing, "nothing was written"); + } + + #[test] + fn the_ssh_setup_is_checked_with_an_agent_openssh_knows_and_again_on_each_menu() { + let mut app = App::new(); + app.apply_snapshot(vec![ready_agent("builder")], Vec::new()); + assert_eq!(app.ssh_check_request(), None, "no Agent has an alias to resolve"); + + let mut app = ssh_app(SshSetup::Unknown); + let mut other = ready_agent("alpha"); + other.spec.access = vec![agent::AccessSpec::Ssh {}]; + let worker = app.agents[0].clone(); + app.apply_snapshot(vec![other, worker], Vec::new()); + assert_eq!(app.ssh_check_request().as_deref(), Some("alpha")); + assert_eq!(app.ssh_check_request(), None, "one check at a time"); + app.ssh_checked("alpha", SshSetup::Missing); + assert_eq!(app.ssh_setup, SshSetup::Missing); + assert_eq!(app.ssh_check_request(), None, "checked until a menu opens"); + + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!( + app.ssh_check_request().as_deref(), + Some("worker"), + "the menu's Agent first" + ); + } + + #[test] + fn a_finished_ssh_check_updates_the_open_menu_and_keeps_its_selection() { + let mut app = ssh_app(SshSetup::Unknown); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + app.on_key(key(KeyCode::Down)); + let setup_offered = |app: &App| { + matches!(&app.modal, Some(Modal::Open(menu)) + if menu.items.iter().any(|item| item.entry == MenuEntry::SetUpSsh)) + }; + assert!(!setup_offered(&app)); + + assert_eq!(app.ssh_check_request().as_deref(), Some("worker")); + app.ssh_checked("worker", SshSetup::Missing); + assert!(setup_offered(&app)); + let Some(Modal::Open(menu)) = &app.modal else { + panic!("expected the open menu"); + }; + assert_eq!( + menu.chosen(), + Some(MenuEntry::Open(OpenTarget::Editor(crate::launch::Editor::VsCode))) + ); + + app.ssh_checked("worker", SshSetup::Installed); + assert!(!setup_offered(&app)); + } + + #[test] + fn clicking_an_unavailable_item_keeps_the_menu_open() { + let mut app = ssh_app(SshSetup::Installed); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + let Some(Modal::Open(menu)) = &app.modal else { + panic!("expected the open menu"); + }; + let zed = menu + .items + .iter() + .position(|item| item.unavailable.is_some()) + .expect("Zed has no launcher in tests"); + assert_eq!(app.on_mouse(MouseAction::ChooseOpen(zed)), Action::None); + assert!(matches!(app.modal, Some(Modal::Open(_)))); + } + + #[test] + fn a_failed_ssh_setup_is_reported_and_opens_nothing() { + let mut app = ssh_app(SshSetup::Missing); + let then = Some(("worker".to_owned(), OpenTarget::CopyAlias)); + + assert_eq!( + app.ssh_set_up(Err("read-only file system".into()), then, Instant::now()), + None + ); + assert_eq!(app.error.as_deref(), Some("read-only file system")); + assert_eq!(app.ssh_setup, SshSetup::Missing); + } + + #[test] + fn the_agent_panel_shows_how_to_connect() { + let app = ssh_app(SshSetup::Missing); + let lines = app.agent_panel_lines("worker"); + let connect = lines + .iter() + .position(|line| line == "Connect · o open…") + .expect("Connect section"); + assert_eq!(lines[connect + 1], " shell in this terminal"); + assert!(lines.contains(&" ! SSH not set up; o offers it".to_owned())); + assert_eq!(lines.last().map(String::as_str), Some(" ssh alias agentctl-worker")); + assert!( + lines.iter().all(|line| !line.contains("agentctl ")), + "the panel offers keys, not commands: {lines:?}" + ); + } + + #[test] + fn one_open_waits_per_agent_and_target_and_ends_in_a_notice_or_an_error() { + let mut app = ssh_app(SshSetup::Installed); + let now = Instant::now(); + let vs_code = OpenTarget::Editor(crate::launch::Editor::VsCode); + assert!(app.start_opening("worker", vs_code, now)); + assert!( + !app.start_opening("worker", vs_code, now), + "a repeat waits for the first" + ); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("already opening VS Code on worker") + ); + assert!(app.start_opening("worker", OpenTarget::Editor(crate::launch::Editor::Zed), now)); + + app.opened( + Some(("worker".into(), vs_code)), + Ok("opening VS Code on worker".into()), + now, + ); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("opening VS Code on worker") + ); + app.opened( + Some(("worker".into(), OpenTarget::Editor(crate::launch::Editor::Zed))), + Err("zed failed".into()), + now, + ); + assert_eq!(app.error.as_deref(), Some("zed failed")); + assert!(app.opening.is_empty()); + } + + fn desktop_forward(id: u64, agent: &str, guest_port: u16) -> ForwardEntry { + ForwardEntry { + id, + agent: agent.into(), + local: format!("127.0.0.1:{}", 50000 + id), + guest_port, + status: None, + finished: false, + } + } + + #[test] + fn quitting_asks_first_while_forwards_would_close() { + let mut app = ssh_app(SshSetup::Installed); + assert_eq!(app.on_key(key(KeyCode::Char('q'))), Action::Quit, "nothing to lose"); + + app.set_forwards(vec![desktop_forward(1, "worker", 6080)]); + assert_eq!(app.on_key(key(KeyCode::Char('q'))), Action::None); + assert!(matches!(app.modal, Some(Modal::ConfirmQuit))); + assert_eq!(app.hints(), &CONFIRM_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None); + assert!(app.modal.is_none(), "n stays"); + app.on_key(key(KeyCode::Esc)); + assert_eq!(app.on_key(key(KeyCode::Char('y'))), Action::Quit); + } + + #[test] + fn a_forward_opens_in_the_application_for_its_port() { + let mut app = ssh_app(SshSetup::Installed); + app.set_forwards(vec![ + desktop_forward(1, "worker", 6080), + desktop_forward(2, "worker", agent::vnc::GUEST_PORT), + ]); + app.view = View::Forwards; + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::OpenUrl("http://127.0.0.1:50001/".into()) + ); + app.on_key(key(KeyCode::Char('j'))); + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::OpenUrl("vnc://127.0.0.1:50002".into()) + ); + } + + #[test] + fn labeled_forwards_name_themselves_and_the_panel_shows_the_open_desktop() { + let mut app = ssh_app(SshSetup::Installed); + let mut desktop = ready_agent("desk"); + desktop.spec.access = vec![agent::AccessSpec::Ssh {}, agent::AccessSpec::Vnc {}]; + let mut agents = std::mem::take(&mut app.agents); + agents.push(desktop); + let sessions = std::mem::take(&mut app.sessions); + app.apply_snapshot(agents, sessions); + app.set_forwards(vec![desktop_forward(1, "desk", 6080)]); + + let desk = app + .render_rows() + .into_iter() + .find(|row| row.name == "desk") + .expect("desk row"); + assert!(desk.detail.contains("ports: desktop 50001:6080"), "{}", desk.detail); + assert!( + app.agent_panel_lines("desk") + .contains(&" desktop open at http://127.0.0.1:50001/".to_owned()) + ); + + let mut stopped = desktop_forward(1, "desk", 6080); + stopped.finished = true; + app.set_forwards(vec![stopped]); + assert!( + app.agent_panel_lines("desk") + .contains(&" desktop browser · VNC client".to_owned()), + "a stopped forward's address no longer opens anything" + ); + } +} diff --git a/agentctl/src/bin/agentctl/tui/mod.rs b/agentctl/src/bin/agentctl/tui/mod.rs new file mode 100644 index 0000000..b9115b0 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/mod.rs @@ -0,0 +1,1548 @@ +mod app; +mod open; +mod provisioning; +mod terminal; +mod view; + +use std::{ + collections::HashMap, + io::IsTerminal as _, + path::{Path, PathBuf}, + process::ExitCode, + time::{Duration, Instant}, +}; + +use agent::{ + Agent, Error, control_api::Client, control_plane::WaitPolicy, local::home::ControlPlaneHome, manifest, + resources::Resources, sessions::Session, sessions::SessionName, sessions::SessionRequest, sessions::Turn, +}; +use crossterm::event::{ + Event, EventStream, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use futures_util::StreamExt as _; +use ignore::WalkBuilder; +use sandbox::terminal::TerminalAttachOutcome; + +use crate::CommandResult; +use crate::forward::{ForwardSpec, PortForward}; +use crate::progress::Wait; +use agent::manifest::MANIFEST_FILE; +use app::{ + Action, App, CreateForm, ForwardEntry, ForwardForm, ManifestCandidate, Modal, MouseAction, PromptForm, RowTarget, +}; +use open::{DesktopViewer, OpenTarget, SshSetup}; +use terminal::Tui; +use view::{HitMap, HitTarget, WheelTarget}; + +/// Turns of the selected Session shown beside the tree. +const TRANSCRIPT_TURNS: usize = 3; +/// Pause before watching again after the daemon could not be reached. +const RECONNECT_INTERVAL: Duration = Duration::from_secs(1); +/// How often the screen is redrawn without input, so times keep moving. +const REDRAW_INTERVAL: Duration = Duration::from_secs(1); +const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(500); +/// Deepest directory level below the working directory searched for manifests. +const DISCOVERY_DEPTH: usize = 8; + +enum Input { + Event(Option>), + /// A watch reply, or why the daemon could not be watched. + Resources(Result), + /// The lines of an Agent's followed provisioning. + Provisioning { + agent: String, + lines: Vec, + }, + TranscriptLoaded { + agent: String, + session: SessionName, + turns: Result, String>, + }, + PromptSent(PromptForm, Result<(), String>), + /// A background Session change failed; its success shows through the watch. + SessionChangeFailed(String), + /// A Session was archived or unarchived, as recorded. + ArchiveChanged(Session), + ForwardCreated(CreateOutcome), + ManifestsDiscovered(Vec), + /// An action a finished step leads to, run in turn with the input already waiting. + Then(Action), + /// OpenSSH resolved an Agent's alias. + SshChecked { + agent: String, + setup: SshSetup, + }, + /// A background open finished; `waiting` is what it ends the wait for. + Opened { + waiting: Option<(String, OpenTarget)>, + outcome: Result, + }, +} + +/// Sends the event loop what background work finished. +type Inputs = tokio::sync::mpsc::UnboundedSender; + +/// Completion of one background forward creation. +type CreateOutcome = (String, ForwardSpec, Option, Result); + +#[derive(Default)] +struct MouseInput { + last_row: Option<(RowTarget, Instant)>, + position: Option<(u16, u16)>, +} + +impl MouseInput { + fn reset(&mut self) { + self.last_row = None; + } + + const fn position(&self) -> Option<(u16, u16)> { + self.position + } + + fn double_click(&mut self, row: &RowTarget, now: Instant) -> bool { + let double = self + .last_row + .as_ref() + .is_some_and(|(previous, at)| previous == row && now.duration_since(*at) <= DOUBLE_CLICK_INTERVAL); + if double { + self.reset(); + } else { + self.last_row = Some((row.clone(), now)); + } + double + } + + fn action(&mut self, event: MouseEvent, hit_map: &HitMap, app: &mut App, now: Instant) -> Action { + self.position = Some((event.column, event.row)); + if !event.modifiers.is_empty() { + self.reset(); + return Action::None; + } + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + let Some(target) = hit_map.click_at(event.column, event.row) else { + self.reset(); + return Action::None; + }; + match target { + HitTarget::Action(action) => { + self.reset(); + app.on_mouse(action) + } + HitTarget::Row(row) => { + if self.double_click(&row, now) { + app.on_mouse(MouseAction::Primary(row)) + } else { + app.on_mouse(MouseAction::Select(row)) + } + } + } + } + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + self.reset(); + let delta = if event.kind == MouseEventKind::ScrollUp { -1 } else { 1 }; + match hit_map.wheel_at(event.column, event.row) { + Some(WheelTarget::Tree) => app.on_mouse(MouseAction::MoveTree(delta)), + Some(WheelTarget::Forwards) => app.on_mouse(MouseAction::MoveForward(delta)), + Some(WheelTarget::Detail) => app.on_mouse(MouseAction::ScrollDetail(delta)), + None => Action::None, + } + } + MouseEventKind::Down(_) | MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => { + self.reset(); + Action::None + } + MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::Moved => Action::None, + } + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn run(home: &ControlPlaneHome, client: &Client) -> CommandResult { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err(Error::Invalid("tui requires an interactive local terminal".into()).into()); + } + let mut app = App::new(); + app.environment = open::Environment::detect(); + app.ssh_include = agent::ssh::UserInclude::for_home(home).ok(); + let mut forwards = ActiveForwards::default(); + let (inputs, mut background) = tokio::sync::mpsc::unbounded_channel(); + let mut tui = Tui::enter()?; + let mut events = EventStream::new(); + let mut mouse = MouseInput::default(); + let mut follow = Follow::default(); + let mut redraw = tokio::time::interval(REDRAW_INTERVAL); + redraw.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + spawn_watch(home.socket_path(), inputs.clone()); + loop { + app.open_queued_create(); + app.set_forwards(forwards.entries()); + follow.sync(app.followed_agent(), home.socket_path(), &inputs); + app.side_panel = view::shows_side_panel(tui.width()); + app.expire_notice(Instant::now()); + if let Some((agent, session)) = app.transcript_request() { + spawn_transcript(home.socket_path(), inputs.clone(), agent, session); + } + if let Some(agent) = app.ssh_check_request() { + spawn_ssh_check(inputs.clone(), agent); + } + let hit_map = tui.draw(&app)?; + tui.set_pointer_for(&hit_map, mouse.position())?; + let input = tokio::select! { + event = events.next() => Input::Event(event), + Some(input) = background.recv() => input, + // Times in state and elapsed step times move without new input. + _ = redraw.tick() => continue, + }; + let action = match input { + Input::Resources(Ok(resources)) => { + app.connection_error = None; + forwards.prune(&resources.agents); + app.apply_snapshot(resources.agents, resources.sessions); + continue; + } + Input::Resources(Err(error)) => { + app.connection_error = Some(error); + continue; + } + Input::Provisioning { agent, lines } => { + app.provisioning_followed(&agent, lines); + continue; + } + Input::TranscriptLoaded { agent, session, turns } => { + app.transcript_loaded(&agent, &session, turns); + continue; + } + Input::PromptSent(form, result) => { + app.prompting = app.prompting.saturating_sub(1); + if let Err(error) = result { + app.prompt_failed(form, error); + } + continue; + } + Input::SessionChangeFailed(error) => { + app.error = Some(error); + continue; + } + Input::ArchiveChanged(session) => { + app.archive_changed(session, Instant::now()); + continue; + } + Input::ForwardCreated(outcome) => { + mouse.reset(); + forward_created(&mut app, &mut forwards, outcome); + continue; + } + Input::ManifestsDiscovered(candidates) => { + mouse.reset(); + app.manifests_discovered(candidates); + continue; + } + Input::Then(action) => action, + Input::SshChecked { agent, setup } => { + app.ssh_checked(&agent, setup); + continue; + } + Input::Opened { waiting, outcome } => { + open_finished(&mut app, &mut forwards, waiting, outcome); + continue; + } + Input::Event(None) => { + tui.restore()?; + return Ok(ExitCode::SUCCESS); + } + Input::Event(Some(Err(error))) => { + tui.restore()?; + return Err(Error::from(error).into()); + } + Input::Event(Some(Ok(Event::Key(key)))) if key.kind == KeyEventKind::Press => { + mouse.reset(); + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + tui.restore()?; + return Ok(ExitCode::SUCCESS); + } + app.on_key(key) + } + Input::Event(Some(Ok(Event::Mouse(event)))) => mouse.action(event, &hit_map, &mut app, Instant::now()), + Input::Event(Some(Ok(_))) => { + mouse.reset(); + continue; + } + }; + match action { + Action::None => {} + Action::Quit => { + tui.restore()?; + return Ok(ExitCode::SUCCESS); + } + Action::Delete { agent } => { + if let Err(error) = client.delete(&agent).await { + app.error = Some(error.to_string()); + } + } + Action::SetRunState { agent, state } => { + if let Err(error) = client.set_run_state(&agent, state).await { + app.error = Some(error.to_string()); + } + } + Action::OpenCreate => { + if !app.discovering { + app.discovering = true; + spawn_discovery(inputs.clone(), app.agents.clone()); + } + } + Action::CreateAgent { + manifest, + name, + env_file, + form, + } => create(&mut app, client, manifest, name, env_file, form).await, + Action::CreateForward { agent, spec, replace } => { + if let Some(id) = replace { + forwards.remove(id); + } + app.creating += 1; + spawn_create(home, inputs.clone(), agent, spec, replace); + } + Action::DeleteSession { agent, session } => { + spawn_session_delete(home.socket_path(), inputs.clone(), agent, session); + } + Action::SetArchived { + agent, + session, + archived, + } => spawn_session_archive(home.socket_path(), inputs.clone(), agent, session, archived), + Action::DeleteForward { id } => forwards.remove(id), + Action::Prompt(form) => { + app.prompting += 1; + spawn_prompt(home.socket_path(), inputs.clone(), form); + } + Action::Open { + agent, + target: target @ OpenTarget::Editor(editor), + } => { + if app.start_opening(&agent, target, Instant::now()) { + let launcher = app.environment.launcher(editor).map(Path::to_path_buf); + let outside = Outside::Editor { + agent: agent.clone(), + editor, + launcher, + }; + spawn_open(home, inputs.clone(), Some((agent, target)), outside, false); + } + } + Action::Open { + agent, + target: OpenTarget::CopyAlias, + } => { + let alias = agent::ssh::alias(&agent); + terminal::copy_to_clipboard(&alias)?; + app.notice = Some((format!("copied {alias}"), Instant::now())); + } + Action::SetUpSsh { include, then } => { + // One line in one small file: written at once, so nothing can + // ask for the setup again while it is being written. + let result = include + .install() + .map(|_| include.user_config.clone()) + .map_err(|error| error.to_string()); + if let Some(next) = app.ssh_set_up(result, then, Instant::now()) { + let _ = inputs.send(Input::Then(next)); + } + } + Action::Open { + agent, + target: target @ OpenTarget::Desktop(viewer), + } => { + if app.start_opening(&agent, target, Instant::now()) { + // An Agent's desktop already forwarded is opened again rather than twice. + let outside = forwards.desktop(&agent, viewer).map_or_else( + || Outside::Desktop { + agent: agent.clone(), + viewer, + }, + Outside::Forward, + ); + let copy = app.environment.launch_blocked.is_some(); + spawn_open(home, inputs.clone(), Some((agent, target)), outside, copy); + } + } + Action::OpenUrl(url) => { + let copy = app.environment.launch_blocked.is_some(); + spawn_open(home, inputs.clone(), None, Outside::Forward(url), copy); + } + action => { + drop(events); + suspended(&mut app, &mut tui, home, client, action).await?; + events = EventStream::new(); + } + } + } +} + +/// Whether `forward` dials the Sandbox the Agent named `agent` has now. An +/// Agent whose Sandbox is not materialized yet keeps what it has. +fn dials_current_sandbox(agents: &[Agent], agent: &str, forward: &PortForward) -> bool { + agents.iter().any(|listed| { + listed.metadata.name == agent + && listed + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .is_none_or(|id| Some(id) == forward.assignment().id()) + }) +} + +/// Process-owned port forwards keyed by a stable per-run identity. +#[derive(Default)] +struct ActiveForwards { + next_id: u64, + active: Vec<(u64, String, PortForward)>, +} + +impl ActiveForwards { + /// Holds `forward` while its Agent still has the Sandbox it dials. A + /// forward that finished starting after its Agent was deleted or re-created + /// is stopped at once, and `false` says so. + fn push(&mut self, agent: String, forward: PortForward, agents: &[Agent]) -> bool { + if !dials_current_sandbox(agents, &agent, &forward) { + return false; + } + let id = self.next_id; + self.next_id += 1; + self.active.push((id, agent, forward)); + true + } + + fn remove(&mut self, id: u64) { + self.active.retain(|(entry, _, _)| *entry != id); + } + + /// Stops forwards whose Sandbox their Agent no longer has: the Agent was + /// deleted, or re-created under the same name with a Sandbox of its own. + /// An Agent whose Sandbox is not materialized yet keeps its forwards. + fn prune(&mut self, agents: &[Agent]) { + self.active + .retain(|(_, name, forward)| dials_current_sandbox(agents, name, forward)); + } + + /// The address that opens the Agent's forward to `viewer`, while it still serves. + fn desktop(&self, agent: &str, viewer: DesktopViewer) -> Option { + self.active + .iter() + .map(|(_, name, forward)| (name, forward)) + .find(|(name, forward)| { + *name == agent && forward.spec().guest_port == viewer.guest_port() && !forward.finished() + }) + .map(|(_, forward)| crate::launch::forward_url(forward.local_address(), forward.spec().guest_port)) + } + + fn entries(&self) -> Vec { + self.active + .iter() + .map(|(id, agent, forward)| ForwardEntry { + id: *id, + agent: agent.clone(), + local: forward.local_address().to_string(), + guest_port: forward.spec().guest_port, + status: forward.status(), + finished: forward.finished(), + }) + .collect() + } +} + +/// Follows every Agent and Session, sending each state the daemon reports. +/// +/// Each reply is the complete current state, so a reply lost to a reconnect +/// needs no recovery: the next one supersedes it. +fn spawn_watch(socket_path: PathBuf, inputs: Inputs) { + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let mut after = None; + loop { + let reply = match client.watch_resources(after).await { + Ok(resources) => { + after = Some(resources.revision); + Ok(resources) + } + // An upgraded daemon may no longer speak this client's protocol, + // which explains the failure better than the failed call does. + Err(error) => Err(client.require_compatible_daemon().await.err().unwrap_or(error)), + }; + let failed = reply.is_err(); + if inputs + .send(Input::Resources(reply.map_err(|error| error.to_string()))) + .is_err() + { + return; + } + if failed { + tokio::time::sleep(RECONNECT_INTERVAL).await; + } + } + }); +} + +/// The task following the provisioning an open detail shows, at most one. +#[derive(Default)] +struct Follow { + task: Option<(String, tokio::task::JoinHandle<()>)>, +} + +impl Follow { + /// Follows `agent`, stopping the previous task when the agent changes. + fn sync(&mut self, agent: Option<&str>, socket_path: PathBuf, inputs: &Inputs) { + if self.task.as_ref().map(|(followed, _)| followed.as_str()) == agent { + return; + } + if let Some((_, task)) = self.task.take() { + task.abort(); + } + self.task = agent.map(|agent| { + ( + agent.to_owned(), + spawn_follow(socket_path, agent.to_owned(), inputs.clone()), + ) + }); + } +} + +/// Follows one Agent's provisioning through `agents.v1.progress`, sending the +/// lines to show after every reply. +fn spawn_follow(socket_path: PathBuf, agent: String, inputs: Inputs) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let mut followed = provisioning::Followed::default(); + loop { + let (after, output) = followed.position(); + let lines = match client.agent_progress(&agent, after, output).await { + Ok(progress) => { + followed.apply(progress); + followed.lines() + } + Err(error) => { + tokio::time::sleep(RECONNECT_INTERVAL).await; + vec![format!("Cannot follow provisioning: {error}")] + } + }; + let agent = agent.clone(); + if inputs.send(Input::Provisioning { agent, lines }).is_err() { + return; + } + } + }) +} + +/// Checks in the background how OpenSSH resolves `agent`'s alias. +fn spawn_ssh_check(inputs: Inputs, agent: String) { + tokio::task::spawn_local(async move { + let setup = SshSetup::check(&agent).await; + let _ = inputs.send(Input::SshChecked { agent, setup }); + }); +} + +/// What `o` opens outside this terminal. +enum Outside { + Editor { + agent: String, + editor: crate::launch::Editor, + launcher: Option, + }, + /// The Agent's desktop, through a new forward. + Desktop { agent: String, viewer: DesktopViewer }, + /// The address of a forward that is already open. + Forward(String), +} + +/// What a background open leaves for the event loop. +struct OpenOutcome { + notice: String, + /// A forward the open started, for the TUI to hold, with its Agent. + forward: Option<(String, PortForward)>, + /// An address to copy instead of opening, since this terminal cannot show windows. + copy: Option, +} + +/// Opens `outside`, first waiting for its Agent to be Ready when it has one, +/// so an editor's first connection does not wait behind provisioning and time +/// out, and ends the wait for `waiting`. With `copy`, since nothing can open +/// here, an address is copied instead. +fn spawn_open( + home: &ControlPlaneHome, + inputs: Inputs, + waiting: Option<(String, OpenTarget)>, + outside: Outside, + copy: bool, +) { + let home_path = home.path().to_path_buf(); + let socket_path = home.socket_path(); + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let outcome = async { + let (launch, what, forward) = match outside { + Outside::Editor { + agent, + editor, + launcher, + } => { + client + .ensure_execution(&agent, WaitPolicy::UntilConverged) + .await + .map_err(|error| error.to_string())?; + let access = client.ssh_access(&agent).await.map_err(|error| error.to_string())?; + let launch = editor + .launch(launcher.as_deref(), &access.alias, &access.working_directory) + .ok_or_else(|| { + format!("{}: {}", editor.label(), editor.missing_launcher().unwrap_or_default()) + })?; + (launch, format!("{} on {agent}", editor.label()), None) + } + Outside::Desktop { agent, viewer } => { + let forward = desktop_forward(&client, home_path, &agent, viewer).await?; + let url = crate::launch::forward_url(forward.local_address(), forward.spec().guest_port); + (crate::launch::Launch::Url(url.clone()), url, Some((agent, forward))) + } + Outside::Forward(url) => (crate::launch::Launch::Url(url.clone()), url, None), + }; + hand_over(launch, &what, forward, copy).await + } + .await; + let _ = inputs.send(Input::Opened { waiting, outcome }); + }); +} + +/// Launches what an open resolved to, or copies its address where nothing can +/// open here. Every address is a forward's, so when launching it fails the +/// forward is kept, since its address still works, and the address is copied. +async fn hand_over( + launch: crate::launch::Launch, + what: &str, + forward: Option<(String, PortForward)>, + copy: bool, +) -> Result { + let url = match &launch { + crate::launch::Launch::Url(url) => Some(url.clone()), + crate::launch::Launch::Command { .. } => None, + }; + if let (Some(url), true) = (&url, copy) { + return Ok(OpenOutcome { + notice: format!("copied {url}, reachable from the machine running agentctl"), + forward, + copy: Some(url.clone()), + }); + } + launched(launch.start().await, what, url, forward) +} + +/// How an open ends once its launch was tried. +fn launched( + started: Result<(), String>, + what: &str, + url: Option, + forward: Option<(String, PortForward)>, +) -> Result { + match (started, url, forward) { + (Ok(()), _, forward) => Ok(OpenOutcome { + notice: format!("opening {what}"), + forward, + copy: None, + }), + (Err(error), Some(url), forward) => Ok(OpenOutcome { + notice: format!("could not open {url}, so it is copied: {error}"), + forward, + copy: Some(url), + }), + (Err(error), _, _) => Err(format!("opening {what} failed: {error}")), + } +} + +/// Applies a finished background open: keeps a forward it started, copies an +/// address it left, and shows how it ended. A failed copy is shown like any +/// other failure, so the forwards the TUI holds stay open. +fn open_finished( + app: &mut App, + forwards: &mut ActiveForwards, + waiting: Option<(String, OpenTarget)>, + outcome: Result, +) { + let result = outcome.and_then(|opened| { + if let Some((agent, forward)) = opened.forward + && !forwards.push(agent.clone(), forward, &app.agents) + { + return Err(format!( + "{agent} was deleted or re-created while it opened, so its address no longer works" + )); + } + if let Some(url) = &opened.copy { + terminal::copy_to_clipboard(url).map_err(|error| format!("could not copy {url}: {error}"))?; + } + Ok(opened.notice) + }); + app.opened(waiting, result, Instant::now()); +} + +/// Forwards the Agent's desktop to a free local port once it is Ready. The +/// browser viewer's port is known only after a pass has seen what the image +/// declares, so it is read after converging. +async fn desktop_forward( + client: &Client, + home: PathBuf, + agent: &str, + viewer: DesktopViewer, +) -> Result { + let target = client + .ensure_execution(agent, WaitPolicy::UntilConverged) + .await + .map_err(|error| error.to_string())?; + let access = client.vnc_access(agent).await.map_err(|error| error.to_string())?; + let guest_port = match viewer { + DesktopViewer::Browser => access + .web_guest_port + .ok_or_else(|| format!("the image of {agent} serves no browser viewer; open it in a VNC client"))?, + DesktopViewer::VncClient => access.guest_port, + }; + let spec = ForwardSpec { + address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + local_port: 0, + guest_port, + }; + PortForward::start(home, target.sandbox, spec) + .await + .map_err(|error| error.to_string()) +} + +/// Loads the most recent turns of the selected Session. +fn spawn_transcript(socket_path: PathBuf, inputs: Inputs, agent: String, session: SessionName) { + tokio::task::spawn_local(async move { + let turns = Client::for_path(socket_path) + .session_turns(&agent, session.clone(), Some(TRANSCRIPT_TURNS)) + .await + .map_err(|error| error.to_string()); + let _ = inputs.send(Input::TranscriptLoaded { agent, session, turns }); + }); +} + +/// Sends a prompt to a running Session without waiting for its turn to start. +fn spawn_prompt(socket_path: PathBuf, inputs: Inputs, form: PromptForm) { + tokio::task::spawn_local(async move { + let result = Client::for_path(socket_path) + .prompt_session(&form.agent, form.session.clone(), form.input.clone(), false, None) + .await + .map_err(|error| error.to_string()); + let _ = inputs.send(Input::PromptSent(form, result)); + }); +} + +/// Deletes a Session off the event loop: the call returns only once its harness +/// is stopped, and the watch removes the row. +fn spawn_session_delete(socket_path: PathBuf, inputs: Inputs, agent: String, session: SessionName) { + tokio::task::spawn_local(async move { + if let Err(error) = Client::for_path(socket_path).delete_session(&agent, session).await { + let _ = inputs.send(Input::SessionChangeFailed(error.to_string())); + } + }); +} + +/// Archives or unarchives a Session off the event loop, which stopping its harness would block. +fn spawn_session_archive(socket_path: PathBuf, inputs: Inputs, agent: String, session: SessionName, archived: bool) { + tokio::task::spawn_local(async move { + let _ = inputs.send( + match Client::for_path(socket_path) + .set_session_archived(&agent, session, archived) + .await + { + Ok(session) => Input::ArchiveChanged(session), + Err(error) => Input::SessionChangeFailed(error.to_string()), + }, + ); + }); +} + +/// Creates a forward off the event loop so provisioning never freezes the UI. +fn spawn_create(home: &ControlPlaneHome, inputs: Inputs, agent: String, spec: ForwardSpec, replace: Option) { + let home_path = home.path().to_path_buf(); + let socket_path = home.socket_path(); + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let result = async { + // The TUI has no place to render progress while on screen, so a failing + // first pass is reported instead of waited through. + let target = client.ensure_execution(&agent, WaitPolicy::FirstPass).await?; + PortForward::start(home_path, target.sandbox, spec.clone()).await + } + .await; + let _ = inputs.send(Input::ForwardCreated((agent, spec, replace, result))); + }); +} + +/// Discovers create-agent candidates off the event loop so a slow filesystem never freezes the UI. +fn spawn_discovery(inputs: Inputs, agents: Vec) { + tokio::task::spawn_local(async move { + let candidates = manifest_candidates(std::env::current_dir().ok(), &agents).await; + let _ = inputs.send(Input::ManifestsDiscovered(candidates)); + }); +} + +/// Assembles create-agent candidates from the working tree and recorded Agent manifests. +/// +/// Every manifest below the working directory is offered, or below the repository +/// root when the working directory is inside a git repository, skipping hidden and +/// ignored directories; a recorded manifest that is unreadable stays listed so its +/// error is visible. +async fn manifest_candidates(current_directory: Option, agents: &[Agent]) -> Vec { + let agents = agents.to_vec(); + tokio::task::spawn_blocking(move || manifest_candidates_blocking(current_directory.as_deref(), &agents)) + .await + .unwrap_or_default() +} + +fn manifest_candidates_blocking(current_directory: Option<&Path>, agents: &[Agent]) -> Vec { + let mut recorded: Vec = agents + .iter() + .filter_map(|agent| agent.status.provenance.as_ref()) + .map(agent::Provenance::manifest_or_default) + .collect(); + recorded.sort(); + recorded.dedup(); + let found = current_directory.map_or_else(Vec::new, working_tree_manifests); + let paths = found + .into_iter() + .map(|path| (path, false)) + .chain(recorded.into_iter().map(|path| (path, true))); + let mut seen = HashMap::::new(); + let mut candidates = Vec::::new(); + for (path, recorded) in paths { + let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + if let Some(index) = seen.get(&canonical).copied() { + candidates[index].add_equivalent_path(path); + continue; + } + let name = match manifest::resolve(&path) { + Ok(resolved) => Ok(resolved.agent.metadata.name), + Err(error) if recorded || path.exists() => Err(error.to_string()), + Err(_) => continue, + }; + seen.insert(canonical, candidates.len()); + candidates.push(ManifestCandidate::new(path, name)); + } + candidates +} + +/// Returns the root of the git repository containing `directory`, if any. +/// +/// A linked worktree keeps `.git` as a file, so only presence is checked. +fn repository_root(directory: &Path) -> Option<&Path> { + directory.ancestors().find(|ancestor| ancestor.join(".git").exists()) +} + +/// Lists Agents and their variant manifests below `directory`, or below its git repository root. +/// +/// The walk honors ignore files for directories and finds complete `agent.yaml` +/// manifests. Each Agent directory is then enumerated directly so checkout-local, +/// ignored `agent..yaml` siblings remain discoverable. +fn working_tree_manifests(directory: &Path) -> Vec { + let root = repository_root(directory).unwrap_or(directory); + let mut found: Vec = WalkBuilder::new(root) + .max_depth(Some(DISCOVERY_DEPTH)) + .require_git(false) + .follow_links(false) + .build() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()) && entry.file_name() == MANIFEST_FILE) + .flat_map(|entry| { + let base = entry.into_path(); + let Some(parent) = base.parent() else { + return vec![base]; + }; + let mut manifests = std::fs::read_dir(parent) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| manifest::is_manifest_filename(path)) + .collect::>(); + manifests.sort_by_key(|path| (path.file_name().is_none_or(|name| name != MANIFEST_FILE), path.clone())); + manifests + }) + .collect(); + found.sort_by_key(|path| { + ( + path.components().count(), + path.parent().map(Path::to_path_buf), + path.file_name().is_none_or(|name| name != MANIFEST_FILE), + path.clone(), + ) + }); + found +} + +/// Applies the manifest under the chosen name; a rejection reopens the form with the error. +async fn create( + app: &mut App, + client: &Client, + manifest: PathBuf, + name: String, + env_file: Option, + mut form: CreateForm, +) { + match create_agent(client, manifest, name, env_file).await { + Ok(applied) => app.agent_applied(applied), + Err(error) => { + form.error = Some(error.to_string()); + app.modal = Some(Modal::CreateAgent(form)); + } + } +} + +async fn create_agent( + client: &Client, + manifest: PathBuf, + name: String, + env_file: Option, +) -> Result { + let mut request = crate::read_apply_request(manifest, env_file)?; + request.agent.metadata.name = name; + request.create_only = true; + client.apply(request).await +} + +/// Applies one completed background forward creation to the UI state. +fn forward_created(app: &mut App, forwards: &mut ActiveForwards, outcome: CreateOutcome) { + let (agent, spec, replace, result) = outcome; + app.creating = app.creating.saturating_sub(1); + match result { + Ok(forward) => { + if !forwards.push(agent.clone(), forward, &app.agents) { + app.error = Some(format!("{agent} was deleted or re-created before its forward started")); + } + } + Err(error) => { + app.modal = Some(Modal::PortForward(ForwardForm::rejected( + agent, + &spec, + replace, + error.to_string(), + ))); + } + } +} + +async fn suspended( + app: &mut App, + tui: &mut Tui, + home: &ControlPlaneHome, + client: &Client, + action: Action, +) -> CommandResult<()> { + tui.suspend()?; + let result = match action { + Action::Attach { agent, session } => attach(home, client, &agent, session, SessionRequest::default()).await, + Action::CreateSession { + agent, + session, + harness, + model_selection, + } => { + let request = SessionRequest { + harness: Some(harness), + model_selection, + initial_prompt: None, + }; + attach(home, client, &agent, session, request).await + } + Action::Exec { agent } + | Action::Open { + agent, + target: OpenTarget::Shell, + } => exec(home, client, &agent).await, + Action::Open { + agent, + target: OpenTarget::SshShell, + } => ssh_shell(client, &agent).await, + _ => Ok(()), + }; + tui.resume()?; + if let Err(error) = result { + app.error = Some(error.to_string()); + } + Ok(()) +} + +async fn attach( + home: &ControlPlaneHome, + client: &Client, + agent: &str, + session: SessionName, + request: SessionRequest, +) -> Result<(), Error> { + let wait = Wait::start(); + let target = wait + .until( + client, + agent, + client.ensure_session(agent, session, request, WaitPolicy::UntilConverged), + ) + .await?; + agent::sessions::attach(home.path(), &target).await +} + +async fn exec(home: &ControlPlaneHome, client: &Client, agent: &str) -> Result<(), Error> { + let wait = Wait::start(); + let target = wait + .until( + client, + agent, + client.ensure_execution(agent, WaitPolicy::UntilConverged), + ) + .await?; + let command = ["bash".to_owned(), "-l".to_owned()]; + let spec = agent::sandbox::platform::execution_spec(&target.operating_system, &command, true)?; + match agent::sandbox::attach_terminal( + home.path(), + &target.sandbox, + sandbox::terminal::AttachTerminalRequest::new(spec), + ) + .await? + { + TerminalAttachOutcome::Exited(_) | TerminalAttachOutcome::Detached => Ok(()), + _ => Err(Error::Session( + "terminal execution returned an unsupported outcome".into(), + )), + } +} + +/// Runs OpenSSH against the Agent's generated alias until it exits. +/// +/// Unlike `agentctl ssh`, this waits for the client rather than replacing the +/// process, which is the TUI's. +async fn ssh_shell(client: &Client, agent: &str) -> Result<(), Error> { + let wait = Wait::start(); + wait.until( + client, + agent, + client.ensure_execution(agent, WaitPolicy::UntilConverged), + ) + .await?; + let access = client.ssh_access(agent).await?; + // Awaited, not waited on: the TUI's forwards and watch share this thread. + let status = tokio::process::Command::new(crate::ssh_client_executable()) + .args(crate::ssh_client_arguments(&access)) + .status() + .await + .map_err(|error| Error::Invalid(crate::ssh_client_failure(&error)))?; + // 255 is OpenSSH's own failure; any other status is the remote shell's last command. + if status.code() == Some(255) { + // Returning to the TUI clears the screen, and with it what ssh printed about why. + eprint!( + "\nssh to {} ended with an OpenSSH error. Press Enter to return to agentctl. ", + access.alias + ); + let mut line = String::new(); + let _ = + tokio::io::AsyncBufReadExt::read_line(&mut tokio::io::BufReader::new(tokio::io::stdin()), &mut line).await; + return Err(Error::Invalid(format!( + "ssh to {} ended with an OpenSSH error", + access.alias + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + fn manifest_yaml(name: &str) -> String { + format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: {name}\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + \x20 - type: claudeCode\n\ + \x20 version: \"1.0.0\"\n\ + \x20 auth: mediated\n\ + \x20 secrets: []\n\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ) + } + + fn recorded_agent(name: &str, source: Option<&std::path::Path>) -> Agent { + let mut agent = manifest::decode(manifest_yaml(name).as_bytes()).expect("test manifest should decode"); + agent.status.provenance = source.map(|directory| agent::Provenance { + source_directory: directory.to_path_buf(), + manifest_path: None, + env_file: None, + }); + agent + } + + fn manifest_directory(root: &std::path::Path, name: &str, content: &str) -> PathBuf { + let directory = root.join(name); + std::fs::create_dir_all(&directory).expect("manifest directory should be created"); + std::fs::write(directory.join(MANIFEST_FILE), content).expect("manifest should be written"); + directory + } + + fn empty_directory(root: &std::path::Path, name: &str) -> PathBuf { + let directory = root.join(name); + std::fs::create_dir_all(&directory).expect("directory should be created"); + directory + } + + #[tokio::test(flavor = "local")] + async fn discovery_offers_the_working_directory_and_recorded_manifests_once() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = manifest_directory(root.path(), "a-cwd", &manifest_yaml("local")); + let broken = manifest_directory(root.path(), "broken", "not a manifest"); + let missing = empty_directory(root.path(), "missing"); + let recorded = manifest_directory(root.path(), "recorded", &manifest_yaml("recorded")); + let agents = vec![ + recorded_agent("recorded", Some(&recorded)), + recorded_agent("duplicate", Some(&cwd)), + recorded_agent("missing", Some(&missing)), + recorded_agent("broken", Some(&broken)), + recorded_agent("unknown", None), + ]; + + let candidates = manifest_candidates(Some(cwd.clone()), &agents).await; + + assert_eq!(candidates.len(), 4); + assert_eq!(candidates[0].path, cwd.join(MANIFEST_FILE)); + assert_eq!(candidates[0].name.as_deref(), Ok("local")); + assert_eq!(candidates[1].path, broken.join(MANIFEST_FILE)); + assert!(candidates[1].name.is_err()); + assert_eq!(candidates[2].path, missing.join(MANIFEST_FILE)); + assert!(candidates[2].name.is_err()); + assert_eq!(candidates[3].path, recorded.join(MANIFEST_FILE)); + assert_eq!(candidates[3].name.as_deref(), Ok("recorded")); + } + + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn discovery_retains_equivalent_recorded_paths_for_picker_preselection() { + let root = tempfile::tempdir().expect("temporary directory"); + let source = manifest_directory(root.path(), "source", &manifest_yaml("worker")); + let alias = root.path().join("alias"); + std::os::unix::fs::symlink(&source, &alias).expect("manifest directory symlink"); + let recorded_manifest = alias.join(MANIFEST_FILE); + let mut agent = recorded_agent("worker", Some(&alias)); + agent + .status + .provenance + .as_mut() + .expect("recorded provenance") + .manifest_path = Some(recorded_manifest.clone()); + + let candidates = manifest_candidates(Some(source.clone()), &[agent]).await; + let form = CreateForm::new(candidates, Some(&recorded_manifest)); + + assert_eq!(form.agents.len(), 1); + assert_eq!( + form.candidate().map(|candidate| candidate.path.as_path()), + Some(source.join(MANIFEST_FILE).as_path()) + ); + } + + #[tokio::test(flavor = "local")] + async fn discovery_walks_the_working_directory_tree_but_not_hidden_or_ignored_directories() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = manifest_directory(root.path(), "cwd", &manifest_yaml("top")); + let nested = manifest_directory(&cwd, "examples/deeper", &manifest_yaml("nested")); + let sibling = manifest_directory(&cwd, "examples/other", &manifest_yaml("other")); + manifest_directory(&cwd, ".hidden", &manifest_yaml("hidden")); + manifest_directory(&cwd, "target/ignored", &manifest_yaml("ignored")); + std::fs::write(cwd.join(".gitignore"), "target/\n").expect("ignore file should be written"); + let agents = vec![recorded_agent("nested", Some(&nested))]; + + let candidates = manifest_candidates(Some(cwd.clone()), &agents).await; + + let paths: Vec<&std::path::Path> = candidates.iter().map(|candidate| candidate.path.as_path()).collect(); + assert_eq!( + paths, + [ + cwd.join(MANIFEST_FILE), + nested.join(MANIFEST_FILE), + sibling.join(MANIFEST_FILE) + ] + ); + assert_eq!(candidates[1].name.as_deref(), Ok("nested")); + } + + #[tokio::test(flavor = "local")] + async fn discovery_includes_ignored_sibling_variants_and_their_resolution_errors() { + let root = tempfile::tempdir().expect("temporary directory"); + let agent = manifest_directory(root.path(), "configured-agent", &manifest_yaml("default")); + std::fs::write(agent.join(".gitignore"), "agent.*.yaml\n").expect("Agent ignore file"); + std::fs::write( + agent.join("agent.mine.yaml"), + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: agent.yaml\nmetadata:\n name: mine\n", + ) + .expect("local variant"); + std::fs::write( + agent.join("agent.broken.yaml"), + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: missing.yaml\nmetadata:\n name: broken\n", + ) + .expect("broken local variant"); + + let candidates = manifest_candidates(Some(agent.clone()), &[]).await; + + assert_eq!(candidates.len(), 3); + assert_eq!(candidates[0].path, agent.join(MANIFEST_FILE)); + assert_eq!(candidates[0].name.as_deref(), Ok("default")); + assert_eq!(candidates[1].path, agent.join("agent.broken.yaml")); + assert!( + candidates[1] + .name + .as_ref() + .is_err_and(|error| error.contains("extends must name")) + ); + assert_eq!(candidates[2].path, agent.join("agent.mine.yaml")); + assert_eq!(candidates[2].name.as_deref(), Ok("mine")); + } + + #[tokio::test(flavor = "local")] + async fn discovery_walks_the_whole_git_repository_from_a_nested_working_directory() { + let root = tempfile::tempdir().expect("temporary directory"); + let repository = manifest_directory(root.path(), "repository", &manifest_yaml("root")); + std::fs::write(repository.join(".git"), "gitdir: elsewhere\n").expect("worktree marker should be written"); + let cwd = empty_directory(&repository, "src/deep/inside"); + let sibling = manifest_directory(&repository, "agents/full", &manifest_yaml("full")); + manifest_directory(root.path(), "outside", &manifest_yaml("outside")); + + let candidates = manifest_candidates(Some(cwd), &[]).await; + + let paths: Vec<&std::path::Path> = candidates.iter().map(|candidate| candidate.path.as_path()).collect(); + assert_eq!(paths, [repository.join(MANIFEST_FILE), sibling.join(MANIFEST_FILE)]); + } + + #[tokio::test(flavor = "local")] + async fn discovery_uses_the_recorded_manifest_filename() { + let root = tempfile::tempdir().expect("temporary directory"); + let source = empty_directory(root.path(), "custom"); + let manifest = source.join("worker.yml"); + std::fs::write(&manifest, manifest_yaml("custom")).expect("manifest should be written"); + let mut agent = recorded_agent("custom", Some(&source)); + agent + .status + .provenance + .as_mut() + .expect("provenance should be recorded") + .manifest_path = Some(manifest.clone()); + + let candidates = manifest_candidates(None, &[agent]).await; + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].path, manifest); + assert_eq!(candidates[0].name.as_deref(), Ok("custom")); + } + + #[tokio::test(flavor = "local")] + async fn a_manifest_less_working_directory_never_hides_its_recorded_source() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = empty_directory(root.path(), "cwd"); + let agents = vec![recorded_agent("worker", Some(&cwd))]; + + let candidates = manifest_candidates(Some(cwd.clone()), &agents).await; + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].path, cwd.join(MANIFEST_FILE)); + assert!(candidates[0].name.is_err()); + } + + #[tokio::test(flavor = "local")] + async fn discovery_skips_a_working_directory_without_a_manifest() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = empty_directory(root.path(), "cwd"); + + assert!(manifest_candidates(Some(cwd), &[]).await.is_empty()); + assert!(manifest_candidates(None, &[]).await.is_empty()); + } + + #[test] + fn row_primary_actions_require_two_clicks_on_the_same_row_in_time() { + let mut mouse = MouseInput::default(); + let start = Instant::now(); + let row = |name: &str| RowTarget::Tree(app::TreeRowId::Agent(name.into())); + + assert!(!mouse.double_click(&row("first"), start)); + assert!(!mouse.double_click(&row("second"), start + Duration::from_millis(100))); + assert!(!mouse.double_click(&row("second"), start + Duration::from_millis(700))); + assert!(mouse.double_click(&row("second"), start + Duration::from_millis(800))); + assert!(!mouse.double_click(&RowTarget::Forward(3), start + Duration::from_millis(850))); + } + + #[test] + fn mouse_uses_clickable_hints_and_ignores_unsupported_input() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = App::new(); + let mut state = view::ViewState::default(); + let mut hit_map = None; + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + terminal + .draw(|frame| hit_map = Some(view::render(frame, &app, &mut state))) + .expect("draw"); + let hit_map = hit_map.expect("hit map"); + let mut mouse = MouseInput::default(); + let now = Instant::now(); + let event = |kind, modifiers| MouseEvent { + kind, + column: 0, + row: 10, + modifiers, + }; + + assert_eq!( + mouse.action( + event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE), + &hit_map, + &mut app, + now, + ), + Action::OpenCreate + ); + assert_eq!(mouse.position(), Some((0, 10))); + for input in [ + event(MouseEventKind::Down(MouseButton::Right), KeyModifiers::NONE), + event(MouseEventKind::Moved, KeyModifiers::NONE), + event(MouseEventKind::Drag(MouseButton::Left), KeyModifiers::NONE), + event(MouseEventKind::ScrollLeft, KeyModifiers::NONE), + event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::SHIFT), + ] { + assert_eq!(mouse.action(input, &hit_map, &mut app, now), Action::None); + } + } + + #[test] + fn wheel_scrolls_details_only_inside_the_rendered_content() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = App::new(); + // More lines than the view shows, so there is something to scroll. + app.detail = Some(app::Detail::text( + "detail".into(), + (1..=12).map(|line| format!("line {line}")).collect(), + )); + let mut state = view::ViewState::default(); + let mut hit_map = None; + let mut terminal = Terminal::new(TestBackend::new(40, 8)).expect("test terminal"); + terminal + .draw(|frame| hit_map = Some(view::render(frame, &app, &mut state))) + .expect("draw"); + let hit_map = hit_map.expect("hit map"); + let mut mouse = MouseInput::default(); + let now = Instant::now(); + let wheel = |column, row| MouseEvent { + kind: MouseEventKind::ScrollDown, + column, + row, + modifiers: KeyModifiers::NONE, + }; + + assert_eq!(mouse.action(wheel(1, 2), &hit_map, &mut app, now), Action::None); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(1)); + assert_eq!(mouse.action(wheel(0, 1), &hit_map, &mut app, now), Action::None); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(1)); + } + + fn materialized(id: &str) -> agent::sandbox::Assignment { + serde_json::from_value(serde_json::json!({ + "state": "materialized", + "provider": "memory", + "id": id, + })) + .expect("test assignment") + } + + async fn forward(assignment: &agent::sandbox::Assignment, guest_port: u16) -> PortForward { + let spec = ForwardSpec::parse(&format!("127.0.0.1:0:{guest_port}")).expect("spec"); + let home = tempfile::tempdir().expect("home"); + PortForward::start(home.path().to_path_buf(), assignment.clone(), spec) + .await + .expect("forward binds") + } + + #[tokio::test(flavor = "local")] + async fn a_failed_launch_keeps_the_forward_and_copies_its_address() { + let sandbox = materialized("00000000-0000-0000-0000-00000000000a"); + let url = "http://127.0.0.1:50001/".to_owned(); + let failed = || Err("no opener".to_owned()); + + let kept = launched( + failed(), + &url, + Some(url.clone()), + Some(("desk".into(), forward(&sandbox, 6080).await)), + ) + .expect("the forward still works"); + assert_eq!(kept.notice, format!("could not open {url}, so it is copied: no opener")); + assert_eq!(kept.copy.as_deref(), Some(url.as_str())); + assert!(kept.forward.is_some()); + + let reopened = launched(failed(), &url, Some(url.clone()), None).expect("the open forward still works"); + assert_eq!( + reopened.copy.as_deref(), + Some(url.as_str()), + "an open forward is copied too" + ); + + assert_eq!( + launched(failed(), "Zed on desk", None, None).err().as_deref(), + Some("opening Zed on desk failed: no opener") + ); + let opened = launched(Ok(()), "Zed on desk", None, None).expect("opened"); + assert_eq!(opened.notice, "opening Zed on desk"); + assert!(opened.copy.is_none()); + } + + #[tokio::test(flavor = "local")] + async fn a_blocked_launch_copies_the_address_and_keeps_the_forward() { + let sandbox = materialized("00000000-0000-0000-0000-00000000000a"); + let url = "http://127.0.0.1:50001/".to_owned(); + let copied = hand_over( + crate::launch::Launch::Url(url.clone()), + &url, + Some(("desk".into(), forward(&sandbox, 6080).await)), + true, + ) + .await + .expect("copied"); + assert_eq!( + copied.notice, + format!("copied {url}, reachable from the machine running agentctl") + ); + assert_eq!(copied.copy.as_deref(), Some(url.as_str())); + assert!(copied.forward.is_some()); + } + + #[tokio::test(flavor = "local")] + async fn forwards_end_with_the_sandbox_they_dial() { + let first = materialized("00000000-0000-0000-0000-00000000000a"); + let second = materialized("00000000-0000-0000-0000-00000000000b"); + let mut forwards = ActiveForwards::default(); + let mut desk = recorded_agent("desk", None); + desk.status.sandbox = Some(first.clone()); + assert!(forwards.push( + "desk".into(), + forward(&first, agent::vnc::WEB_GUEST_PORT).await, + std::slice::from_ref(&desk) + )); + + forwards.prune(std::slice::from_ref(&desk)); + assert!( + forwards.desktop("desk", DesktopViewer::Browser).is_some(), + "same Sandbox" + ); + assert!( + forwards.desktop("desk", DesktopViewer::VncClient).is_none(), + "another viewer" + ); + + desk.status.sandbox = None; + forwards.prune(std::slice::from_ref(&desk)); + assert_eq!( + forwards.entries().len(), + 1, + "no Sandbox reported yet, so nothing says it is gone" + ); + + desk.status.sandbox = Some(second.clone()); + forwards.prune(std::slice::from_ref(&desk)); + assert!( + forwards.entries().is_empty(), + "re-created under the same name, so the old forward is dead" + ); + + assert!(forwards.push("desk".into(), forward(&second, 3000).await, std::slice::from_ref(&desk))); + forwards.prune(&[]); + assert!(forwards.entries().is_empty(), "the Agent is gone"); + } + + #[tokio::test(flavor = "local")] + async fn a_forward_that_starts_after_its_agent_was_re_created_is_not_kept() { + let first = materialized("00000000-0000-0000-0000-00000000000a"); + let mut desk = recorded_agent("desk", None); + desk.status.sandbox = Some(materialized("00000000-0000-0000-0000-00000000000b")); + let mut app = App::new(); + app.agents = vec![desk]; + let mut forwards = ActiveForwards::default(); + let target = OpenTarget::Desktop(DesktopViewer::Browser); + app.start_opening("desk", target, Instant::now()); + + let opened = OpenOutcome { + notice: "opening the desktop".into(), + forward: Some(("desk".into(), forward(&first, agent::vnc::WEB_GUEST_PORT).await)), + copy: None, + }; + open_finished(&mut app, &mut forwards, Some(("desk".into(), target)), Ok(opened)); + + assert!(forwards.entries().is_empty()); + assert_eq!( + app.error.as_deref(), + Some("desk was deleted or re-created while it opened, so its address no longer works") + ); + assert!(app.opening.is_empty(), "the wait ends"); + + let spec = ForwardSpec::parse("127.0.0.1:0:3000").expect("spec"); + app.error = None; + app.creating = 1; + let started = forward(&first, 3000).await; + forward_created(&mut app, &mut forwards, ("desk".into(), spec, None, Ok(started))); + assert!(forwards.entries().is_empty()); + assert_eq!( + app.error.as_deref(), + Some("desk was deleted or re-created before its forward started") + ); + } + + #[tokio::test(flavor = "local")] + async fn an_edited_desktop_forward_is_still_the_desktop() { + let assignment = materialized("00000000-0000-0000-0000-00000000000a"); + let mut forwards = ActiveForwards::default(); + let desk = recorded_agent("desk", None); + forwards.push( + "desk".into(), + forward(&assignment, agent::vnc::WEB_GUEST_PORT).await, + std::slice::from_ref(&desk), + ); + let id = forwards.entries()[0].id; + forwards.remove(id); + let mut app = App::new(); + app.agents = vec![desk]; + app.creating = 1; + let spec = ForwardSpec::parse("127.0.0.1:0:6080").expect("spec"); + let edited = forward(&assignment, agent::vnc::WEB_GUEST_PORT).await; + + forward_created(&mut app, &mut forwards, ("desk".into(), spec, Some(id), Ok(edited))); + + assert_eq!(forwards.entries()[0].label(), Some("desktop")); + assert!( + forwards.desktop("desk", DesktopViewer::Browser).is_some(), + "o w finds it again" + ); + } +} diff --git a/agentctl/src/bin/agentctl/tui/open.rs b/agentctl/src/bin/agentctl/tui/open.rs new file mode 100644 index 0000000..5e79969 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/open.rs @@ -0,0 +1,698 @@ +//! What the open menu offers for one Agent, and why an item is unavailable. +//! +//! The side panel's Connect section follows the same rules, so the panel never +//! promises something the menu then refuses. + +use std::{ + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +use agent::{Agent, Condition, ConditionStatus}; + +use crate::launch::Editor; + +/// Where the Agent's desktop is shown. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DesktopViewer { + /// The browser-based viewer the image serves. + Browser, + /// A VNC client of the person's own. + VncClient, +} + +impl DesktopViewer { + /// The guest port the viewer is served on. + pub(crate) const fn guest_port(self) -> u16 { + match self { + Self::Browser => agent::vnc::WEB_GUEST_PORT, + Self::VncClient => agent::vnc::GUEST_PORT, + } + } +} + +/// One way into an Agent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OpenTarget { + /// A login shell in the Sandbox, in this terminal. + Shell, + /// An editor on this machine, connected over SSH. + Editor(Editor), + /// The Agent's desktop, forwarded to this machine. + Desktop(DesktopViewer), + /// An OpenSSH login, in this terminal. + SshShell, + /// The OpenSSH alias, for tools outside the TUI. + CopyAlias, +} + +impl OpenTarget { + pub(crate) fn label(self) -> String { + match self { + Self::Shell => "Shell in the Sandbox".into(), + Self::Editor(Editor::VsCode) => "VS Code, Remote-SSH".into(), + Self::Editor(editor) => editor.label().into(), + Self::Desktop(DesktopViewer::Browser) => "Desktop in the browser".into(), + Self::Desktop(DesktopViewer::VncClient) => "Desktop in a VNC client".into(), + Self::SshShell => "SSH shell".into(), + Self::CopyAlias => "Copy SSH alias".into(), + } + } + + /// The target as notices and the header name it, without the menu's detail. + pub(crate) fn name(self) -> String { + match self { + Self::Editor(editor) => editor.label().into(), + target => target.label(), + } + } + + /// The key that chooses the target directly in the menu. + pub(crate) const fn key(self) -> char { + match self { + Self::Shell => 'e', + Self::Editor(Editor::VsCode) => 'c', + Self::Editor(Editor::Zed) => 'z', + Self::Desktop(DesktopViewer::Browser) => 'w', + Self::Desktop(DesktopViewer::VncClient) => 'v', + Self::SshShell => 's', + Self::CopyAlias => 'y', + } + } + + /// Whether the target reaches the Agent through the user's own OpenSSH + /// configuration, which then needs the generated one included. + pub(crate) const fn needs_include(self) -> bool { + matches!(self, Self::Editor(_) | Self::CopyAlias) + } +} + +/// One row of the open menu: a way into the Agent, or the SSH setup editors need. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MenuEntry { + Open(OpenTarget), + /// Adds the generated configuration's `Include` to the user's own. + SetUpSsh, +} + +impl MenuEntry { + pub(crate) fn label(self) -> String { + match self { + Self::Open(target) => target.label(), + Self::SetUpSsh => "Set up SSH".into(), + } + } + + pub(crate) const fn key(self) -> Option { + match self { + Self::Open(target) => Some(target.key()), + Self::SetUpSsh => None, + } + } +} + +/// How long OpenSSH may take to resolve an alias; `Match exec` in the user's +/// configuration runs commands of theirs, which must not hold the check forever. +const SSH_CHECK_TIMEOUT: Duration = Duration::from_secs(5); + +/// Whether the user's OpenSSH configuration reaches Agents through the generated one. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum SshSetup { + /// Not checked yet, or OpenSSH could not tell: it is missing, timed out + /// or rejects the configuration. Nothing is asked for then. + #[default] + Unknown, + Installed, + Missing, +} + +impl SshSetup { + /// Asks OpenSSH how the user's configuration resolves `agent`'s alias, as + /// editors resolve it. `ssh -G` only prints the result and connects nowhere. + pub(crate) async fn check(agent: &str) -> Self { + Self::resolve(agent, None).await + } + + /// [`Self::check`] against `config` in place of the user's configuration. + async fn resolve(agent: &str, config: Option<&Path>) -> Self { + let mut ssh = tokio::process::Command::new(crate::ssh_client_executable()); + if let Some(config) = config { + ssh.arg("-F").arg(config); + } + ssh.arg("-G") + .arg(agent::ssh::alias(agent)) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + match tokio::time::timeout(SSH_CHECK_TIMEOUT, ssh.output()).await { + Ok(Ok(output)) if output.status.success() => { + if agent::ssh::resolves_through_agentctl(&String::from_utf8_lossy(&output.stdout), agent) { + Self::Installed + } else { + Self::Missing + } + } + _ => Self::Unknown, + } + } +} + +/// Facts about this machine the menu depends on, gathered when the TUI starts. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct Environment { + /// Why an application started here would not appear in front of the + /// person, when it would not. + pub(crate) launch_blocked: Option, + /// Each editor's launcher found on `PATH`. + pub(crate) launchers: Vec<(Editor, PathBuf)>, +} + +impl Environment { + pub(crate) fn detect() -> Self { + let path = std::env::var_os("PATH"); + Self { + launch_blocked: crate::launch::launch_blocked(|name| std::env::var_os(name)), + launchers: Editor::ALL + .into_iter() + .filter_map(|editor| Some((editor, editor.locate(path.as_deref())?))) + .collect(), + } + } + + pub(crate) fn launcher(&self, editor: Editor) -> Option<&Path> { + self.launchers + .iter() + .find(|(found, _)| *found == editor) + .map(|(_, path)| path.as_path()) + } +} + +/// One row of the open menu. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenItem { + pub(crate) entry: MenuEntry, + /// Why the item cannot be chosen. + pub(crate) unavailable: Option, +} + +/// The open menu for one Agent, with its selected row. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenMenu { + pub(crate) agent: String, + pub(crate) items: Vec, + pub(crate) selected: usize, +} + +impl OpenMenu { + pub(crate) fn new(agent: &Agent, environment: &Environment, setup: SshSetup) -> Self { + let items = items(agent, environment, setup); + let selected = items + .iter() + .position(|item| item.unavailable.is_none()) + .unwrap_or_default(); + Self { + agent: agent.metadata.name.clone(), + items, + selected, + } + } + + /// Moves the selection by `delta` among the items that can be chosen. + pub(crate) fn move_selection(&mut self, delta: isize) { + let available = (0..self.items.len()) + .filter(|index| self.items[*index].unavailable.is_none()) + .collect::>(); + let Some(current) = available.iter().position(|index| *index == self.selected) else { + return; + }; + let length = isize::try_from(available.len()).unwrap_or(1); + let next = (isize::try_from(current).unwrap_or_default() + delta).rem_euclid(length); + self.selected = available[usize::try_from(next).unwrap_or_default()]; + } + + /// The entry the selected row chooses, when it can be chosen. + pub(crate) fn chosen(&self) -> Option { + self.items + .get(self.selected) + .filter(|item| item.unavailable.is_none()) + .map(|item| item.entry) + } + + /// The entry `key` chooses, when it can be chosen. + pub(crate) fn by_key(&self, key: char) -> Option { + self.items + .iter() + .find(|item| item.entry.key() == Some(key) && item.unavailable.is_none()) + .map(|item| item.entry) + } + + /// Each reason an item is unavailable, once, with the labels of the items + /// it applies to, in menu order. + pub(crate) fn unavailable_reasons(&self) -> Vec<(Vec, String)> { + let mut reasons = Vec::<(Vec, String)>::new(); + for item in &self.items { + let Some(reason) = &item.unavailable else { + continue; + }; + match reasons.iter_mut().find(|(_, listed)| listed == reason) { + Some((labels, _)) => labels.push(item.entry.label()), + None => reasons.push((vec![item.entry.label()], reason.clone())), + } + } + reasons + } +} + +/// Every item of the open menu for `agent`, in menu order. +pub(crate) fn items(agent: &Agent, environment: &Environment, setup: SshSetup) -> Vec { + let ssh = ssh_unavailable(agent); + let open = |target, unavailable| OpenItem { + entry: MenuEntry::Open(target), + unavailable, + }; + let mut items = vec![open(OpenTarget::Shell, None)]; + items.extend(Editor::ALL.into_iter().map(|editor| { + open( + OpenTarget::Editor(editor), + ssh.clone().or_else(|| editor_unavailable(editor, environment)), + ) + })); + let desktop = vnc_unavailable(agent); + items.extend( + [DesktopViewer::Browser, DesktopViewer::VncClient] + .map(|viewer| open(OpenTarget::Desktop(viewer), desktop.clone())), + ); + items.extend([OpenTarget::SshShell, OpenTarget::CopyAlias].map(|target| open(target, ssh.clone()))); + if ssh.is_none() && setup == SshSetup::Missing { + items.push(OpenItem { + entry: MenuEntry::SetUpSsh, + unavailable: None, + }); + } + items +} + +/// The Agent's Connect section for the side panel: what the menu offers and +/// what stands in the way, without the rows' keys. `desktop` is the address +/// that opens a desktop forward this TUI holds open, if any. +pub(crate) fn connect_lines( + agent: &Agent, + environment: &Environment, + setup: SshSetup, + desktop: Option<&str>, +) -> Vec { + let mut lines = vec![" shell in this terminal".to_owned()]; + lines.push(match (vnc_unavailable(agent), desktop) { + (Some(reason), _) => format!(" desktop {reason}"), + (None, Some(url)) => format!(" desktop open at {url}"), + (None, None) => " desktop browser · VNC client".to_owned(), + }); + if let Some(reason) = ssh_unavailable(agent) { + lines.push(format!(" ssh {reason}")); + return lines; + } + // VS Code needs no launcher of its own, so only a blocked launch leaves no editor. + lines.push(environment.launch_blocked.as_ref().map_or_else( + || { + let editors = Editor::ALL + .into_iter() + .filter(|editor| editor_unavailable(*editor, environment).is_none()) + .map(Editor::label) + .collect::>(); + format!(" editors {}", editors.join(" · ")) + }, + |reason| format!(" editors none: {reason}"), + )); + if setup == SshSetup::Missing { + lines.push(" ! SSH not set up; o offers it".to_owned()); + } + lines.push(format!(" ssh alias {}", agent::ssh::alias(&agent.metadata.name))); + lines +} + +/// Why nothing reached over SSH can be offered, if anything is in the way. +fn ssh_unavailable(agent: &Agent) -> Option { + access_unavailable(agent, agent.spec.ssh_access(), Condition::SSH_READY, "SSH access") +} + +/// Why the desktop cannot be offered, if anything is in the way. +fn vnc_unavailable(agent: &Agent) -> Option { + access_unavailable(agent, agent.spec.vnc_access(), Condition::VNC_READY, "VNC access") +} + +/// An access capability is unavailable while undeclared or after its last pass failed. +fn access_unavailable(agent: &Agent, declared: bool, condition: &str, what: &str) -> Option { + if !declared { + return Some(format!("{what} is not declared in spec.access")); + } + agent + .status + .conditions + .iter() + .find(|found| found.kind == condition && found.status == ConditionStatus::False) + .map(|found| format!("{what} is not ready: {}", found.detail().trim_end())) +} + +fn editor_unavailable(editor: Editor, environment: &Environment) -> Option { + if let Some(reason) = &environment.launch_blocked { + return Some(reason.clone()); + } + if environment.launcher(editor).is_some() { + return None; + } + editor.missing_launcher() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + fn agent(access: &str) -> Agent { + let yaml = format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: worker\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + \x20 - type: claudeCode\n\ + \x20 version: \"1.0.0\"\n\ + \x20 auth: mediated\n\ + \x20 secrets: []\n\ + {access}\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ); + agent::manifest::decode(yaml.as_bytes()).expect("test manifest") + } + + fn with_ssh() -> Agent { + agent("\x20 access:\n\x20 - type: ssh\n") + } + + fn local(editors: &[Editor]) -> Environment { + Environment { + launch_blocked: None, + launchers: editors + .iter() + .map(|editor| (*editor, PathBuf::from(format!("/opt/bin/{}", editor.label())))) + .collect(), + } + } + + fn unavailable(items: &[OpenItem], target: OpenTarget) -> Option { + items + .iter() + .find(|item| item.entry == MenuEntry::Open(target)) + .expect("item is listed") + .unavailable + .clone() + } + + #[test] + fn an_agent_without_ssh_offers_only_its_shell() { + let items = items(&agent(""), &local(&Editor::ALL), SshSetup::Missing); + + assert_eq!(unavailable(&items, OpenTarget::Shell), None); + for target in [ + OpenTarget::Editor(Editor::VsCode), + OpenTarget::SshShell, + OpenTarget::CopyAlias, + ] { + assert_eq!( + unavailable(&items, target).as_deref(), + Some("SSH access is not declared in spec.access") + ); + } + assert!(items.iter().all(|item| item.entry != MenuEntry::SetUpSsh)); + } + + #[test] + fn failed_ssh_access_names_its_cause() { + let mut agent = with_ssh(); + agent.status.conditions.push(Condition { + kind: Condition::SSH_READY.into(), + status: ConditionStatus::False, + reason: "ReconcileFailed".into(), + message: "the image lacks sshd".into(), + last_transition_time: None, + }); + + let items = items(&agent, &local(&Editor::ALL), SshSetup::Installed); + + assert_eq!( + unavailable(&items, OpenTarget::SshShell).as_deref(), + Some("SSH access is not ready: the image lacks sshd") + ); + } + + #[test] + fn editors_need_a_local_terminal_and_zed_its_launcher() { + let without_launchers = items(&with_ssh(), &local(&[]), SshSetup::Installed); + assert_eq!( + unavailable(&without_launchers, OpenTarget::Editor(Editor::VsCode)), + None + ); + assert_eq!( + unavailable(&without_launchers, OpenTarget::Editor(Editor::Zed)), + Editor::Zed.missing_launcher() + ); + + let remote = Environment { + launch_blocked: Some("this terminal has no display to open windows on".into()), + ..local(&Editor::ALL) + }; + let over_ssh = items(&with_ssh(), &remote, SshSetup::Installed); + assert!(unavailable(&over_ssh, OpenTarget::Editor(Editor::VsCode)).is_some()); + assert_eq!(unavailable(&over_ssh, OpenTarget::CopyAlias), None); + assert_eq!(unavailable(&over_ssh, OpenTarget::SshShell), None); + } + + #[test] + fn setting_up_ssh_is_offered_only_while_the_include_is_missing() { + let environment = local(&Editor::ALL); + for (setup, offered) in [ + (SshSetup::Missing, true), + (SshSetup::Installed, false), + (SshSetup::Unknown, false), + ] { + let listed = items(&with_ssh(), &environment, setup); + assert_eq!( + listed.iter().any(|item| item.entry == MenuEntry::SetUpSsh), + offered, + "{setup:?}" + ); + } + } + + #[test] + fn the_menu_selects_and_chooses_only_available_items() { + let mut menu = OpenMenu::new(&with_ssh(), &local(&[]), SshSetup::Installed); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::Shell))); + menu.move_selection(1); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::Editor(Editor::VsCode)))); + // Zed has no launcher, so the selection passes over it. + menu.move_selection(1); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::SshShell))); + menu.move_selection(-2); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::Shell))); + menu.move_selection(-1); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::CopyAlias))); + assert_eq!(menu.by_key('z'), None); + assert_eq!( + menu.by_key('c'), + Some(MenuEntry::Open(OpenTarget::Editor(Editor::VsCode))) + ); + } + + #[test] + fn each_unavailable_reason_is_given_once_with_what_it_blocks() { + let menu = OpenMenu::new(&agent(""), &local(&[]), SshSetup::Installed); + assert_eq!( + menu.unavailable_reasons(), + [ + ( + vec![ + "VS Code, Remote-SSH".to_owned(), + "Zed".to_owned(), + "SSH shell".to_owned(), + "Copy SSH alias".to_owned() + ], + "SSH access is not declared in spec.access".to_owned() + ), + ( + vec![ + "Desktop in the browser".to_owned(), + "Desktop in a VNC client".to_owned() + ], + "VNC access is not declared in spec.access".to_owned() + ) + ] + ); + let menu = OpenMenu::new(&with_desktop(), &local(&[]), SshSetup::Installed); + assert_eq!( + menu.unavailable_reasons(), + [( + vec!["Zed".to_owned()], + Editor::Zed.missing_launcher().expect("Zed needs a launcher") + )] + ); + } + + fn with_desktop() -> Agent { + agent("\x20 access:\n\x20 - type: ssh\n\x20 - type: vnc\n") + } + + #[test] + fn the_desktop_is_offered_when_declared_and_ready() { + let environment = local(&[]); + let browser = OpenTarget::Desktop(DesktopViewer::Browser); + assert_eq!( + unavailable(&items(&with_ssh(), &environment, SshSetup::Installed), browser).as_deref(), + Some("VNC access is not declared in spec.access") + ); + assert_eq!( + unavailable(&items(&with_desktop(), &environment, SshSetup::Installed), browser), + None + ); + + let mut failed = with_desktop(); + failed.status.conditions.push(Condition { + kind: Condition::VNC_READY.into(), + status: ConditionStatus::False, + reason: "ReconcileFailed".into(), + message: "the image runs no desktop".into(), + last_transition_time: None, + }); + let listed = items(&failed, &environment, SshSetup::Installed); + assert_eq!( + unavailable(&listed, OpenTarget::Desktop(DesktopViewer::VncClient)).as_deref(), + Some("VNC access is not ready: the image runs no desktop") + ); + assert_eq!(unavailable(&listed, OpenTarget::SshShell), None, "SSH is unaffected"); + + let menu = OpenMenu::new(&with_desktop(), &environment, SshSetup::Installed); + assert_eq!(menu.by_key('w'), Some(MenuEntry::Open(browser))); + assert_eq!( + menu.by_key('v'), + Some(MenuEntry::Open(OpenTarget::Desktop(DesktopViewer::VncClient))) + ); + } + + #[test] + fn the_panel_shows_an_open_desktop_where_it_listens() { + let lines = connect_lines(&with_desktop(), &local(&[]), SshSetup::Installed, None); + assert_eq!(lines[1], " desktop browser · VNC client"); + let lines = connect_lines( + &with_desktop(), + &local(&[]), + SshSetup::Installed, + Some("http://127.0.0.1:53817/"), + ); + assert_eq!(lines[1], " desktop open at http://127.0.0.1:53817/"); + } + + #[test] + fn connect_lines_agree_with_the_menu() { + assert_eq!( + connect_lines(&agent(""), &local(&Editor::ALL), SshSetup::Missing, None), + [ + " shell in this terminal", + " desktop VNC access is not declared in spec.access", + " ssh SSH access is not declared in spec.access" + ] + ); + assert_eq!( + connect_lines(&with_ssh(), &local(&[Editor::Zed]), SshSetup::Missing, None), + [ + " shell in this terminal", + " desktop VNC access is not declared in spec.access", + " editors VS Code · Zed", + " ! SSH not set up; o offers it", + " ssh alias agentctl-worker", + ] + ); + let remote = Environment { + launch_blocked: Some("this terminal has no display to open windows on".into()), + launchers: Vec::new(), + }; + assert_eq!( + connect_lines(&with_ssh(), &remote, SshSetup::Installed, None)[2], + " editors none: this terminal has no display to open windows on" + ); + } + + /// User configurations OpenSSH reads the generated one from, however they + /// spell the `Include`; ones where it does not apply, since a block or a + /// match of the user's own comes first; and one OpenSSH rejects. + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn ssh_setup_is_what_openssh_resolves_however_the_include_is_written() { + if std::process::Command::new(crate::ssh_client_executable()) + .arg("-V") + .output() + .is_err() + { + eprintln!("skipped: no OpenSSH client"); + return; + } + let directory = tempfile::tempdir().expect("temporary directory"); + let generated = directory.path().join("generated").join("config"); + std::fs::create_dir_all(generated.parent().expect("parent")).expect("directory"); + let proxy = agent::ssh::render_proxy_command( + Path::new("/usr/local/bin/agentctl"), + "worker", + agent::ssh::CommandShell::Posix, + ) + .expect("proxy command"); + std::fs::write(&generated, format!("Host agentctl-worker\n ProxyCommand {proxy}\n")).expect("generated"); + let path = generated.display().to_string(); + let glob = generated.with_file_name("*").display().to_string(); + let cases = [ + (format!("Include {path}\n"), SshSetup::Installed), + (format!("include {path}\n"), SshSetup::Installed), + (format!("Include={path}\n"), SshSetup::Installed), + (format!("Include \"{path}\" # agentctl\n"), SshSetup::Installed), + (format!("Include {glob}\n"), SshSetup::Installed), + (format!("Include /nonexistent {path}\n"), SshSetup::Installed), + ( + format!("Host *\n ServerAliveInterval 30\n\nInclude {path}\n"), + SshSetup::Installed, + ), + ( + format!("Host github.com\n User git\n\nInclude {path}\n"), + SshSetup::Missing, + ), + ( + format!("Host agentctl-*\n ProxyCommand none\n\nInclude {path}\n"), + SshSetup::Missing, + ), + (String::new(), SshSetup::Missing), + ("Bogus yes\n".to_owned(), SshSetup::Unknown), + ]; + for (text, expected) in cases { + let user = directory.path().join("user_config"); + std::fs::write(&user, &text).expect("user config"); + assert_eq!(SshSetup::resolve("worker", Some(&user)).await, expected, "{text}"); + } + } +} diff --git a/agentctl/src/bin/agentctl/tui/provisioning.rs b/agentctl/src/bin/agentctl/tui/provisioning.rs new file mode 100644 index 0000000..8409318 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/provisioning.rs @@ -0,0 +1,169 @@ +//! The provisioning detail: one Agent's latest pass, followed while it is open. + +use std::collections::VecDeque; + +use agent::{ + progress::{AgentProgress, OutputPosition}, + resources::Revision, +}; +use sandbox::progress::{ProgressCursor, Update}; + +use crate::format; + +/// Output lines of the pass the detail keeps. +const OUTPUT_LINES: usize = 20; + +/// What the detail knows of one Agent's provisioning. A reply carries each +/// output line once, so the most recent lines of the pass are kept here. +#[derive(Default)] +pub(crate) struct Followed { + latest: Option, + pass: Option, + cursor: ProgressCursor, + output: VecDeque, +} + +impl Followed { + /// Revision to follow from and the output already kept. + pub(crate) fn position(&self) -> (Option, Option) { + let output = self.pass.map(|pass| OutputPosition { + pass, + sequence: self.cursor.output_sequence(), + }); + (self.latest.as_ref().map(|latest| latest.revision), output) + } + + pub(crate) fn apply(&mut self, progress: AgentProgress) { + if let Some(provisioning) = &progress.provisioning { + if self.pass != Some(provisioning.pass) { + self.pass = Some(provisioning.pass); + self.cursor = ProgressCursor::new(); + self.output.clear(); + } + for update in self.cursor.updates(&provisioning.progress) { + let line = match update { + Update::Output(line) => line.text.clone(), + Update::OutputSkipped(count) => format!("… {count} lines skipped"), + Update::StepFinished(_) | Update::PhaseFinished(_) => continue, + }; + if self.output.len() == OUTPUT_LINES { + self.output.pop_front(); + } + self.output.push_back(line); + } + } + self.latest = Some(progress); + } + + /// The Agent's readiness and failure class, then its latest pass. + pub(crate) fn lines(&self) -> Vec { + let Some(latest) = &self.latest else { + return vec!["Waiting for agentd…".to_owned()]; + }; + let mut lines = format::readiness_lines(&latest.status); + match &latest.provisioning { + Some(provisioning) => lines.extend(format::provisioning_lines( + &provisioning.progress, + self.output.iter().map(String::as_str), + )), + None => lines.push("Provisioning: no pass since agentd started".to_owned()), + } + lines + } +} + +#[cfg(test)] +mod tests { + use sandbox::{OutputStream, ProgressEvent, SandboxPhase, StepId}; + + use super::*; + + fn pass(number: u64) -> Revision { + format!("00000000-0000-0000-0000-000000000001:{number}") + .parse() + .expect("test revision") + } + + fn reply(number: u64, events: &[ProgressEvent]) -> AgentProgress { + let mut progress = sandbox::progress::Progress::new(); + for event in events { + progress.apply(event); + } + serde_json::from_value(serde_json::json!({ + "revision": pass(number), + "status": {}, + "provisioning": {"pass": pass(number), "progress": progress}, + })) + .expect("test reply") + } + + fn output(step: &StepId, text: &str) -> ProgressEvent { + ProgressEvent::StepOutput { + id: step.clone(), + stream: OutputStream::Stdout, + bytes: text.as_bytes().to_vec().into(), + } + } + + #[test] + fn output_is_kept_across_replies_and_starts_over_with_a_new_pass() { + let step = StepId::generate(); + let started = [ + ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + }, + ProgressEvent::StepStarted { + id: step.clone(), + name: "Build Docker image".into(), + unit: None, + total: None, + }, + ]; + let mut followed = Followed::default(); + assert_eq!(followed.position(), (None, None)); + + followed.apply(reply( + 1, + &[started[0].clone(), started[1].clone(), output(&step, "one\n")], + )); + let (_, position) = followed.position(); + assert_eq!( + position, + Some(OutputPosition { + pass: pass(1), + sequence: 1 + }) + ); + // The daemon trims what the follower has already seen. + let mut trimmed = reply( + 1, + &[ + started[0].clone(), + started[1].clone(), + output(&step, "one\n"), + output(&step, "two\n"), + ], + ); + if let Some(provisioning) = &mut trimmed.provisioning { + provisioning.progress = provisioning.progress.output_from(1); + } + followed.apply(trimmed); + let lines = followed.lines(); + assert!( + lines.contains(&" → Resolve Sandbox Image (0s)".to_owned()), + "{lines:#?}" + ); + assert!(lines.contains(&" Build Docker image".to_owned())); + assert!(lines.ends_with(&[" Output:".to_owned(), " one".to_owned(), " two".to_owned()])); + + followed.apply(reply(2, &[started[0].clone()])); + assert!(!followed.lines().iter().any(|line| line.contains("one"))); + assert_eq!( + followed.position().1, + Some(OutputPosition { + pass: pass(2), + sequence: 0 + }) + ); + } +} diff --git a/agentctl/src/bin/agentctl/tui/terminal.rs b/agentctl/src/bin/agentctl/tui/terminal.rs new file mode 100644 index 0000000..92e6ff7 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/terminal.rs @@ -0,0 +1,265 @@ +use std::{ + fmt, + io::{Stdout, Write}, +}; + +use agent::Error; +use crossterm::{ + Command, + cursor::Show, + event::{DisableMouseCapture, EnableMouseCapture}, + terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; + +use super::app::App; +use super::view; + +pub(crate) struct Tui { + terminal: Terminal>, + view_state: view::ViewState, + pointer_shape: PointerShape, + active: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum PointerShape { + #[default] + Default, + Pointer, +} + +struct SetPointerShape(PointerShape); + +impl Command for SetPointerShape { + fn write_ansi(&self, output: &mut impl fmt::Write) -> fmt::Result { + let shape = match self.0 { + PointerShape::Default => "default", + PointerShape::Pointer => "pointer", + }; + write!(output, "\x1b]22;{shape}\x1b\\") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +struct ResetPointerShape; + +impl Command for ResetPointerShape { + fn write_ansi(&self, output: &mut impl fmt::Write) -> fmt::Result { + output.write_str("\x1b]22;\x1b\\") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Asks the terminal to put text on the system clipboard (OSC 52). +/// +/// Terminals may ignore it, and nothing reports whether one did, so callers +/// also show the text. +struct CopyToClipboard<'a>(&'a str); + +impl Command for CopyToClipboard<'_> { + fn write_ansi(&self, output: &mut impl fmt::Write) -> fmt::Result { + use base64::Engine as _; + let encoded = base64::engine::general_purpose::STANDARD.encode(self.0); + write!(output, "\x1b]52;c;{encoded}\x1b\\") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Tui { + pub(crate) fn enter() -> Result { + install_panic_hook(); + activate()?; + let terminal = match Terminal::new(CrosstermBackend::new(std::io::stdout())) { + Ok(terminal) => terminal, + Err(error) => { + let _ = deactivate(); + return Err(Error::from(error)); + } + }; + Ok(Self { + terminal, + view_state: view::ViewState::for_environment(), + pointer_shape: PointerShape::Default, + active: true, + }) + } + + /// Width of the terminal in cells. + pub(crate) fn width(&self) -> u16 { + self.terminal.size().map_or(0, |size| size.width) + } + + pub(crate) fn draw(&mut self, app: &App) -> Result { + let mut hit_map = None; + let view_state = &mut self.view_state; + self.terminal + .draw(|frame| hit_map = Some(view::render(frame, app, view_state))) + .map_err(Error::from)?; + Ok(hit_map.unwrap_or_default()) + } + + pub(crate) fn set_pointer_for( + &mut self, + hit_map: &view::HitMap, + position: Option<(u16, u16)>, + ) -> Result<(), Error> { + let shape = if position.is_some_and(|(column, row)| hit_map.clickable_at(column, row)) { + PointerShape::Pointer + } else { + PointerShape::Default + }; + if shape != self.pointer_shape { + crossterm::execute!(std::io::stdout(), SetPointerShape(shape))?; + self.pointer_shape = shape; + } + Ok(()) + } + + pub(crate) fn suspend(&mut self) -> Result<(), Error> { + deactivate()?; + self.pointer_shape = PointerShape::Default; + self.active = false; + Ok(()) + } + + pub(crate) fn resume(&mut self) -> Result<(), Error> { + activate()?; + self.pointer_shape = PointerShape::Default; + self.active = true; + crossterm::execute!(std::io::stdout(), Clear(ClearType::All))?; + self.terminal = Terminal::new(CrosstermBackend::new(std::io::stdout())).map_err(Error::from)?; + Ok(()) + } + + pub(crate) fn restore(&mut self) -> Result<(), Error> { + self.suspend() + } +} + +impl Drop for Tui { + fn drop(&mut self) { + if self.active { + let _ = deactivate(); + } + } +} + +/// Offers `text` to the system clipboard through the terminal. +pub(crate) fn copy_to_clipboard(text: &str) -> Result<(), Error> { + crossterm::execute!(std::io::stdout(), CopyToClipboard(text))?; + Ok(()) +} + +fn activate() -> Result<(), Error> { + enable_raw_mode()?; + if let Err(error) = enter_screen(&mut std::io::stdout()) { + let _ = deactivate(); + return Err(Error::from(error)); + } + Ok(()) +} + +fn deactivate() -> Result<(), Error> { + let screen = leave_screen(&mut std::io::stdout()); + let raw = disable_raw_mode(); + screen?; + raw.map_err(Error::from) +} + +fn enter_screen(output: &mut impl Write) -> std::io::Result<()> { + crossterm::execute!( + output, + EnterAlternateScreen, + EnableMouseCapture, + SetPointerShape(PointerShape::Default) + ) +} + +fn leave_screen(output: &mut impl Write) -> std::io::Result<()> { + crossterm::execute!( + output, + ResetPointerShape, + DisableMouseCapture, + LeaveAlternateScreen, + Show + ) +} + +fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = deactivate(); + previous(info); + })); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + // Crossterm deliberately routes these commands through WinAPI on Windows, + // so only ANSI terminals write their bytes into this in-memory output. + #[cfg(not(windows))] + #[test] + fn screen_activation_enables_mouse_capture_after_entering_the_alternate_screen() { + let mut output = Vec::new(); + + enter_screen(&mut output).expect("screen activation"); + + let output = String::from_utf8(output).expect("terminal commands are UTF-8"); + let alternate = output.find("?1049h").expect("enter alternate screen"); + let mouse = output.find("?1000h").expect("enable mouse capture"); + let pointer = output.find("]22;default").expect("set default pointer shape"); + assert!(alternate < mouse); + assert!(mouse < pointer); + } + + #[cfg(not(windows))] + #[test] + fn screen_cleanup_disables_mouse_capture_before_leaving_the_alternate_screen() { + let mut output = Vec::new(); + + leave_screen(&mut output).expect("screen cleanup"); + + let output = String::from_utf8(output).expect("terminal commands are UTF-8"); + let pointer = output.find("]22;").expect("reset pointer shape"); + let mouse = output.find("?1006l").expect("disable mouse capture"); + let alternate = output.find("?1049l").expect("leave alternate screen"); + assert!(pointer < mouse); + assert!(mouse < alternate); + } + + #[test] + fn pointer_shape_commands_use_osc_22_and_can_restore_the_terminal_default() { + let mut output = Vec::new(); + + crossterm::execute!(output, SetPointerShape(PointerShape::Pointer), ResetPointerShape) + .expect("pointer commands"); + + assert_eq!(output, b"\x1b]22;pointer\x1b\\\x1b]22;\x1b\\"); + } + + #[test] + fn clipboard_copies_use_osc_52_with_base64_text() { + let mut output = Vec::new(); + + crossterm::execute!(output, CopyToClipboard("agentctl-worker")).expect("clipboard command"); + + assert_eq!(output, b"\x1b]52;c;YWdlbnRjdGwtd29ya2Vy\x1b\\"); + } +} diff --git a/agentctl/src/bin/agentctl/tui/view.rs b/agentctl/src/bin/agentctl/tui/view.rs new file mode 100644 index 0000000..634821e --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/view.rs @@ -0,0 +1,2539 @@ +use ratatui::{ + Frame, + layout::{Constraint, Layout, Margin, Position, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Cell, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Table, TableState, Wrap}, +}; + +use super::MANIFEST_FILE; +use super::app::{ + App, CONFIRM_HINTS, CONFIRM_SSH_SETUP_HINTS, CONFIRM_SSH_SETUP_THEN_HINTS, CREATE_AGENT_HINTS, CreateField, + ForwardField, HELP, HELP_HINTS, HelpSection, Hint, Modal, MouseAction, NEW_SESSION_HINTS, OPEN_HINTS, + PORT_FORWARD_HINTS, Row as TreeRow, RowTarget, RowView, SELECTION_HINTS, SessionField, Tone, TreeRowId, View, + harness_label, +}; +use super::open::{MenuEntry, OpenMenu, OpenTarget}; + +/// Background of the selected row; without color it is drawn reversed instead. +const SELECTION: Color = Color::Rgb(52, 58, 70); +/// Narrowest tree that still shows the detail and age columns. +const WIDE_TREE: u16 = 70; +/// Narrowest terminal that shows the side panel beside the tree. +const SIDE_PANEL: u16 = 110; +/// Narrowest and widest name column of a wide tree. +const NAME_WIDTH: (usize, usize) = (12, 32); +/// Width of every form but create-Agent, whose pickers also show manifest paths. +const FORM_WIDTH: u16 = 64; +const CREATE_AGENT_FORM_WIDTH: u16 = 96; +/// Width of the help overlay: two columns inside its border and padding. +const HELP_WIDTH: u16 = 76; +/// Width of a help column, and of the key labels in it. +const HELP_COLUMN_WIDTH: usize = 36; +const HELP_KEY_WIDTH: usize = 8; +/// Label column shared by every form row. +const FORM_LABEL_WIDTH: usize = 12; +/// A picker's value between its arrows. +const PICKER_VALUE_WIDTH: usize = 18; +/// A picker's arrows, value and position, before its detail. +const PICKER_WIDTH: usize = PICKER_VALUE_WIDTH + 12; +const ERROR_HINTS: [Hint; 2] = [ + Hint::key("esc", "dismiss", crossterm::event::KeyCode::Esc), + Hint::key("q", "quit", crossterm::event::KeyCode::Char('q')), +]; +/// Lines the selection's hints may wrap onto before the footer cuts them short. +const SELECTION_HINT_LINES: usize = 2; +/// Separates hints on a line. +const HINT_SEPARATOR: &str = " · "; +/// Ends a hint line that could not fit every hint. +const HINT_OVERFLOW: &str = "…"; + +#[derive(Default)] +pub(crate) struct ViewState { + /// First tree row below the column header, pinned Agent aside. + tree_offset: usize, + forwards: ListState, + /// Draw without color; glyphs and modifiers still tell states apart. + no_color: bool, +} + +impl ViewState { + /// Honors `NO_COLOR` when it is set to anything but an empty string. + pub(crate) fn for_environment() -> Self { + Self { + no_color: std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()), + ..Self::default() + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WheelTarget { + Tree, + Forwards, + Detail, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum HitTarget { + Row(RowTarget), + Action(MouseAction), +} + +#[derive(Default)] +pub(crate) struct HitMap { + frame: Rect, + clicks: Vec<(Rect, HitTarget)>, + wheels: Vec<(Rect, WheelTarget)>, +} + +impl HitMap { + fn new(frame: Rect) -> Self { + Self { + frame, + ..Self::default() + } + } + + fn clear(&mut self) { + self.clicks.clear(); + self.wheels.clear(); + } + + fn click(&mut self, area: Rect, target: HitTarget) { + let area = area.intersection(self.frame); + if !area.is_empty() { + self.clicks.push((area, target)); + } + } + + fn wheel(&mut self, area: Rect, target: WheelTarget) { + let area = area.intersection(self.frame); + if !area.is_empty() { + self.wheels.push((area, target)); + } + } + + pub(crate) fn click_at(&self, column: u16, row: u16) -> Option { + let position = Position::new(column, row); + self.frame.contains(position).then_some(())?; + self.clicks + .iter() + .rev() + .find_map(|(area, target)| area.contains(position).then(|| target.clone())) + } + + pub(crate) fn clickable_at(&self, column: u16, row: u16) -> bool { + self.click_at(column, row).is_some() + } + + pub(crate) fn wheel_at(&self, column: u16, row: u16) -> Option { + let position = Position::new(column, row); + self.frame.contains(position).then_some(())?; + self.wheels + .iter() + .rev() + .find_map(|(area, target)| area.contains(position).then_some(*target)) + } +} + +/// Whether a terminal this wide shows the panel beside the tree. +pub(crate) const fn shows_side_panel(width: u16) -> bool { + width >= SIDE_PANEL +} + +pub(crate) fn render(frame: &mut Frame, app: &App, state: &mut ViewState) -> HitMap { + let mut hit_map = HitMap::new(frame.area()); + let [header, body, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(footer_height(app, frame.area().width)), + ]) + .areas(frame.area()); + render_header(frame, header, app, &mut hit_map); + if let Some(detail) = &app.detail { + render_detail(frame, body, detail, &mut hit_map); + } else if let Some(error) = &app.error { + render_error(frame, body, error, &mut hit_map); + } else if app.view == View::Forwards { + render_forwards(frame, body, app, state, &mut hit_map); + } else if shows_side_panel(body.width) { + // The panel keeps its width whatever is selected, so the tree's + // columns stay put while the selection moves. + let [tree, panel] = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) + .spacing(1) + .areas(body); + render_tree(frame, tree, app, state, &mut hit_map); + match (&app.selection, &app.transcript) { + (Some(TreeRowId::Session { .. }), Some(transcript)) => render_transcript(frame, panel, transcript), + (Some(TreeRowId::Agent(agent)), _) => { + render_agent_panel(frame, panel, agent, &app.agent_panel_lines(agent)); + } + _ => frame.render_widget(Block::bordered().border_style(Style::new().fg(Color::DarkGray)), panel), + } + } else { + render_tree(frame, body, app, state, &mut hit_map); + } + match &app.modal { + Some(Modal::Filter | Modal::Prompt(_)) => { + hit_map.clear(); + render_footer(frame, footer, app, &mut hit_map); + } + // A form carries its own hints, so it may use the footer's rows too. + Some(modal) => { + hit_map.clear(); + render_modal(frame, body.union(footer), app, modal, &mut hit_map); + } + None => render_footer(frame, footer, app, &mut hit_map), + } + if state.no_color { + let area = frame.area(); + let buffer = frame.buffer_mut(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + buffer[(x, y)].set_fg(Color::Reset).set_bg(Color::Reset); + } + } + } + hit_map +} + +fn render_header(frame: &mut Frame, area: Rect, app: &App, hit_map: &mut HitMap) { + let counts = app.triage_counts(); + let mut needs_you = Style::new().fg(Color::Yellow); + if counts.needs_you > 0 { + needs_you = needs_you.add_modifier(Modifier::BOLD); + } + let mut spans = vec![ + Span::styled( + " agentctl ", + Style::new().fg(Color::Cyan).add_modifier(Modifier::REVERSED), + ), + Span::raw(" "), + ]; + if let Some(error) = &app.connection_error { + spans.push(Span::styled( + format!("reconnecting: {error} · "), + Style::new().fg(Color::Red), + )); + } + let needs_you = Span::styled(format!("{} need you", counts.needs_you), needs_you); + let x = area + .x + .saturating_add(u16::try_from(Line::from(spans.clone()).width()).unwrap_or(u16::MAX)); + let width = u16::try_from(needs_you.width()).unwrap_or(u16::MAX); + hit_map.click( + Rect::new(x, area.y, width, 1), + HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + )), + ); + spans.push(needs_you); + // Before the counts, so a narrow header cuts those rather than the outcome + // of a change, which is gone after a few seconds. + if let Some((notice, _)) = &app.notice { + spans.push(Span::styled(format!(" · {notice}"), Style::new().fg(Color::Cyan))); + } + for (count, label, color) in [ + (counts.working, "working", Color::Green), + (counts.starting, "starting", Color::Cyan), + (counts.idle, "idle", Color::DarkGray), + (counts.failed, "failed", Color::Red), + (counts.provisioning, "provisioning", Color::Cyan), + (counts.archived, "archived", Color::DarkGray), + ] { + if count > 0 { + spans.push(Span::styled(format!(" · {count} {label}"), Style::new().fg(color))); + } + } + if !app.filter.is_empty() { + spans.push(Span::styled( + format!(" · filter: {}", app.filter), + Style::new().fg(Color::Cyan), + )); + } + if app.creating > 0 { + spans.push(Span::styled(" · creating forward…", Style::new().fg(Color::Cyan))); + } + if app.prompting > 0 { + spans.push(Span::styled(" · sending prompt…", Style::new().fg(Color::Cyan))); + } + if app.discovering { + spans.push(Span::styled(" · scanning manifests…", Style::new().fg(Color::Cyan))); + } + if let Some((agent, target)) = app.opening.first() { + spans.push(Span::styled( + format!(" · opening {} on {agent}…", target.name()), + Style::new().fg(Color::Cyan), + )); + } + frame.render_widget(Line::from(spans), area); +} + +fn render_tree(frame: &mut Frame, area: Rect, app: &App, state: &mut ViewState, hit_map: &mut HitMap) { + let rows = app.render_rows(); + if rows.is_empty() { + let placeholder = match (app.loaded, app.filter.is_empty()) { + (false, _) => "loading…", + (true, true) => "(no agents)", + (true, false) => "(nothing matches the filter)", + }; + frame.render_widget( + Paragraph::new(placeholder).style(Style::new().fg(Color::DarkGray)), + area, + ); + return; + } + let wide = area.width >= WIDE_TREE; + let mut header = vec![ + Cell::default(), + Cell::from("NAME"), + Cell::from("STATE"), + Cell::from(Line::from("FOR").right_aligned()), + ]; + // A wide tree sizes names to fit, within bounds, and gives the rest to the detail. + let name_width = rows + .iter() + .map(|row| name_cell(row).width()) + .max() + .unwrap_or_default() + .clamp(NAME_WIDTH.0, NAME_WIDTH.1); + let mut widths = vec![ + Constraint::Length(1), + if wide { + Constraint::Length(u16::try_from(name_width).unwrap_or(u16::MAX)) + } else { + Constraint::Fill(1) + }, + Constraint::Length(12), + Constraint::Length(4), + ]; + if wide { + header.extend([Cell::from("DETAIL"), Cell::from(Line::from("AGE").right_aligned())]); + widths.extend([Constraint::Fill(1), Constraint::Length(4)]); + } + // Every column but the detail, and a space between each pair. + let detail_width = usize::from(area.width).saturating_sub(1 + name_width + 12 + 4 + 4 + 5); + // Rows fit below the column header. + let height = usize::from(area.height.saturating_sub(1)); + let (offset, pinned) = tree_viewport(app, state.tree_offset, height); + state.tree_offset = offset; + let capacity = height.saturating_sub(usize::from(pinned.is_some())); + let visible = pinned + .into_iter() + .chain(offset..rows.len().min(offset.saturating_add(capacity))) + .collect::>(); + let table_rows = visible.iter().map(|&index| { + let row = tree_row(&rows[index], wide.then_some(detail_width)); + if pinned == Some(index) { + row.style(Style::new().add_modifier(Modifier::DIM)) + } else { + row + } + }); + let table = Table::new(table_rows, widths) + .header(Row::new(header).style(Style::new().fg(Color::DarkGray))) + .row_highlight_style(selection(state)); + let selected = app.selected_index(); + let mut table_state = + TableState::default().with_selected(visible.iter().position(|index| Some(*index) == selected)); + frame.render_stateful_widget(table, area, &mut table_state); + let body = Rect::new( + area.x, + area.y.saturating_add(1), + area.width, + area.height.saturating_sub(1), + ); + hit_map.wheel(body, WheelTarget::Tree); + for (line, index) in visible.into_iter().enumerate() { + let Some(target) = app.tree_id_at(index) else { + continue; + }; + let y = body.y.saturating_add(u16::try_from(line).unwrap_or(u16::MAX)); + hit_map.click( + Rect::new(body.x, y, body.width, 1), + HitTarget::Row(RowTarget::Tree(target.clone())), + ); + if let TreeRowId::Agent(agent) = target { + hit_map.click( + Rect::new(body.x, y, body.width.min(4), 1), + HitTarget::Action(MouseAction::FoldTree(agent)), + ); + } + } +} + +/// Chooses the first row shown so the selection stays in view, and pins the +/// Agent above it when the view starts among that Agent's Sessions. +fn tree_viewport(app: &App, offset: usize, height: usize) -> (usize, Option) { + let selected = app.selected_index().unwrap_or_default(); + let mut offset = offset.min(selected).min(app.rows.len().saturating_sub(height)); + loop { + let pinned = match app.rows.get(offset) { + Some(TreeRow::Session { group, .. }) => app.rows[..offset] + .iter() + .rposition(|row| *row == TreeRow::Agent(*group)), + _ => None, + }; + let capacity = height.saturating_sub(usize::from(pinned.is_some())).max(1); + if selected < offset.saturating_add(capacity) { + return (offset, pinned); + } + offset = selected + 1 - capacity; + } +} + +/// The selected Session's recent turns, wrapped to the panel, the newest at the bottom. +fn render_transcript(frame: &mut Frame, area: Rect, transcript: &super::app::Transcript) { + let block = Block::bordered() + .title(format!(" {} · recent turns ", transcript.session.as_str())) + .border_style(Style::new().fg(Color::DarkGray)); + let inner = block.inner(area); + let mut lines = crate::format::turn_lines(&transcript.turns); + if transcript.stopped { + lines = vec![format!( + "agent/{} is stopped; its turns are shown after a start.", + transcript.agent + )]; + } else if let Some(error) = &transcript.error { + lines.push(format!("Turns unavailable: {error}")); + } else if lines.is_empty() { + lines.push( + if transcript.loading { + "Loading…" + } else { + "No turns yet." + } + .to_owned(), + ); + } + let rows = lines + .iter() + .flat_map(|line| wrap(line, usize::from(inner.width))) + .collect::>(); + let visible = rows[rows.len().saturating_sub(usize::from(inner.height))..] + .iter() + .map(|row| { + let style = if row.starts_with("===") { + Style::new().fg(Color::DarkGray) + } else if row.starts_with("[user]") { + Style::new().fg(Color::Cyan) + } else { + Style::new() + }; + Line::from(Span::styled(row.clone(), style)) + }) + .collect::>(); + frame.render_widget(Paragraph::new(visible).block(block), area); +} + +/// Splits a line into rows of at most `width` cells. +fn wrap(line: &str, width: usize) -> Vec { + let mut rows = vec![String::new()]; + let mut used = 0; + for character in line.chars() { + let cells = Line::from(character.to_string()).width(); + if used + cells > width.max(1) { + rows.push(String::new()); + used = 0; + } + if let Some(row) = rows.last_mut() { + row.push(character); + } + used += cells; + } + rows +} + +/// A tree row's name cell: Sessions are indented under their Agent. +fn name_cell(row: &RowView) -> Line<'static> { + let tone = Style::new().fg(tone_color(row.tone)); + let (indent, name) = if row.agent { + ("", tone.add_modifier(Modifier::BOLD)) + } else { + (" ", Style::new()) + }; + Line::from(vec![ + Span::raw(indent), + Span::styled(row.marker, tone), + Span::raw(" "), + Span::styled(row.name.clone(), name), + ]) +} + +/// One tree row, with the detail and age columns when the detail has a +/// width. Every state has a glyph as well as a color. +fn tree_row(row: &RowView, detail_width: Option) -> Row<'static> { + let tone = Style::new().fg(tone_color(row.tone)); + let mut state = tone; + if row.attention && !row.agent { + state = state.add_modifier(Modifier::BOLD); + } + let mut cells = vec![ + Cell::from(Span::styled( + if row.attention { "▐" } else { "" }, + Style::new().fg(Color::Yellow), + )), + Cell::from(name_cell(row)), + Cell::from(Span::styled(row.state, state)), + Cell::from(Line::from(Span::styled(row.since.clone(), tone)).right_aligned()), + ]; + if let Some(width) = detail_width { + let style = if row.agent { + tone + } else { + Style::new().fg(Color::DarkGray) + }; + let detail = if row.detail_keeps_end { + tail_ellipsized(&row.detail, width) + } else { + row.detail.clone() + }; + cells.extend([ + Cell::from(Span::styled(detail, style)), + Cell::from(Line::from(Span::styled(row.age.clone(), Style::new().fg(Color::DarkGray))).right_aligned()), + ]); + } + Row::new(cells) +} + +fn render_forwards(frame: &mut Frame, area: Rect, app: &App, state: &mut ViewState, hit_map: &mut HitMap) { + let block = Block::bordered().title(" port-forwards "); + let inner = block.inner(area); + if app.forwards.is_empty() { + frame.render_widget( + Paragraph::new("(no port forwards — press f on an agent to create one)") + .style(Style::new().fg(Color::DarkGray)) + .block(block), + area, + ); + return; + } + let items = app + .forwards + .iter() + .map(|entry| { + let mut spans = vec![ + Span::styled("⇄ ", Style::new().fg(Color::Cyan)), + Span::styled( + format!("{:<8}", entry.label().unwrap_or_default()), + Style::new().fg(Color::Cyan), + ), + Span::raw(format!("{} → {}", entry.local, entry.guest_port)), + Span::styled(format!(" {}", entry.agent), Style::new().fg(Color::DarkGray)), + ]; + match &entry.status { + Some(status) => spans.push(Span::styled(format!(" {status}"), Style::new().fg(Color::Red))), + None => spans.push(Span::styled(" active", Style::new().fg(Color::Green))), + } + ListItem::new(Line::from(spans)) + }) + .collect::>(); + let list = List::new(items).block(block).highlight_style(selection(state)); + state.forwards.select(Some(app.forward_selected)); + frame.render_stateful_widget(list, area, &mut state.forwards); + hit_map.wheel(inner, WheelTarget::Forwards); + for visible in 0..usize::from(inner.height) { + let index = state.forwards.offset().saturating_add(visible); + let Some(entry) = app.forwards.get(index) else { + break; + }; + let y = inner.y.saturating_add(u16::try_from(visible).unwrap_or(u16::MAX)); + hit_map.click( + Rect::new(inner.x, y, inner.width, 1), + HitTarget::Row(RowTarget::Forward(entry.id)), + ); + } +} + +/// A detail view, with long lines wrapped to its width so a failure message +/// is read in full. +fn render_detail(frame: &mut Frame, area: Rect, detail: &super::app::Detail, hit_map: &mut HitMap) { + let block = Block::bordered().title(format!(" {} — q back · ↑/↓ scroll ", detail.title)); + let inner = block.inner(area); + let rows = wrapped(&detail.lines, inner.width); + let limit = rows.len().saturating_sub(usize::from(inner.height)); + detail.scroll_limit.set(Some(limit)); + let scroll = detail.scroll.min(limit); + let visible = rows.into_iter().skip(scroll).map(Line::from).collect::>(); + frame.render_widget(Paragraph::new(visible).block(block), area); + hit_map.wheel(inner, WheelTarget::Detail); +} + +/// The selected Agent's status beside the tree, wrapped to the panel. +fn render_agent_panel(frame: &mut Frame, area: Rect, agent: &str, lines: &[String]) { + let block = Block::bordered() + .title(format!(" {agent} · status ")) + .border_style(Style::new().fg(Color::DarkGray)); + let inner = block.inner(area); + let rows = wrapped(lines, inner.width) + .into_iter() + .map(Line::from) + .collect::>(); + frame.render_widget(Paragraph::new(rows).block(block), area); +} + +/// Lines split at their line breaks and wrapped to `width` cells. +fn wrapped(lines: &[String], width: u16) -> Vec { + lines + .iter() + .flat_map(|line| line.split('\n')) + .flat_map(|line| wrap(line, usize::from(width))) + .collect() +} + +fn render_error(frame: &mut Frame, area: Rect, error: &str, hit_map: &mut HitMap) { + let paragraph = Paragraph::new(error) + .style(Style::new().fg(Color::Red)) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); + let last_error_row = (area.y..area.bottom()) + .rev() + .find(|&y| (area.x..area.right()).any(|x| !frame.buffer_mut()[(x, y)].symbol().trim().is_empty())) + .unwrap_or(area.y); + let hint_y = last_error_row.saturating_add(2); + if hint_y < area.bottom() { + let hints = Rect::new(area.x, hint_y, area.width, 1); + render_hints(frame, hints, &ERROR_HINTS, Color::Red, Color::Red, hit_map, |_| true); + } +} + +/// Footer rows: the contextual hints on as many lines as they need at this +/// width, then the global hints. Below the tree that is the widest selection's +/// hints, so moving the selection or opening a prompt never moves the tree. +fn footer_height(app: &App, width: u16) -> u16 { + let own = [app.hints()]; + let contextual: &[&[Hint]] = if app.detail.is_some() || app.view == View::Forwards { + &own + } else { + &SELECTION_HINTS + }; + let lines = contextual + .iter() + .map(|hints| hint_lines(hints, width).len()) + .max() + .unwrap_or(1) + .clamp(1, SELECTION_HINT_LINES); + u16::try_from(lines + 1).unwrap_or(u16::MAX) +} + +/// Keys that apply wherever the tree is shown, the help first so a line cut +/// short still shows where the rest are. +const fn global_hints(app: &App) -> [Hint; 6] { + use crossterm::event::KeyCode; + let archived = if app.show_archived { + "hide archived" + } else { + "show archived" + }; + [ + Hint::key("?", "help", KeyCode::Char('?')), + Hint::key("tab", "needs you", KeyCode::Tab), + Hint::key("/", "filter", KeyCode::Char('/')), + Hint::key("A", archived, KeyCode::Char('A')), + Hint::key("F", "forwards", KeyCode::Char('F')), + Hint::key("q", "quit", KeyCode::Char('q')), + ] +} + +fn render_footer(frame: &mut Frame, area: Rect, app: &App, hit_map: &mut HitMap) { + let [contextual, global] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area); + let input = match &app.modal { + Some(Modal::Filter) => Some(("/".to_owned(), app.filter.as_str(), None)), + Some(Modal::Prompt(form)) => Some(( + format!("{} › ", form.session.as_str()), + form.input.as_str(), + form.error.as_deref(), + )), + _ => None, + }; + if let Some((prompt, text, error)) = input { + let width = usize::from(global.width).saturating_sub(Line::from(prompt.as_str()).width() + 1); + frame.render_widget( + Line::from(vec![ + Span::styled(prompt, Style::new().fg(Color::Cyan)), + Span::raw(tail_ellipsized(text, width)), + Span::styled("▏", Style::new().fg(Color::Cyan)), + ]), + global, + ); + match error { + Some(error) => { + let line = Rect::new(contextual.x, contextual.bottom().saturating_sub(1), contextual.width, 1); + frame.render_widget(Span::styled(error.to_owned(), Style::new().fg(Color::Red)), line); + } + None => render_hints( + frame, + contextual, + app.hints(), + Color::Cyan, + Color::DarkGray, + hit_map, + |_| true, + ), + } + return; + } + render_hints( + frame, + contextual, + app.hints(), + Color::Cyan, + Color::DarkGray, + hit_map, + |_| app.error.is_none(), + ); + // A detail view's own hints replace the tree's, whose keys do not apply there. + if app.detail.is_some() { + return; + } + render_hints( + frame, + global, + &global_hints(app), + Color::DarkGray, + Color::DarkGray, + hit_map, + |hint| { + if app.modal.is_some() || app.detail.is_some() || app.view == View::Forwards { + false + } else if app.error.is_some() { + hint.label == "q" + } else { + true + } + }, + ); +} + +/// Draws hints on the bottom lines of `area`, wrapping between hints. Hints +/// that do not fit give way to an ellipsis, so a cut line reads as cut. +fn render_hints( + frame: &mut Frame, + area: Rect, + hints: &[Hint], + key_color: Color, + description_color: Color, + hit_map: &mut HitMap, + clickable: impl Fn(&Hint) -> bool, +) { + let mut lines = hint_lines(hints, area.width); + let overflow = lines.len() > usize::from(area.height); + lines.truncate(usize::from(area.height)); + if overflow && let Some(last) = lines.last_mut() { + let reserved = separator_width().saturating_add(text_width(HINT_OVERFLOW)); + while let [kept @ .., _] = *last + && hints_width(last).saturating_add(reserved) > area.width + { + *last = kept; + } + } + let top = area + .bottom() + .saturating_sub(u16::try_from(lines.len()).unwrap_or(u16::MAX)); + let last_line = lines.len().saturating_sub(1); + for (row, (line, y)) in lines.iter().zip(top..).enumerate() { + let mut spans = Vec::new(); + let mut x = area.x; + for (index, hint) in line.iter().enumerate() { + if index > 0 { + spans.push(Span::styled(HINT_SEPARATOR, Style::new().fg(description_color))); + x = x.saturating_add(separator_width()); + } + spans.push(Span::styled(hint.label, Style::new().fg(key_color))); + spans.push(Span::raw(" ")); + spans.push(Span::styled(hint.description, Style::new().fg(description_color))); + let width = hint_width(hint); + if clickable(hint) + && let Some((code, modifiers)) = hint.key + { + hit_map.click( + Rect::new(x, y, width.min(area.right().saturating_sub(x)), 1), + HitTarget::Action(MouseAction::Key(code, modifiers)), + ); + } + x = x.saturating_add(width); + } + if overflow && row == last_line { + if !line.is_empty() { + spans.push(Span::styled(HINT_SEPARATOR, Style::new().fg(description_color))); + } + spans.push(Span::styled(HINT_OVERFLOW, Style::new().fg(description_color))); + } + frame.render_widget(Line::from(spans), Rect::new(area.x, y, area.width, 1)); + } +} + +/// Splits hints into lines no wider than `width`, keeping each hint whole. +fn hint_lines(hints: &[Hint], width: u16) -> Vec<&[Hint]> { + let mut lines = Vec::new(); + let mut start = 0; + for end in 1..=hints.len() { + if end - start > 1 && hints_width(&hints[start..end]) > width { + lines.push(&hints[start..end - 1]); + start = end - 1; + } + } + if start < hints.len() { + lines.push(&hints[start..]); + } + lines +} + +fn hints_width(hints: &[Hint]) -> u16 { + let separators = u16::try_from(hints.len().saturating_sub(1)).unwrap_or(u16::MAX); + hints + .iter() + .map(hint_width) + .fold(separators.saturating_mul(separator_width()), u16::saturating_add) +} + +fn separator_width() -> u16 { + text_width(HINT_SEPARATOR) +} + +fn text_width(text: &str) -> u16 { + u16::try_from(Line::from(text).width()).unwrap_or(u16::MAX) +} + +fn hint_width(hint: &Hint) -> u16 { + u16::try_from(Line::from(format!("{} {}", hint.label, hint.description)).width()).unwrap_or(u16::MAX) +} + +fn render_modal(frame: &mut Frame, area: Rect, app: &App, modal: &Modal, hit_map: &mut HitMap) { + match modal { + Modal::ConfirmDelete { agent, sessions } => { + Form::new(" delete ", Color::Red, &CONFIRM_HINTS) + .row(Line::from(format!("Delete agent {agent}?"))) + .row(note_line(&format!("{sessions} session(s) will be deleted with it."))) + .render(frame, area, FORM_WIDTH, hit_map); + } + Modal::ConfirmStop { agent } => { + Form::new(" stop ", Color::Yellow, &CONFIRM_HINTS) + .row(Line::from(format!("Stop agent {agent}?"))) + .row(note_line("Running harnesses stop with its VM.")) + .row(note_line( + "Its disk is kept; attaching after a start resumes a Session.", + )) + .render(frame, area, FORM_WIDTH, hit_map); + } + Modal::ConfirmDeleteSession { agent, session } => { + Form::new(" delete ", Color::Red, &CONFIRM_HINTS) + .row(Line::from(format!("Delete session {agent}/{session}?"))) + .row(note_line("Its harness is stopped and the Session is removed.")) + .render(frame, area, FORM_WIDTH, hit_map); + } + Modal::NewSession(form) => render_new_session(frame, area, form, hit_map), + Modal::CreateAgent(form) => render_create_agent(frame, area, form, hit_map), + Modal::PortForward(form) => render_port_forward(frame, area, form, hit_map), + Modal::Help => render_help(frame, area, hit_map), + Modal::Open(menu) => render_open(frame, area, menu, hit_map), + Modal::ConfirmQuit => render_confirm_quit(frame, area, app, hit_map), + Modal::ConfirmSshSetup { include, then, .. } => { + render_confirm_ssh_setup(frame, area, include, *then, hit_map); + } + // Typed in the footer, so the tree and the Session's turns stay in view. + Modal::Filter | Modal::Prompt(_) => {} + } +} + +/// The ways into one Agent, one per row; unavailable ones are dimmed, and why is listed below them. +fn render_open(frame: &mut Frame, area: Rect, menu: &OpenMenu, hit_map: &mut HitMap) { + const REASON_ROWS: usize = 3; + let title = format!(" open {} ", menu.agent); + let width = usize::from(FORM_WIDTH.saturating_sub(4)); + let mut form = Form::new(&title, Color::Cyan, &OPEN_HINTS); + for (index, item) in menu.items.iter().enumerate() { + let label = item.entry.label(); + if item.unavailable.is_some() { + form = form.row(note_line(&format!(" {label}"))); + continue; + } + let selected = index == menu.selected; + let marker = if selected { "▸ " } else { " " }; + let key = item.entry.key().map_or_else(String::new, |key| key.to_string()); + let fill = width.saturating_sub(2 + Line::from(label.as_str()).width() + key.len() + 1); + let style = if selected { + Style::new().fg(Color::Cyan) + } else { + Style::new() + }; + form = form.row(Line::from(vec![ + Span::styled(marker, Style::new().fg(Color::Cyan)), + Span::styled(label, style), + Span::raw(" ".repeat(fill)), + Span::styled(key, Style::new().fg(Color::Cyan)), + ])); + } + // Below the rows, where there is room to read them: each reason once, + // wrapped, and cut short so the menu still fits an 80x24 terminal. + for (labels, reason) in menu.unavailable_reasons() { + form = form.row(Line::default()); + let mut rows = wrap(&format!("{}: {reason}", labels.join(", ")), width); + if rows.len() > REASON_ROWS { + rows.truncate(REASON_ROWS); + if let Some(last) = rows.last_mut() { + let kept = last.chars().take(width.saturating_sub(1)).collect::(); + *last = format!("{}…", kept.trim_end()); + } + } + for row in rows { + form = form.row(note_line(&row)); + } + } + if menu.items.iter().any(|item| item.entry == MenuEntry::SetUpSsh) { + form = form + .row(Line::default()) + .row(Line::from(Span::styled( + "SSH needs a line in ~/.ssh/config;", + Style::new().fg(Color::Yellow), + ))) + .row(Line::from(Span::styled( + "you are asked before it is added.", + Style::new().fg(Color::Yellow), + ))); + } + let target = form.render(frame, area, FORM_WIDTH, hit_map); + for (index, item) in menu.items.iter().enumerate() { + if item.unavailable.is_none() { + hit_map.click( + line_area(target, index), + HitTarget::Action(MouseAction::ChooseOpen(index)), + ); + } + } +} + +/// Lists the forwards that close with the TUI before it quits. +fn render_confirm_quit(frame: &mut Frame, area: Rect, app: &App, hit_map: &mut HitMap) { + const LISTED: usize = 6; + let width = usize::from(FORM_WIDTH.saturating_sub(4)); + let mut form = Form::new(" quit ", Color::Cyan, &CONFIRM_HINTS) + .row(Line::from("Quit agentctl tui?")) + .row(note_line("These forwards close with it:")); + for entry in app.forwards.iter().take(LISTED) { + let mapping = format!(" {} ", entry.mapping()); + let agent = fixed_width(&entry.agent, width.saturating_sub(Line::from(mapping.as_str()).width())); + form = form.row(Line::from(vec![ + Span::styled(mapping, Style::new().fg(Color::Cyan)), + Span::styled(agent.trim_end().to_owned(), Style::new().fg(Color::DarkGray)), + ])); + } + if let Some(more) = app.forwards.len().checked_sub(LISTED).filter(|more| *more > 0) { + form = form.row(note_line(&format!(" …and {more} more"))); + } + form.render(frame, area, FORM_WIDTH, hit_map); +} + +/// Shows the exact line SSH setup adds, and where, before anything is written. +fn render_confirm_ssh_setup( + frame: &mut Frame, + area: Rect, + include: &agent::ssh::UserInclude, + then: Option, + hit_map: &mut HitMap, +) { + let file = abbreviate_home(&include.user_config.display().to_string()); + let hints: &[Hint] = if then.is_some() { + &CONFIRM_SSH_SETUP_THEN_HINTS + } else { + &CONFIRM_SSH_SETUP_HINTS + }; + let mut form = Form::new(" set up SSH ", Color::Cyan, hints) + .row(Line::from("Editors reach Agents through your OpenSSH config.")) + .row(Line::from(format!("Add this line at the top of {file}?"))) + .row(Line::default()) + .row(Line::from(Span::styled( + format!(" {}", include.line), + Style::new().fg(Color::Cyan), + ))) + .row(Line::default()) + .row(note_line("Existing lines are kept. It is added once for every")) + .row(note_line("Agent, and new Agents need nothing more.")); + if let Some(target) = then { + form = form + .row(Line::default()) + .row(Line::from(format!("Then: {}.", target.label()))); + } + form.render(frame, area, FORM_WIDTH, hit_map); +} + +/// Every key, grouped by where it applies, in two columns over the current view. +fn render_help(frame: &mut Frame, area: Rect, hit_map: &mut HitMap) { + let heading = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD); + let label = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let column = |sections: &[HelpSection]| { + let mut lines = Vec::new(); + for (index, (title, keys)) in sections.iter().enumerate() { + if index > 0 { + lines.push(vec![Span::raw("")]); + } + lines.push(vec![Span::styled(*title, heading)]); + for (key, description) in *keys { + lines.push(vec![ + Span::styled(format!("{key:>HELP_KEY_WIDTH$}"), label), + Span::raw(format!(" {description}")), + ]); + } + } + lines + }; + let [left, right] = HELP.map(column); + let mut form = Form::new(" keys ", Color::Cyan, &HELP_HINTS); + for index in 0..left.len().max(right.len()) { + let mut spans = left.get(index).cloned().unwrap_or_default(); + let used = Line::from(spans.clone()).width(); + spans.push(Span::raw(" ".repeat(HELP_COLUMN_WIDTH.saturating_sub(used)))); + spans.extend(right.get(index).cloned().unwrap_or_default()); + form = form.row(Line::from(spans)); + } + form.render(frame, area, HELP_WIDTH, hit_map); +} + +fn render_new_session(frame: &mut Frame, area: Rect, form: &super::app::SessionForm, hit_map: &mut HitMap) { + let harness = form + .installation() + .map_or("", |installation| harness_label(installation.kind)); + let target = Form::new(" new session ", Color::Cyan, &NEW_SESSION_HINTS) + .row(labeled("Agent", false, text_input(&form.agent, false, ""))) + .row(labeled( + "Name", + form.field == SessionField::Name, + text_input(&form.name, form.field == SessionField::Name, ""), + )) + .row(labeled( + "Model", + form.field == SessionField::Model, + text_input( + &form.model, + form.field == SessionField::Model, + &selection_hint(form.model_default()), + ), + )) + .row(labeled( + "Effort", + form.field == SessionField::Effort, + text_input( + &form.effort, + form.field == SessionField::Effort, + &selection_hint(form.effort_default()), + ), + )) + .row(labeled( + "Harness", + false, + picker(harness, false, form.harness, form.harnesses.len(), "", 0), + )) + .error(form.error.as_deref()) + .render(frame, area, FORM_WIDTH, hit_map); + for (row, field) in [ + (1, SessionField::Name), + (2, SessionField::Model), + (3, SessionField::Effort), + ] { + hit_map.click( + line_area(target, row), + HitTarget::Action(MouseAction::FocusSessionField(field)), + ); + } + if let Some(last) = form.harnesses.len().checked_sub(1) { + let previous = form.harness.checked_sub(1).unwrap_or(last); + let next = if form.harness >= last { 0 } else { form.harness + 1 }; + map_picker_targets( + line_area(target, 4), + MouseAction::SelectHarness(previous), + MouseAction::SelectHarness(next), + hit_map, + ); + } +} + +/// What an empty selection field resolves to: the manifest default or the harness's own. +fn selection_hint(manifest_default: Option<&str>) -> String { + manifest_default.map_or_else( + || "harness default".to_owned(), + |default| format!("{default} (manifest default)"), + ) +} + +fn render_create_agent(frame: &mut Frame, area: Rect, form: &super::app::CreateForm, hit_map: &mut HitMap) { + let candidate_error = form.candidate().and_then(|candidate| candidate.name.as_ref().err()); + let mut widget = Form::new(" create agent ", Color::Cyan, &CREATE_AGENT_HINTS) + .error(form.error.as_ref().or(candidate_error).map(String::as_str)); + let Some((agent, candidate)) = form.agent().zip(form.candidate()) else { + widget + .row(Line::from("No agent manifests found.")) + .row(note_line(&format!( + "Start the TUI inside a repository or directory tree containing {MANIFEST_FILE}," + ))) + .row(note_line("or apply one first: agentctl apply -f")) + .row(Line::default()) + .render(frame, area, CREATE_AGENT_FORM_WIDTH, hit_map); + return; + }; + let detail_width = + usize::from(CREATE_AGENT_FORM_WIDTH.saturating_sub(4)).saturating_sub(FORM_LABEL_WIDTH + PICKER_WIDTH); + let manifest_file = candidate.path.file_name().map_or_else( + || candidate.path.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ); + widget = widget + .row(labeled( + "Agent", + form.field == CreateField::Agent, + picker( + &agent.label(), + form.field == CreateField::Agent, + form.agent, + form.agents.len(), + &abbreviate_home(&agent.directory.display().to_string()), + detail_width, + ), + )) + .row(labeled( + "Variant", + form.field == CreateField::Variant, + picker( + &form.variant_label().unwrap_or_default(), + form.field == CreateField::Variant, + form.variant, + agent.variants.len(), + &manifest_file, + detail_width, + ), + )) + .row(labeled( + "Name", + form.field == CreateField::Name, + text_input( + &form.name, + form.field == CreateField::Name, + form.placeholder().unwrap_or_default(), + ), + )) + .row(labeled( + "Env file", + form.field == CreateField::EnvironmentFile, + text_input( + &form.env_file, + form.field == CreateField::EnvironmentFile, + "default: .env beside manifest", + ), + )); + let target = widget.render(frame, area, CREATE_AGENT_FORM_WIDTH, hit_map); + for (row, field) in [ + (0, CreateField::Agent), + (1, CreateField::Variant), + (2, CreateField::Name), + (3, CreateField::EnvironmentFile), + ] { + hit_map.click( + line_area(target, row), + HitTarget::Action(MouseAction::FocusCreateField(field)), + ); + } + for (row, field) in [(0, CreateField::Agent), (1, CreateField::Variant)] { + map_picker_targets( + line_area(target, row), + MouseAction::SelectCreate { field, delta: -1 }, + MouseAction::SelectCreate { field, delta: 1 }, + hit_map, + ); + } +} + +fn render_port_forward(frame: &mut Frame, area: Rect, form: &super::app::ForwardForm, hit_map: &mut HitMap) { + let title = if form.replace.is_some() { + " edit forward " + } else { + " port forward " + }; + let text = |label, value: &str, field, placeholder| { + labeled( + label, + form.field == field, + text_input(value, form.field == field, placeholder), + ) + }; + let target = Form::new(title, Color::Cyan, &PORT_FORWARD_HINTS) + .row(labeled("Agent", false, text_input(&form.agent, false, ""))) + .row(text("Address", &form.address, ForwardField::Address, "127.0.0.1")) + .row(text( + "Local port", + &form.local, + ForwardField::LocalPort, + "same as guest port", + )) + .row(text("Guest port", &form.guest, ForwardField::GuestPort, "")) + .error(form.error.as_deref()) + .render(frame, area, FORM_WIDTH, hit_map); + for (row, field) in [ + (1, ForwardField::Address), + (2, ForwardField::LocalPort), + (3, ForwardField::GuestPort), + ] { + hit_map.click( + line_area(target, row), + HitTarget::Action(MouseAction::FocusForwardField(field)), + ); + } +} + +/// A modal drawn at a fixed size whatever is typed: one line per row, a line +/// for an error, then the form's key hints. Row `n` is drawn on line +/// `n`, which is where callers put its mouse targets. +struct Form<'a> { + title: &'a str, + border: Color, + hints: &'a [Hint], + rows: Vec>, + error: Line<'static>, +} + +impl<'a> Form<'a> { + fn new(title: &'a str, border: Color, hints: &'a [Hint]) -> Self { + Self { + title, + border, + hints, + rows: Vec::new(), + error: Line::default(), + } + } + + fn row(mut self, row: Line<'static>) -> Self { + self.rows.push(row); + self + } + + /// Shows a validation or submission error above the hints. + fn error(mut self, error: Option<&str>) -> Self { + self.error = error.map_or_else(Line::default, |error| { + Line::from(Span::styled(error.to_owned(), Style::new().fg(Color::Red))) + }); + self + } + + fn render(self, frame: &mut Frame, area: Rect, width: u16, hit_map: &mut HitMap) -> Rect { + let hint_row = self.rows.len() + 1; + let mut lines = self.rows; + // The hints are drawn into their line after the block, as the footer's are. + lines.extend([self.error, Line::default()]); + let height = u16::try_from(lines.len()).unwrap_or(u16::MAX).saturating_add(2); + let target = centered_rect(area, width.min(area.width), height.min(area.height)); + frame.render_widget(Clear, target); + let block = Block::bordered() + .title(self.title.to_owned()) + .border_style(Style::new().fg(self.border)) + .padding(Padding::horizontal(1)); + frame.render_widget(Paragraph::new(lines).block(block), target); + render_hints( + frame, + line_area(target, hint_row), + self.hints, + Color::Cyan, + Color::DarkGray, + hit_map, + |_| true, + ); + target + } +} + +/// A form row: the label, highlighted while the row has focus, then its value. +fn labeled(label: &str, focused: bool, value: Vec>) -> Line<'static> { + let style = if focused { + Style::new().fg(Color::Cyan) + } else { + Style::new().fg(Color::DarkGray) + }; + let mut spans = vec![Span::styled(format!("{label: Vec> { + let mut spans = vec![Span::raw(" ")]; + let cursor = Span::styled("▏", Style::new().fg(Color::Cyan)); + if !value.is_empty() { + spans.push(Span::raw(value.to_owned())); + spans.extend(focused.then_some(cursor)); + return spans; + } + let gray = Style::new().fg(Color::DarkGray); + let mut placeholder = placeholder.chars(); + match placeholder.next() { + Some(first) => spans.extend([ + Span::styled( + first.to_string(), + if focused { + gray.add_modifier(Modifier::REVERSED) + } else { + gray + }, + ), + Span::styled(placeholder.collect::(), gray), + ]), + None => spans.extend(focused.then_some(cursor)), + } + spans +} + +/// A value chosen with ←/→: arrows around it, its position, then a detail +/// shortened from the front to `detail_width`. +fn picker( + value: &str, + focused: bool, + selected: usize, + total: usize, + detail: &str, + detail_width: usize, +) -> Vec> { + let (arrow, value_style) = if focused { + (Style::new().fg(Color::Cyan), Style::new().fg(Color::Cyan)) + } else { + (Style::new().fg(Color::DarkGray), Style::new()) + }; + let position = format!("{}/{}", selected.saturating_add(1), total); + vec![ + Span::styled("◂ ", arrow), + Span::styled(fixed_width(value, PICKER_VALUE_WIDTH), value_style), + Span::styled(" ▸", arrow), + Span::styled(format!(" {position:>5} "), Style::new().fg(Color::DarkGray)), + Span::styled(tail_ellipsized(detail, detail_width), Style::new().fg(Color::DarkGray)), + ] +} + +fn map_picker_targets(line: Rect, previous: MouseAction, next: MouseAction, hit_map: &mut HitMap) { + let arrows = line + .x + .saturating_add(u16::try_from(FORM_LABEL_WIDTH).unwrap_or(u16::MAX)); + let value = u16::try_from(PICKER_VALUE_WIDTH).unwrap_or(u16::MAX); + hit_map.click(Rect::new(arrows, line.y, 2, 1), HitTarget::Action(previous)); + hit_map.click( + Rect::new(arrows.saturating_add(2).saturating_add(value), line.y, 2, 1), + HitTarget::Action(next), + ); +} + +fn note_line(note: &str) -> Line<'static> { + Line::from(Span::styled(note.to_owned(), Style::new().fg(Color::DarkGray))) +} + +fn fixed_width(value: &str, width: usize) -> String { + let mut characters = value.chars(); + let prefix = characters.by_ref().take(width).collect::(); + if characters.next().is_none() { + format!("{prefix:(); + truncated.push('…'); + truncated + } +} + +fn tail_ellipsized(value: &str, width: usize) -> String { + if Line::from(value).width() <= width { + return value.to_owned(); + } + if width == 0 { + return String::new(); + } + + let available = width.saturating_sub(1); + let mut start = value.len(); + for (index, _) in value.char_indices().rev() { + if Line::from(&value[index..]).width() > available { + break; + } + start = index; + } + format!("…{}", &value[start..]) +} + +fn abbreviate_home(path: &str) -> String { + abbreviate(path, std::env::var("HOME").ok().as_deref()) +} + +fn abbreviate(path: &str, home: Option<&str>) -> String { + home.filter(|home| !home.is_empty()) + .and_then(|home| { + let rest = path.strip_prefix(home)?; + (rest.is_empty() || rest.starts_with('/')).then(|| format!("~{rest}")) + }) + .unwrap_or_else(|| path.to_owned()) +} + +/// Line `line` of a form's content, inside its border and padding. +fn line_area(popup: Rect, line: usize) -> Rect { + let inner = popup.inner(Margin::new(2, 1)); + let y = inner.y.saturating_add(u16::try_from(line).unwrap_or(u16::MAX)); + if y >= inner.bottom() { + Rect::default() + } else { + Rect::new(inner.x, y, inner.width, 1) + } +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + Rect { + x, + y, + width, + height: height.min(area.height), + } +} + +const fn selection(state: &ViewState) -> Style { + if state.no_color { + Style::new().add_modifier(Modifier::REVERSED) + } else { + Style::new().bg(SELECTION) + } +} + +const fn tone_color(tone: Tone) -> Color { + match tone { + Tone::Green => Color::Green, + Tone::Yellow => Color::Yellow, + Tone::Cyan => Color::Cyan, + Tone::Gray => Color::DarkGray, + Tone::Red => Color::Red, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use ratatui::{Terminal, backend::TestBackend}; + + use super::*; + + fn buffer_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + let area = buffer.area(); + let mut text = String::new(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + text.push_str(buffer[(x, y)].symbol()); + } + text.push('\n'); + } + text + } + + fn draw(terminal: &mut Terminal, app: &App) -> HitMap { + let mut state = ViewState::default(); + draw_with_state(terminal, app, &mut state) + } + + fn draw_with_state(terminal: &mut Terminal, app: &App, state: &mut ViewState) -> HitMap { + let mut hit_map = None; + terminal + .draw(|frame| hit_map = Some(render(frame, app, state))) + .expect("test draw"); + hit_map.expect("renderer returns a hit map") + } + + fn tree_app(count: usize) -> App { + let agents = (0..count) + .map(|index| { + let yaml = format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: agent-{index:02}\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + \x20 - type: claudeCode\n\ + \x20 version: \"1.0.0\"\n\ + \x20 auth: mediated\n\ + \x20 secrets: []\n\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ); + agent::manifest::decode(yaml.as_bytes()).expect("test manifest") + }) + .collect(); + let mut app = App::new(); + app.apply_snapshot(agents, Vec::new()); + app + } + + fn create_modal_geometry(text: &str) -> (String, usize, usize) { + let top = text + .lines() + .position(|line| line.contains("create agent")) + .expect("create Agent modal top"); + let border = text.lines().nth(top).expect("create Agent modal border").to_owned(); + let bottom = text + .lines() + .enumerate() + .skip(top + 1) + .find_map(|(row, line)| line.contains('└').then_some(row)) + .expect("create Agent modal bottom"); + (border, top, bottom) + } + + fn text_column(line: &str, text: &str) -> usize { + line.split_once(text).expect("text in rendered row").0.chars().count() + } + + #[test] + fn frame_shows_header_counts_tree_and_hints() { + let app = App::new(); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("agentctl")); + assert!(text.contains("agentctl 0 need you")); + assert!(text.contains("loading…")); + assert!(text.contains("A show archived · F forwards · q quit")); + } + + #[test] + fn the_footer_wraps_every_selection_hint_at_eighty_columns() { + let mut app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + let footer = |terminal: &Terminal| { + let text = buffer_text(terminal); + let mut lines = text.lines().rev().take(3).map(str::trim_end).collect::>(); + lines.reverse(); + lines.into_iter().map(str::to_owned).collect::>() + }; + + app.selection = Some(TreeRowId::Session { + agent: "agent-00".into(), + session: agent::sessions::SessionName::new("main").expect("name"), + }); + let hit_map = draw(&mut terminal, &app); + assert_eq!( + footer(&terminal), + [ + "enter attach · p prompt · o open… · a archive · d delete · s describe · y yaml", + "n new session · c new agent", + "? help · tab needs you · / filter · A show archived · F forwards · q quit", + ] + ); + assert_eq!( + hit_map.click_at(2, 10), + Some(HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Char('n'), + crossterm::event::KeyModifiers::NONE, + ))), + "a wrapped hint is clicked where it is drawn" + ); + + app.selection = Some(TreeRowId::Agent("agent-00".into())); + app.show_archived = true; + draw(&mut terminal, &app); + assert_eq!( + footer(&terminal), + [ + "enter fold · n new session · o open… · e exec · f forward · d delete", + "p provisioning · s describe · y yaml · x stop · z all · c new agent", + "? help · tab needs you · / filter · A hide archived · F forwards · q quit", + ] + ); + + let mut wide = Terminal::new(TestBackend::new(140, 12)).expect("test terminal"); + draw(&mut wide, &app); + let text = buffer_text(&wide); + let lines = text.lines().rev().take(3).collect::>(); + assert!(lines[1].starts_with("enter fold"), "two footer lines fit:\n{text}"); + assert!(!lines[2].contains("enter"), "{text}"); + } + + #[test] + fn a_footer_too_narrow_for_its_hints_ends_in_an_ellipsis() { + let mut app = triage_app(); + app.selection = Some(TreeRowId::Agent("agent-00".into())); + let mut terminal = Terminal::new(TestBackend::new(40, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().rev().take(3).map(str::trim_end).collect::>(); + assert_eq!(lines[2], "enter fold · n new session · o open…"); + assert_eq!(lines[1], "e exec · f forward · d delete · …"); + assert!(lines.iter().all(|line| line.chars().count() <= 40)); + } + + #[test] + fn the_header_tells_what_an_archive_did_before_its_counts() { + let mut app = triage_app(); + app.notice = Some(("main archived · A to show".into(), std::time::Instant::now())); + let mut terminal = Terminal::new(TestBackend::new(50, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let header = text.lines().next().unwrap_or_default(); + assert_eq!( + header.trim_end(), + " agentctl 1 need you · main archived · A to show", + "a narrow header cuts the counts, not the notice" + ); + } + + #[test] + fn the_footer_keeps_its_height_below_the_tree_and_fits_other_views() { + let mut app = triage_app(); + let mut heights = Vec::new(); + for selection in [ + TreeRowId::Agent("agent-00".into()), + TreeRowId::Session { + agent: "agent-00".into(), + session: agent::sessions::SessionName::new("main").expect("name"), + }, + ] { + app.selection = Some(selection); + heights.push(footer_height(&app, 80)); + } + app.modal = Some(Modal::Filter); + heights.push(footer_height(&app, 80)); + assert_eq!(heights, [3, 3, 3], "the tree keeps its rows"); + assert_eq!(footer_height(&app, 140), 2, "a wide terminal needs one line per group"); + + app.modal = None; + app.view = View::Forwards; + assert_eq!(footer_height(&app, 80), 2, "the forwards view sizes for its own keys"); + app.view = View::Tree; + app.detail = Some(super::super::app::Detail::text("describe".into(), Vec::new())); + assert_eq!(footer_height(&app, 80), 2, "and so does a detail"); + } + + fn session(agent: &str, name: &str, state: &str) -> agent::sessions::Session { + let lifecycle = match state { + "working" | "waitingForInput" => "running", + other => other, + }; + serde_json::from_value(serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "agentId": "00000000-0000-0000-0000-000000000002", + "agent": agent, + "name": name, + "harness": "claudeCode", + "modelSelection": {"model": "fable"}, + "createdAt": "2026-08-25T00:00:00Z", + "status": {"state": state, "lifecycle": {"state": lifecycle}} + })) + .expect("test session") + } + + fn triage_app() -> App { + let mut app = tree_app(2); + let agents = std::mem::take(&mut app.agents); + app.apply_snapshot( + agents, + vec![ + session("agent-00", "review", "waitingForInput"), + session("agent-00", "main", "working"), + ], + ); + app + } + + #[test] + fn the_tree_aligns_state_columns_and_marks_sessions_that_need_input() { + let app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(100, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + assert!(lines[0].contains("1 need you · 1 working")); + assert!(lines[1].contains("NAME") && lines[1].contains("DETAIL") && lines[1].contains("AGE")); + assert!(lines[2].starts_with("▐ ▾ agent-00"), "{}", lines[2]); + assert!(lines[3].starts_with(" * main"), "{}", lines[3]); + assert!(lines[4].starts_with("▐ ! review"), "{}", lines[4]); + let state = text_column(lines[1], "STATE"); + assert_eq!(text_column(lines[3], "Working"), state); + assert_eq!(text_column(lines[4], "Needs you"), state); + assert_eq!( + text_column(lines[4], "Claude Code · fable"), + text_column(lines[1], "DETAIL") + ); + } + + fn failing_app() -> App { + let mut app = triage_app(); + let mut agents = app.agents.clone(); + agents[1].status.conditions.push(agent::Condition { + kind: agent::Condition::READY.into(), + status: agent::ConditionStatus::False, + reason: "SandboxReconcileFailed".into(), + message: "Sandbox operation failed: resolve Sandbox Image: cache error at /home/user/.agent/cache/tmp/load-4.blob: No space left on device (os error 28)".into(), + last_transition_time: None, + }); + agents[1].status.failure = Some(agent::FailureKind::Transient); + let sessions = app.sessions.clone(); + app.apply_snapshot(agents, sessions); + app + } + + #[test] + fn a_failure_keeps_its_cause_in_view_and_the_side_panel_keeps_the_tree_still() { + let mut app = failing_app(); + app.side_panel = true; + app.select_index(3); + let mut terminal = Terminal::new(TestBackend::new(140, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + let failed = lines + .iter() + .find(|line| line.contains("agent-01") && line.contains("Retrying")) + .expect("failed Agent row"); + assert!(failed.contains("Retrying"), "{failed}"); + assert!( + failed.contains("…") && failed.contains("(os error 28)"), + "the cause at the end stays: {failed}" + ); + assert!( + text.contains("agent-01 · status"), + "the panel shows the selected Agent:\n{text}" + ); + assert!(text.contains("Failure: Transient"), "{text}"); + let panel = lines + .iter() + .map(|line| line.chars().skip(text_column(lines[1], "AGE") + 5).collect::()) + .collect::(); + assert!( + panel.contains("(os error 28)"), + "the panel wraps the whole message:\n{text}" + ); + + let state = text_column(lines[1], "STATE"); + app.select_index(2); + app.transcript_request().expect("the selected Session's turns"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert_eq!( + text_column(text.lines().nth(1).expect("column header"), "STATE"), + state, + "selecting a Session leaves the columns where they were" + ); + assert!(text.contains("review · recent turns"), "{text}"); + } + + #[test] + fn every_view_draws_at_common_widths() { + fn press(app: &mut App, row: usize, key: char) { + app.select_index(row); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(key), + crossterm::event::KeyModifiers::NONE, + )); + } + type Open = fn(&mut App); + let views: [(&str, Open); 8] = [ + ("tree", |_| {}), + ("filter", |app| app.modal = Some(Modal::Filter)), + ("new session", |app| press(app, 0, 'n')), + ("forward", |app| press(app, 0, 'f')), + ("delete", |app| press(app, 0, 'd')), + ("describe", |app| press(app, 3, 's')), + ("prompt", |app| press(app, 2, 'p')), + ("help", |app| press(app, 0, '?')), + ]; + for width in [60, 80, 110, 160] { + for (name, open) in views { + let mut app = failing_app(); + app.side_panel = shows_side_panel(width); + open(&mut app); + let mut terminal = Terminal::new(TestBackend::new(width, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + assert!( + lines[0].contains("agentctl"), + "{name} at {width} keeps the header:\n{text}" + ); + if name == "describe" { + assert!(!text.contains("q quit"), "a detail offers only its own keys:\n{text}"); + } + assert!( + lines.iter().rev().take(2).any(|line| !line.trim().is_empty()) || text.contains('┌'), + "{name} at {width} shows its hints:\n{text}" + ); + } + } + } + + #[test] + fn help_lists_every_key_by_where_it_applies_and_the_footer_offers_it() { + let mut app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!( + text.lines().last().is_some_and(|line| line.starts_with("? help")), + "the footer offers help first:\n{text}" + ); + + app.modal = Some(Modal::Help); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + for expected in [ + "keys", + "Fleet", + "Selected Agent", + "Selected Session", + "follow provisioning", + "esc close", + ] { + assert!(text.contains(expected), "{expected:?} in:\n{text}"); + } + } + + #[test] + fn a_detail_scrolls_to_the_end_of_its_wrapped_lines() { + let mut app = triage_app(); + let lines = (1..=5) + .map(|line| format!("{}END{line}", "x".repeat(117))) + .collect::>(); + app.detail = Some(super::super::app::Detail::text("long".into(), lines)); + let mut terminal = Terminal::new(TestBackend::new(40, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let press = |app: &mut App, code| { + app.on_key(crossterm::event::KeyEvent::new( + code, + crossterm::event::KeyModifiers::NONE, + )); + }; + for _ in 0..30 { + press(&mut app, crossterm::event::KeyCode::Char('j')); + } + draw(&mut terminal, &app); + assert!( + buffer_text(&terminal).contains("END5"), + "the last wrapped row comes into view:\n{}", + buffer_text(&terminal) + ); + let bottom = app.detail.as_ref().map(|detail| detail.scroll); + press(&mut app, crossterm::event::KeyCode::Char('k')); + assert_eq!( + app.detail.as_ref().map(|detail| detail.scroll), + bottom.map(|scroll| scroll - 1), + "one step up moves at once" + ); + } + + #[test] + fn a_narrow_tree_keeps_name_state_and_time_in_state() { + let app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(50, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Needs you")); + assert!(!text.contains("DETAIL")); + assert!(!text.contains("Claude Code")); + } + + #[test] + fn no_color_draws_without_color_but_keeps_glyphs_and_the_selection() { + let mut app = triage_app(); + app.select_index(1); + let mut state = ViewState { + no_color: true, + ..ViewState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(100, 10)).expect("test terminal"); + draw_with_state(&mut terminal, &app, &mut state); + let buffer = terminal.backend().buffer(); + assert!( + buffer + .content() + .iter() + .all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset) + ); + assert!( + buffer[(6, 3)].modifier.contains(Modifier::REVERSED), + "the selected row stays marked" + ); + assert!(buffer_text(&terminal).contains("! review")); + } + + #[test] + fn a_selected_session_shows_its_latest_turns_beside_the_tree() { + let mut app = triage_app(); + app.side_panel = true; + app.select_index(2); + let (agent, session) = app.transcript_request().expect("turns are requested"); + let turns = serde_json::from_value(serde_json::json!([ + {"messages": [{"role": "user", "parts": [{"kind": "text", "text": "first question"}]}]}, + {"messages": [ + {"role": "user", "parts": [{"kind": "text", "text": "update the snapshot?"}]}, + {"role": "assistant", "parts": [ + {"kind": "toolCall", "name": "Bash"}, + {"kind": "text", "text": "The snapshot changed as expected. Shall I accept it and rerun the suite?"} + ]} + ]} + ])) + .expect("test turns"); + app.transcript_loaded(&agent, &session, Ok(turns)); + let mut terminal = Terminal::new(TestBackend::new(120, 9)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("review · recent turns")); + assert!(text.contains("[assistant] -> Bash")); + assert!( + text.contains("rerun the suite?"), + "the newest line wraps into view:\n{text}" + ); + assert!(!text.contains("first question"), "older lines give way to newer ones"); + + terminal.backend_mut().resize(100, 9); + draw(&mut terminal, &app); + assert!( + !buffer_text(&terminal).contains("recent turns"), + "a narrow terminal keeps the tree only" + ); + } + + #[test] + fn a_prompt_is_typed_in_the_footer_below_the_turns() { + let mut app = triage_app(); + app.select_index(2); + for code in [ + crossterm::event::KeyCode::Char('p'), + crossterm::event::KeyCode::Char('y'), + crossterm::event::KeyCode::Char('e'), + crossterm::event::KeyCode::Char('s'), + ] { + app.on_key(crossterm::event::KeyEvent::new( + code, + crossterm::event::KeyModifiers::NONE, + )); + } + let mut terminal = Terminal::new(TestBackend::new(120, 9)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let footer = text.lines().rev().take(2).collect::>(); + assert!(footer[0].starts_with("review › yes▏"), "{footer:?}"); + assert!(footer[1].starts_with("enter send · esc cancel"), "{footer:?}"); + } + + #[test] + fn a_lost_connection_keeps_the_last_reported_tree_visible() { + let mut app = tree_app(2); + app.connection_error = Some("connection refused".into()); + let mut terminal = Terminal::new(TestBackend::new(80, 8)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("reconnecting: connection refused")); + assert!(text.contains("agent-01")); + } + + #[test] + fn tree_hit_map_uses_the_rendered_offset_and_updates_after_resize() { + let mut app = tree_app(10); + app.select_index(9); + let mut state = ViewState::default(); + // Three footer lines, as the selection's hints wrap at this width. + let mut terminal = Terminal::new(TestBackend::new(40, 9)).expect("test terminal"); + let agent = |name: &str| Some(HitTarget::Row(RowTarget::Tree(TreeRowId::Agent(name.into())))); + + let compact = draw_with_state(&mut terminal, &app, &mut state); + assert_eq!(state.tree_offset, 6); + assert_eq!(compact.click_at(10, 1), None, "the column header is not a row"); + assert_eq!(compact.click_at(10, 2), agent("agent-06")); + assert_eq!(compact.click_at(10, 5), agent("agent-09")); + assert_eq!(compact.click_at(10, 6), None, "footer is not a list row"); + assert_eq!(compact.click_at(40, 2), None, "right edge is out of bounds"); + + terminal.backend_mut().resize(40, 13); + let resized = draw_with_state(&mut terminal, &app, &mut state); + assert_eq!(state.tree_offset, 2, "a taller terminal shows the rows above"); + assert_eq!(resized.click_at(10, 2), agent("agent-02")); + assert_eq!(resized.click_at(10, 9), agent("agent-09")); + assert_eq!( + resized.click_at(10, 10), + None, + "the resized map has no stale row target" + ); + } + + #[test] + fn scrolling_into_an_agents_sessions_pins_the_agent_above_them() { + let mut app = tree_app(1); + let agents = std::mem::take(&mut app.agents); + app.apply_snapshot( + agents, + (0..8) + .map(|index| session("agent-00", &format!("s{index}"), "idle")) + .collect(), + ); + app.select_index(8); + let mut state = ViewState::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 9)).expect("test terminal"); + + let hit_map = draw_with_state(&mut terminal, &app, &mut state); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + assert!(lines[2].contains("▾ agent-00"), "{}", lines[2]); + assert!(lines[3].contains("s5") && lines[5].contains("s7"), "{text}"); + assert_eq!( + hit_map.click_at(10, 2), + Some(HitTarget::Row(RowTarget::Tree(TreeRowId::Agent("agent-00".into())))) + ); + assert_eq!( + terminal.backend().buffer()[(10, 5)].bg, + SELECTION, + "the selected Session stays in view below the pinned Agent" + ); + } + + #[test] + fn the_filter_is_typed_in_the_footer_and_named_in_the_header() { + let mut app = triage_app(); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('/'), + crossterm::event::KeyModifiers::NONE, + )); + for character in "rev".chars() { + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )); + } + let mut terminal = Terminal::new(TestBackend::new(100, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("filter: rev")); + assert!(text.contains("review") && !text.contains("main")); + assert!(text.lines().last().is_some_and(|line| line.starts_with("/rev▏"))); + + app.filter = "nothing".into(); + app.rebuild(); + draw(&mut terminal, &app); + assert!(buffer_text(&terminal).contains("(nothing matches the filter)")); + } + + #[test] + fn forward_hit_map_excludes_its_border_and_tracks_scrolling() { + let mut app = App::new(); + app.view = View::Forwards; + app.forwards = (0..10) + .map(|id| super::super::app::ForwardEntry { + id, + agent: format!("agent-{id}"), + local: format!("127.0.0.1:{}", 8000 + id), + guest_port: 80, + status: None, + finished: false, + }) + .collect(); + app.forward_selected = 7; + let mut state = ViewState::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 8)).expect("test terminal"); + + let hit_map = draw_with_state(&mut terminal, &app, &mut state); + + assert_eq!(state.forwards.offset(), 5); + assert_eq!(hit_map.click_at(1, 2), Some(HitTarget::Row(RowTarget::Forward(5)))); + assert_eq!(hit_map.wheel_at(1, 2), Some(WheelTarget::Forwards)); + assert_eq!(hit_map.click_at(0, 2), None, "left border is inert"); + assert_eq!(hit_map.click_at(1, 1), None, "top border is inert"); + } + + #[test] + fn error_body_hints_are_clickable_where_they_are_rendered() { + let mut app = App::new(); + app.error = Some("request failed because".into()); + let mut terminal = Terminal::new(TestBackend::new(20, 8)).expect("test terminal"); + + let hit_map = draw(&mut terminal, &app); + let dismiss = HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + )); + let quit = HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Char('q'), + crossterm::event::KeyModifiers::NONE, + )); + + assert_eq!(hit_map.click_at(0, 4), Some(dismiss)); + assert_eq!(hit_map.click_at(14, 4), Some(quit)); + assert_eq!(hit_map.click_at(12, 4), None, "separator is inert"); + } + + #[test] + fn modal_hit_map_blocks_the_underlying_list_and_exposes_confirmation() { + let mut app = tree_app(3); + app.modal = Some(Modal::ConfirmDelete { + agent: "agent-00".into(), + sessions: 0, + }); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + + let hit_map = draw(&mut terminal, &app); + + assert_eq!(hit_map.click_at(0, 1), None, "modal prevents click-through"); + let confirmation = HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Char('y'), + crossterm::event::KeyModifiers::NONE, + )); + let area = hit_map + .clicks + .iter() + .find_map(|(area, target)| (target == &confirmation).then_some(*area)) + .expect("confirmation control"); + assert_eq!(hit_map.click_at(area.x, area.y), Some(confirmation)); + } + + #[test] + fn the_stop_confirmation_says_what_a_stop_keeps_in_full() { + let mut app = tree_app(1); + app.modal = Some(Modal::ConfirmStop { + agent: "agent-00".into(), + }); + let mut terminal = Terminal::new(TestBackend::new(80, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Stop agent agent-00?"), "{text}"); + assert!(text.contains("Running harnesses stop with its VM."), "{text}"); + assert!( + text.contains("Its disk is kept; attaching after a start resumes a Session."), + "{text}" + ); + } + + #[test] + fn modal_hit_maps_expose_form_fields_and_choices() { + let mut app = tree_app(1); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('n'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + + let session = draw(&mut terminal, &app); + assert!( + session.clicks.iter().any(|(_, target)| { + target == &HitTarget::Action(MouseAction::FocusSessionField(SessionField::Model)) + }) + ); + assert!( + session + .clicks + .iter() + .any(|(_, target)| { target == &HitTarget::Action(MouseAction::SelectHarness(0)) }) + ); + + app.modal = Some(Modal::PortForward(super::super::app::ForwardForm { + agent: "agent-00".into(), + address: "127.0.0.1".into(), + local: String::new(), + guest: "8080".into(), + field: ForwardField::GuestPort, + error: None, + replace: None, + })); + let forward = draw(&mut terminal, &app); + assert!(forward.clicks.iter().any(|(_, target)| { + target == &HitTarget::Action(MouseAction::FocusForwardField(ForwardField::Address)) + })); + assert!(forward.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )) + })); + assert!(forward.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + )) + })); + let text = buffer_text(&terminal); + let (row, line) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains("esc cancel")) + .expect("form hints"); + let column = u16::try_from(text_column(line, "esc cancel")).expect("column"); + assert_eq!( + forward.click_at(column, u16::try_from(row).expect("row")), + Some(HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + ))), + "a form hint is clicked where it is drawn:\n{text}" + ); + } + + fn modal_border(terminal: &Terminal) -> Vec<(usize, usize)> { + buffer_text(terminal) + .lines() + .enumerate() + .filter_map(|(row, line)| line.find('┌').or_else(|| line.find('└')).map(|column| (row, column))) + .collect() + } + + #[test] + fn forms_keep_their_size_when_an_error_appears_and_leave_the_footer_empty() { + let mut app = tree_app(1); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('n'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let valid = modal_border(&terminal); + let footer = buffer_text(&terminal).lines().rev().take(2).collect::(); + assert!(footer.trim().is_empty(), "form hints are not repeated in the footer"); + + if let Some(Modal::NewSession(form)) = &mut app.modal { + form.name = "a-much-longer-session-name-than-before".into(); + form.error = Some("session name is invalid".into()); + } + draw(&mut terminal, &app); + assert!(buffer_text(&terminal).contains("session name is invalid")); + assert_eq!(modal_border(&terminal), valid); + } + + #[test] + fn create_agent_modal_shows_the_picker_and_placeholder_name() { + use super::super::app::{CreateField, CreateForm, ManifestCandidate}; + + let mut app = App::new(); + app.modal = Some(Modal::CreateAgent(CreateForm::new( + vec![ + ManifestCandidate::new(std::path::PathBuf::from("/sources/full/agent.yaml"), Ok("full".into())), + ManifestCandidate::new( + std::path::PathBuf::from("/sources/full/agent.nested.yaml"), + Ok("full-nested".into()), + ), + ManifestCandidate::new( + std::path::PathBuf::from("/sources/broken/agent.yaml"), + Err("manifest cannot be decoded".into()), + ), + ], + None, + ))); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + let hit_map = draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("create agent")); + assert!(text.contains("Agent ◂ full")); + assert!(text.contains("Variant ◂ default")); + assert!(text.contains("Name full")); + assert!(text.contains("Env file default: .env beside manifest")); + assert!(text.contains("enter create · tab/↑/↓ field · ←/→ select · esc cancel")); + let initial_geometry = create_modal_geometry(&text); + let agent_line = text.lines().find(|line| line.contains("│ Agent")).expect("Agent row"); + let variant_line = text + .lines() + .find(|line| line.contains("│ Variant")) + .expect("Variant row"); + let name_line = text.lines().find(|line| line.contains("│ Name")).expect("Name row"); + let env_line = text + .lines() + .find(|line| line.contains("│ Env file")) + .expect("environment row"); + assert_eq!(text_column(agent_line, "◂"), text_column(variant_line, "◂")); + assert_eq!( + text_column(agent_line, "/sources/full"), + text_column(variant_line, "agent.yaml") + ); + let value_column = text_column(agent_line, "full"); + assert_eq!(value_column, text_column(variant_line, "default")); + assert_eq!(value_column, text_column(name_line, "full")); + assert_eq!(value_column, text_column(env_line, "default")); + assert!( + hit_map + .clicks + .iter() + .any(|(_, target)| { target == &HitTarget::Action(MouseAction::FocusCreateField(CreateField::Name)) }) + ); + assert!(hit_map.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::SelectCreate { + field: CreateField::Agent, + delta: 1, + }) + })); + assert!(hit_map.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )) + })); + + let Some(Modal::CreateAgent(form)) = &mut app.modal else { + panic!("expected the CreateAgent modal"); + }; + form.agent = 1; + form.variant = 0; + form.field = CreateField::Name; + form.name = "copy".into(); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Agent ◂ broken")); + assert!(text.contains("Variant ◂ default")); + assert!(text.contains("manifest cannot be decoded")); + assert!(text.contains("Name copy▏")); + assert!(text.contains("Env file default: .env beside manifest")); + assert_eq!(create_modal_geometry(&text), initial_geometry); + } + + #[test] + fn create_agent_modal_preserves_the_end_of_long_source_paths() { + use super::super::app::{CreateForm, ManifestCandidate}; + + let prefix = "/a/source/directory/whose/leading/components/do/not/fit/inside/the/create/agent/modal"; + let mut app = App::new(); + app.modal = Some(Modal::CreateAgent(CreateForm::new( + vec![ManifestCandidate::new( + std::path::PathBuf::from(prefix).join("agents/full/agent.yaml"), + Ok("full".into()), + )], + None, + ))); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let agent_line = text.lines().find(|line| line.contains("│ Agent")).expect("Agent row"); + let agent_line = agent_line.replace('\\', "/"); + assert!(agent_line.contains('…')); + assert!(agent_line.contains("/fit/inside/the/create/agent/modal/agents/full")); + assert!(!agent_line.contains("/a/source/directory")); + assert!( + agent_line.trim_end().ends_with('│'), + "path remains inside the modal: {agent_line}" + ); + } + + #[test] + fn a_text_input_puts_the_cursor_over_its_placeholder_or_after_its_value() { + let spans = text_input("", true, "full"); + assert_eq!(spans[1].content, "f"); + assert!(spans[1].style.add_modifier.contains(Modifier::REVERSED)); + assert_eq!(spans[2].content, "ull"); + + let spans = text_input("my", true, "full"); + assert_eq!(spans[1].content, "my"); + assert_eq!(spans[2].content, "▏"); + assert_eq!(text_input("", false, "").len(), 1, "only the indent"); + } + + #[test] + fn header_reports_a_running_manifest_scan() { + let mut app = App::new(); + app.discovering = true; + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + draw(&mut terminal, &app); + assert!(buffer_text(&terminal).contains("scanning manifests…")); + } + + #[test] + fn create_agent_modal_explains_an_empty_picker() { + use super::super::app::CreateForm; + + let mut app = App::new(); + app.modal = Some(Modal::CreateAgent(CreateForm::new(Vec::new(), None))); + let mut terminal = Terminal::new(TestBackend::new(80, 14)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("No agent manifests found.")); + assert!(text.contains("agentctl apply")); + } + + #[test] + fn home_abbreviation_replaces_only_the_whole_home_component() { + assert_eq!(abbreviate("/Users/dev/code", Some("/Users/dev")), "~/code"); + assert_eq!(abbreviate("/Users/dev", Some("/Users/dev")), "~"); + assert_eq!( + abbreviate("/Users/devops/code", Some("/Users/dev")), + "/Users/devops/code" + ); + assert_eq!(abbreviate("/srv/code", None), "/srv/code"); + assert_eq!(abbreviate("/srv/code", Some("")), "/srv/code"); + } + + fn ssh_menu_app(setup: super::super::open::SshSetup) -> App { + let mut app = tree_app(1); + let mut agents = std::mem::take(&mut app.agents); + agents[0].spec.access = vec![agent::AccessSpec::Ssh {}]; + app.apply_snapshot(agents, Vec::new()); + app.ssh_setup = setup; + app.ssh_include = Some(agent::ssh::UserInclude { + user_config: "/tmp/user/.ssh/config".into(), + line: "Include ~/.agent/ssh/config".into(), + }); + app.selection = Some(TreeRowId::Agent("agent-00".into())); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('o'), + crossterm::event::KeyModifiers::NONE, + )); + app + } + + #[test] + fn the_open_menu_lists_keys_reasons_and_the_setup_note() { + let mut app = ssh_menu_app(super::super::open::SshSetup::Missing); + let mut terminal = Terminal::new(TestBackend::new(80, 30)).expect("test terminal"); + let hit_map = draw(&mut terminal, &app); + let text = buffer_text(&terminal); + + assert!(text.contains(" open agent-00 "), "{text}"); + let shell = text + .lines() + .find(|line| line.contains("Shell in the Sandbox")) + .expect("shell row"); + assert!( + shell.contains("▸") && shell.trim_end().trim_end_matches('│').trim_end().ends_with('e'), + "{shell}" + ); + let zed = crate::launch::Editor::Zed + .missing_launcher() + .expect("Zed needs a launcher"); + assert!(text.contains(&format!("Zed: {zed}")), "{text}"); + assert!(text.contains("Set up SSH"), "{text}"); + assert!(text.contains("SSH needs a line in ~/.ssh/config;"), "{text}"); + assert!( + text.lines().skip(1).all(|line| !line.contains("agentctl ")), + "below the title, the menu offers keys, not commands:\n{text}" + ); + + let (row, _) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains("SSH shell")) + .expect("SSH shell row"); + let column = u16::try_from(text.lines().nth(row).expect("row").find("SSH").expect("label")).expect("column"); + let target = hit_map.click_at(column, u16::try_from(row).expect("row")); + assert!( + matches!(target, Some(HitTarget::Action(MouseAction::ChooseOpen(_)))), + "{target:?}" + ); + let Some(HitTarget::Action(action)) = target else { + unreachable!() + }; + assert_eq!( + app.on_mouse(action), + super::super::app::Action::Open { + agent: "agent-00".into(), + target: OpenTarget::SshShell, + }, + "the click opens the row it hit" + ); + } + + #[test] + fn the_open_menu_and_quit_question_fit_an_80x24_terminal() { + let mut app = ssh_menu_app(super::super::open::SshSetup::Missing); + let mut agents = std::mem::take(&mut app.agents); + agents[0].spec.access = vec![agent::AccessSpec::Ssh {}, agent::AccessSpec::Vnc {}]; + agents[0].status.conditions.push(agent::Condition { + kind: agent::Condition::VNC_READY.into(), + status: agent::ConditionStatus::False, + reason: "ReconcileFailed".into(), + message: agent::vnc::image_contract_missing("/etc/agent-access.d/vnc.conf is missing"), + last_transition_time: None, + }); + app.apply_snapshot(agents, Vec::new()); + app.modal = None; + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('o'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Desktop in the browser"), "{text}"); + assert!( + text.contains("re-apply the Agent…"), + "a long reason is cut short:\n{text}" + ); + assert!(text.contains("you are asked before it is added."), "{text}"); + assert!(text.contains("esc cancel"), "{text}"); + + app.modal = Some(Modal::ConfirmQuit); + app.forwards = (0..20) + .map(|id| super::super::app::ForwardEntry { + id, + agent: format!("an-agent-with-a-name-longer-than-the-dialog-{id:02}"), + local: format!("127.0.0.1:{}", 50000 + id), + guest_port: 6080, + status: None, + finished: false, + }) + .collect(); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("desktop 50000:6080 an-agent-with"), "{text}"); + assert!(text.contains("…and 14 more"), "{text}"); + assert!(text.contains("y confirm"), "{text}"); + let listed = text + .lines() + .filter(|line| line.contains("an-agent-with")) + .collect::>(); + assert!( + listed.iter().all(|line| line.contains('…')), + "long names are shortened, not cut:\n{text}" + ); + } + + #[test] + fn ssh_setup_shows_the_exact_line_and_file_before_writing() { + let mut app = ssh_menu_app(super::super::open::SshSetup::Missing); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + + assert!(text.contains(" set up SSH "), "{text}"); + assert!( + text.contains("Add this line at the top of /tmp/user/.ssh/config?"), + "{text}" + ); + assert!(text.contains(" Include ~/.agent/ssh/config"), "{text}"); + assert!(text.contains("Then: VS Code, Remote-SSH."), "{text}"); + assert!(text.contains("enter add · o open anyway · esc back"), "{text}"); + } + + #[test] + fn no_help_row_runs_into_the_overlay_border() { + let mut app = triage_app(); + app.modal = Some(Modal::Help); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + for line in text.lines().filter(|line| line.contains("│ ")) { + let inside: Vec = line.chars().collect(); + let border = inside + .iter() + .rposition(|character| *character == '│') + .expect("right border"); + // The form pads one cell inside its border; the cell before that is the last one text uses. + assert_eq!(inside[border - 2], ' ', "cut off at the border:\n{line}"); + } + } +} diff --git a/agentctl/src/bin/agentd.rs b/agentctl/src/bin/agentd.rs new file mode 100644 index 0000000..7bfce03 --- /dev/null +++ b/agentctl/src/bin/agentd.rs @@ -0,0 +1,233 @@ +use std::{io::IsTerminal as _, path::PathBuf, process::ExitCode, rc::Rc, time::Duration}; + +use agent::{ + Error, + control_api::Server, + control_plane::{ControlPlane, Controller, Reconciler}, + local::home::ControlPlaneHome, + persistence, + sandbox::ExecutionService, + sessions::Service as SessionService, +}; +use clap::Parser; +use tokio::runtime::LocalRuntime; + +/// Image materialization runs in this process and allocates heavily for a +/// short time. mimalloc returns unused pages to the operating system after a +/// short delay, so agentd's memory use falls again once an image is ready. +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +#[derive(Parser)] +#[command(name = "agentd", about = "Run the per-user Agent control plane", version = agent::build_version())] +struct Arguments { + /// Agent control-plane home. + #[arg(long)] + home: Option, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("agentd: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Error> { + let arguments = Arguments::parse(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(agent::local::process::daemon_log_filter())), + ) + .with_writer(std::io::stderr) + .with_ansi(std::io::stderr().is_terminal()) + .init(); + let home = ControlPlaneHome::resolve(arguments.home.as_deref())?; + let _lock = acquire_home_lock(&home)?; + let database = persistence::Database::open(&home.path().join("agent.db"))?; + let runtime = LocalRuntime::new()?; + runtime.block_on(run_control_plane(home, database)) +} + +fn acquire_home_lock(home: &ControlPlaneHome) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + match home.acquire_lock() { + Ok(lock) => return Ok(lock), + Err(Error::Io(error)) + if error.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(50)); + } + Err(error) => return Err(error), + } + } +} + +type ErrorHandler = Rc, &Error)>; + +/// Wires SSH and VNC access around the `agentctl` installed beside this +/// daemon, which generated SSH client configuration and reported forwarding +/// commands dial Agents through. SSH keeps its host keys in the database; VNC +/// holds no key material and generates no client configuration. +fn access( + home: &ControlPlaneHome, + database: &persistence::Database, + store: Rc, +) -> Result<(Rc, Rc), Error> { + let agentd = std::env::current_exe()?; + let sibling = agentd.with_file_name(format!("agentctl{}", std::env::consts::EXE_SUFFIX)); + let agentctl = agent::ssh::stable_agentctl_path(&sibling, std::env::var_os("PATH").as_deref()); + let host_keys: Rc = Rc::new(database.clone()); + let ssh = agent::ssh::Access::new(home, agentctl.clone(), host_keys, store.clone()); + Ok((Rc::new(ssh), Rc::new(agent::vnc::Access::new(agentctl, store)))) +} + +/// Logs recoverable reconciliation errors for one durable resource kind. +fn reconciliation_errors(resource: &'static str) -> ErrorHandler { + Rc::new(move |id, error| { + if let Some(id) = id { + tracing::warn!(resource, %id, %error, "reconciliation failed"); + } else { + tracing::error!(resource, %error, "reconciliation scan failed"); + } + }) +} + +async fn open_sandboxes( + home: &ControlPlaneHome, + database: &persistence::Database, + credentials: Rc, + policy: Rc, + platform_api_port: u16, +) -> Result<(Rc, String), Error> { + let microsandbox = Rc::new( + agent::sandbox::microsandbox::Adapter::open( + home.path(), + database.clone(), + credentials, + policy, + platform_api_port, + ) + .await?, + ); + let session_hook_url = microsandbox.platform_url("/v1/session/hooks")?; + let provider: Rc = microsandbox; + let platform: Rc = Rc::new(agent::sandbox::platform::Linux); + Ok(( + Rc::new(agent::sandbox::Service::new([provider], [platform])?), + session_hook_url, + )) +} + +#[allow( + clippy::too_many_lines, + reason = "wires every daemon subsystem explicitly, as the Control API server's dependencies are" +)] +async fn run_control_plane(home: ControlPlaneHome, database: persistence::Database) -> Result<(), Error> { + let store = Rc::new(database.clone()); + let credentials = Rc::new(agent::harness::AuthenticationManager::new(database.clone())); + let policy = Rc::new(agent::authorization::AgentPolicyEngine::new()); + let platform_api_listener = agent::platform_api::bind_persistent(&home.path().join("platform-api-port")).await?; + let platform_api_port = platform_api_listener.local_addr()?.port(); + let (sandboxes, session_hook_url) = + open_sandboxes(&home, &database, credentials.clone(), policy, platform_api_port).await?; + let session_reports: Rc = store.clone(); + let session_store: Rc = store.clone(); + let session_runtime: Rc = Rc::new(agent::sessions::Tmux); + let agent_sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(store.clone(), sandboxes.clone())); + let changes = database.changes(); + let provisioning = agent::progress::ProvisioningState::new(changes.clone()); + + let platform_api_server = Rc::new(agent::platform_api::Server::new( + session_reports, + Rc::new(|error| tracing::error!(%error, "Platform API connection failed")), + )); + let session_reconciler: Rc> = + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + agent_sandboxes.clone(), + session_runtime.clone(), + session_hook_url, + )); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + session_reconciler, + Duration::from_secs(30), + reconciliation_errors("Session"), + ); + let session_notifier = Rc::new(agent::sessions::AgentNotifier::new( + session_store.clone(), + session_wakeup.clone(), + Rc::new(|error| tracing::error!(%error, "Session notification scan failed")), + )); + let (ssh, vnc) = access(&home, &database, store.clone())?; + let reconciler = Rc::new( + Reconciler::new(store.clone(), sandboxes.clone(), provisioning.clone()) + .with_session_notifier(session_notifier) + .with_ssh_access(ssh.clone()) + .with_vnc_access(vnc.clone()), + ); + let (controller, wakeup) = Controller::new( + store.clone(), + reconciler, + Duration::from_secs(30), + reconciliation_errors("Agent"), + ); + let control_plane = + Rc::new(ControlPlane::new(store.clone(), Rc::new(wakeup.clone())).with_provisioning(provisioning)); + let convergence = agent::control_plane::Convergence::new(wakeup, store.clone(), changes.clone()); + let executions = Rc::new(ExecutionService::new(store.clone(), convergence.clone())); + let sessions = Rc::new(SessionService::new( + session_store, + agent_sandboxes, + session_runtime, + convergence.clone(), + session_wakeup, + )); + agent::upgrade::consume_pending_session_relaunch(&home, &sessions).await?; + let server = Rc::new(Server::new( + control_plane, + credentials.clone(), + Rc::new(convergence.clone()), + executions, + sessions, + ssh, + vnc, + changes, + Rc::new(|error| tracing::error!(%error, "Control API connection failed")), + )); + let mut controller_task = tokio::task::spawn_local(controller.run()); + let mut session_controller_task = tokio::task::spawn_local(session_controller.run()); + let mut platform_api_task = tokio::task::spawn_local(platform_api_server.serve(platform_api_listener)); + let socket_path = home.socket_path(); + + let result = tokio::select! { + result = server.serve_path(&socket_path) => result, + result = tokio::signal::ctrl_c() => result.map_err(Error::from), + result = &mut controller_task => match result { + Ok(()) => Err(Error::Daemon("reconciliation controller stopped".into())), + Err(error) => Err(Error::Daemon(format!("reconciliation controller task failed: {error}"))), + }, + result = &mut session_controller_task => match result { + Ok(()) => Err(Error::Daemon("Session reconciliation controller stopped".into())), + Err(error) => Err(Error::Daemon(format!("Session reconciliation controller task failed: {error}"))), + }, + result = &mut platform_api_task => match result { + Ok(Ok(())) => Err(Error::Daemon("Platform API stopped".into())), + Ok(Err(error)) => Err(Error::Daemon(format!("Platform API failed: {error}"))), + Err(error) => Err(Error::Daemon(format!("Platform API task failed: {error}"))), + }, + }; + controller_task.abort(); + session_controller_task.abort(); + platform_api_task.abort(); + let _ = controller_task.await; + let _ = session_controller_task.await; + let _ = platform_api_task.await; + result +} diff --git a/agentctl/src/control_api/client.rs b/agentctl/src/control_api/client.rs new file mode 100644 index 0000000..c39b94a --- /dev/null +++ b/agentctl/src/control_api/client.rs @@ -0,0 +1,486 @@ +use std::{cell::Cell, rc::Rc}; + +use sandbox::LocalFuture; +use serde::{Serialize, de::DeserializeOwned}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +use crate::{Agent, Error, control_plane, control_plane::WaitPolicy, harness, sessions}; + +use super::protocol::{ + DaemonInfo, DirectoryParams, ExecutionEnsureParams, JSON_RPC_VERSION, LoginParams, METHOD_APPLY, METHOD_AUTH_LOGIN, + METHOD_CONVERGE, METHOD_DELETE, METHOD_EXECUTION_ENSURE, METHOD_GET, METHOD_HEALTH, METHOD_LIST, METHOD_PROGRESS, + METHOD_RESOLVE_DIRECTORY, METHOD_RESOURCES_WATCH, METHOD_SESSION_ARCHIVE, METHOD_SESSION_DELETE, + METHOD_SESSION_ENSURE, METHOD_SESSION_GET, METHOD_SESSION_LIST, METHOD_SESSION_PROMPT, METHOD_SESSION_TURNS, + METHOD_SESSION_UNARCHIVE, METHOD_SHUTDOWN, METHOD_SSH_ACCESS, METHOD_START, METHOD_STOP, METHOD_VNC_ACCESS, + NameParams, ProgressParams, ReadMessage, Request, ResourcesWatchParams, Response, SessionEnsureParams, + SessionListParams, SessionParams, SessionPromptParams, SessionTurnsParams, ShutdownParams, ShutdownResult, + read_message, +}; + +/// A byte stream usable by the Agent Control API client. +pub trait Connection: AsyncRead + AsyncWrite + Unpin {} + +impl Connection for T {} + +/// Opens one connection for one local API call. +pub trait Connector { + /// Connects to the local control plane. + fn connect(&self) -> LocalFuture<'_, Result, Error>>; +} + +/// Calls an Agent control plane over a local stream transport. +pub struct Client { + connector: Rc, + next_id: Cell, +} + +impl Client { + /// Creates a client with a replaceable local connector. + #[must_use] + pub fn new(connector: Rc) -> Self { + Self { + connector, + next_id: Cell::new(0), + } + } + + /// Creates a client for the platform local socket at `path`. + #[must_use] + pub fn for_path(path: std::path::PathBuf) -> Self { + Self::new(Rc::new(super::socket::PathConnector::new(path))) + } + + /// Checks whether the local daemon speaks the expected Control API. + /// + /// # Errors + /// + /// Returns an error when the daemon is unavailable or protocol-incompatible. + pub async fn health(&self) -> Result { + self.call(METHOD_HEALTH, serde_json::json!({})).await + } + + /// Requires a daemon built with this client's application protocol and version. + /// + /// # Errors + /// + /// Returns an error with both identities when a daemon is reachable but incompatible. + pub async fn require_compatible_daemon(&self) -> Result { + let daemon = self.health().await?; + daemon.require_compatible()?; + Ok(daemon) + } + + /// Requests a graceful daemon shutdown for an upgrade. + /// + /// # Errors + /// + /// Returns an error when active Sessions block the transition or the daemon + /// cannot drain its listeners and in-flight calls. + pub async fn shutdown_for_upgrade(&self) -> Result, Error> { + let result: ShutdownResult = self + .call( + METHOD_SHUTDOWN, + ShutdownParams { + reason: "upgrade".into(), + }, + ) + .await?; + Ok(result.warnings) + } + + /// Creates or updates an Agent resource. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn apply(&self, request: control_plane::ApplyRequest) -> Result { + self.call(METHOD_APPLY, request).await + } + + /// Gets an Agent resource by name. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn get(&self, name: &str) -> Result { + self.call(METHOD_GET, NameParams { name: name.into() }).await + } + + /// Lists every active Agent. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or storage fails. + pub async fn list_agents(&self) -> Result, Error> { + self.call(METHOD_LIST, serde_json::json!({})).await + } + + /// Resolves the closest persisted Agent source directory containing `directory`. + /// + /// # Errors + /// + /// Returns an error when no unique Agent matches or the API call fails. + pub async fn resolve_agent(&self, directory: std::path::PathBuf) -> Result { + self.resolve_agent_variant(directory, None).await + } + + /// Resolves the closest persisted Agent by directory and optional leaf variant. + /// + /// # Errors + /// + /// Returns an error when no unique Agent matches or the API call fails. + pub async fn resolve_agent_variant( + &self, + directory: std::path::PathBuf, + variant: Option, + ) -> Result { + self.call(METHOD_RESOLVE_DIRECTORY, DirectoryParams { directory, variant }) + .await + } + + /// Converges an Agent and resolves its exact transient Execution target. + /// + /// `wait` decides whether the call returns after one reconciliation pass or + /// waits through background retries until Ready. Follow progress alongside + /// with [`Self::agent_progress`]. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing, deleting, invalid, or fails to + /// reach a ready materialized Sandbox. + pub async fn ensure_execution( + &self, + name: &str, + wait: WaitPolicy, + ) -> Result { + self.call( + METHOD_EXECUTION_ENSURE, + ExecutionEnsureParams { + name: name.into(), + follow: wait == WaitPolicy::UntilConverged, + }, + ) + .await + } + + /// Waits for a change after `after`, then returns the Agent's stored status + /// and the progress of its latest pass. Without a revision, or with one + /// from an earlier daemon process, it returns at once; with a current one + /// it may return the unchanged state after a keepalive interval. When + /// `output` names the latest pass, only output after it is included. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing or the call fails. + pub async fn agent_progress( + &self, + name: &str, + after: Option, + output: Option, + ) -> Result { + self.call( + METHOD_PROGRESS, + ProgressParams { + name: name.into(), + after, + output, + }, + ) + .await + } + + /// Waits for an Agent or Session to change after `after`, then returns + /// every Agent and Session. Without a revision, or with one from an earlier + /// daemon process, it returns the current state at once; with a current + /// revision it may return the unchanged state after a keepalive interval. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or daemon reads fail. + pub async fn watch_resources( + &self, + after: Option, + ) -> Result { + self.call(METHOD_RESOURCES_WATCH, ResourcesWatchParams { after }).await + } + + /// Describes how to reach an Agent over SSH. + /// + /// # Errors + /// + /// Returns an error when the Agent is unknown, deleting, or declares no SSH access. + pub async fn ssh_access(&self, name: &str) -> Result { + self.call(METHOD_SSH_ACCESS, NameParams { name: name.into() }).await + } + + /// Describes how to reach an Agent's desktop over VNC. + /// + /// # Errors + /// + /// Returns an error when the Agent is unknown, deleting, or declares no VNC access. + pub async fn vnc_access(&self, name: &str) -> Result { + self.call(METHOD_VNC_ACCESS, NameParams { name: name.into() }).await + } + + /// Requests deletion of an Agent and its owned sandbox. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn delete(&self, name: &str) -> Result<(), Error> { + let _result: serde_json::Value = self.call(METHOD_DELETE, NameParams { name: name.into() }).await?; + Ok(()) + } + + /// Waits until an Agent has its desired run state, Ready or stopped, and + /// returns it as stored then; transient failures are waited through. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing, deleted, invalid or + /// unresponsive, or the call fails. + pub async fn converge(&self, name: &str) -> Result { + self.call(METHOD_CONVERGE, NameParams { name: name.into() }).await + } + + /// Records whether an Agent's Sandbox runs and returns the Agent as stored; + /// the reconciler stops or starts it. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn set_run_state(&self, name: &str, state: crate::RunState) -> Result { + let method = match state { + crate::RunState::Running => METHOD_START, + crate::RunState::Stopped => METHOD_STOP, + }; + self.call(method, NameParams { name: name.into() }).await + } + + /// Stores a host-acquired harness credential in the daemon. + /// + /// # Errors + /// + /// Returns an error when the credential is invalid, rejected, or cannot be persisted. + pub async fn auth_login( + &self, + harness: harness::Harness, + credential: String, + imported: bool, + ) -> Result { + self.call( + METHOD_AUTH_LOGIN, + LoginParams { + harness, + credential, + imported, + }, + ) + .await + } + + /// Creates or resolves one named session attach target. + /// + /// `request` selects the harness, model, effort and first prompt of a + /// Session this call creates; see [`sessions::Service::ensure`] for the + /// precedence against manifest defaults. `wait` decides whether the call + /// returns after one Agent reconciliation pass or waits through background + /// retries until Ready. Follow progress alongside with [`Self::agent_progress`]. + /// + /// # Errors + /// + /// Returns an error when the Agent is not ready, a selection conflicts with + /// an existing Session, or the registry cannot persist the session. + pub async fn ensure_session( + &self, + agent: &str, + name: sessions::SessionName, + request: sessions::SessionRequest, + wait: WaitPolicy, + ) -> Result { + self.call( + METHOD_SESSION_ENSURE, + SessionEnsureParams { + agent: agent.into(), + name, + harness: request.harness, + model_selection: request.model_selection, + initial_prompt: request.initial_prompt, + follow: wait == WaitPolicy::UntilConverged, + }, + ) + .await + } + + /// Delivers a prompt to a running Session's harness. With `wait`, waits for + /// its completed-turn counter to advance with identical waiting activity in + /// two consecutive polls, 250 ms apart. + /// Work observed during settling requires another completion. + /// The timeout bounds completion waiting after submission, excluding setup and delivery. + /// Conversation output is read separately with [`Self::session_turns`]. + /// + /// # Errors + /// + /// Returns an error when the Session is not running or the input cannot be delivered. + pub async fn prompt_session( + &self, + agent: &str, + name: sessions::SessionName, + prompt: String, + wait: bool, + timeout: Option, + ) -> Result<(), Error> { + let _result: serde_json::Value = self + .call( + METHOD_SESSION_PROMPT, + SessionPromptParams { + agent: agent.into(), + name, + prompt, + wait, + timeout, + }, + ) + .await?; + Ok(()) + } + + /// Reads the harness transcript of a Session as ordered turns. + /// + /// # Errors + /// + /// Returns an error when the Session or its transcript cannot be read. + pub async fn session_turns( + &self, + agent: &str, + name: sessions::SessionName, + last: Option, + ) -> Result, Error> { + self.call( + METHOD_SESSION_TURNS, + SessionTurnsParams { + agent: agent.into(), + name, + last, + }, + ) + .await + } + + /// Gets one named Session scoped to an Agent. + /// + /// # Errors + /// + /// Returns an error when either resource is missing or the registry cannot be read. + pub async fn get_session(&self, agent: &str, name: sessions::SessionName) -> Result { + self.call( + METHOD_SESSION_GET, + SessionParams { + agent: agent.into(), + name, + harness: None, + }, + ) + .await + } + + /// Requests release of one Session: its harness is stopped and the Session + /// is removed, freeing its name. + /// + /// # Errors + /// + /// Returns an error when either resource is missing, or the release pass fails. + pub async fn delete_session(&self, agent: &str, name: sessions::SessionName) -> Result<(), Error> { + let _result: serde_json::Value = self + .call( + METHOD_SESSION_DELETE, + SessionParams { + agent: agent.into(), + name, + harness: None, + }, + ) + .await?; + Ok(()) + } + + /// Archives or unarchives one Session and returns it as recorded. Archiving + /// stops its harness until it is unarchived; the Session keeps its name and + /// conversation. + /// + /// # Errors + /// + /// Returns an error when either resource is missing or the pass fails. + pub async fn set_session_archived( + &self, + agent: &str, + name: sessions::SessionName, + archived: bool, + ) -> Result { + self.call( + if archived { + METHOD_SESSION_ARCHIVE + } else { + METHOD_SESSION_UNARCHIVE + }, + SessionParams { + agent: agent.into(), + name, + harness: None, + }, + ) + .await + } + + /// Lists tracked Sessions, optionally scoped to one Agent. + /// + /// # Errors + /// + /// Returns an error when the scoped Agent is missing or the registry cannot be read. + pub async fn list_sessions(&self, agent: Option<&str>) -> Result, Error> { + self.call( + METHOD_SESSION_LIST, + SessionListParams { + agent: agent.map(str::to_owned), + }, + ) + .await + } + + async fn call(&self, method: &str, params: P) -> Result { + let id = self.next_id.get().wrapping_add(1); + self.next_id.set(id); + let request = Request { + jsonrpc: JSON_RPC_VERSION.into(), + method: method.into(), + params: serde_json::to_value(params)?, + id, + }; + let mut stream = self.connector.connect().await?; + let mut bytes = serde_json::to_vec(&request)?; + bytes.push(b'\n'); + stream.write_all(&bytes).await?; + stream.flush().await?; + + let mut stream = BufReader::new(stream); + let line = match read_message(&mut stream).await? { + ReadMessage::Complete(line) => line, + ReadMessage::EndOfStream | ReadMessage::TooLarge => { + return Err(Error::Invalid("invalid Agent Control API response".into())); + } + }; + let response: Response = serde_json::from_slice(&line)?; + if response.jsonrpc != JSON_RPC_VERSION || response.id != id { + return Err(Error::Invalid("invalid Agent Control API response".into())); + } + if let Some(error) = response.error { + return Err(Error::Rpc(error)); + } + serde_json::from_value( + response + .result + .ok_or_else(|| Error::Invalid("Agent Control API response has no result".into()))?, + ) + .map_err(Error::from) + } +} diff --git a/agentctl/src/control_api/mod.rs b/agentctl/src/control_api/mod.rs new file mode 100644 index 0000000..834acee --- /dev/null +++ b/agentctl/src/control_api/mod.rs @@ -0,0 +1,13 @@ +//! Versioned Agent Control API with JSON-RPC 2.0/JSONL and replaceable stream transports. + +mod client; +mod protocol; +mod server; +mod socket; + +pub use client::{Client, Connection, Connector}; +pub use protocol::{DaemonInfo, PROTOCOL_VERSION, ResponseError}; +pub use server::{ + AgentApi, AuthenticationApi, ConvergenceApi, ErrorHandler, ExecutionApi, Server, SessionApi, SshAccessApi, + VncAccessApi, +}; diff --git a/agentctl/src/control_api/protocol.rs b/agentctl/src/control_api/protocol.rs new file mode 100644 index 0000000..545aa3e --- /dev/null +++ b/agentctl/src/control_api/protocol.rs @@ -0,0 +1,290 @@ +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt as _}; + +/// Agent Control API version, independent of the JSON-RPC envelope. +pub const PROTOCOL_VERSION: &str = "v4"; +pub(crate) const JSON_RPC_VERSION: &str = "2.0"; + +pub(crate) const METHOD_APPLY: &str = "agents.v1.apply"; +pub(crate) const METHOD_HEALTH: &str = "control.v1.health"; +pub(crate) const METHOD_SHUTDOWN: &str = "control.v1.shutdown"; +pub(crate) const METHOD_GET: &str = "agents.v1.get"; +pub(crate) const METHOD_LIST: &str = "agents.v1.list"; +pub(crate) const METHOD_PROGRESS: &str = "agents.v1.progress"; +pub(crate) const METHOD_RESOURCES_WATCH: &str = "resources.v1.watch"; +pub(crate) const METHOD_RESOLVE_DIRECTORY: &str = "agents.v1.resolveDirectory"; +pub(crate) const METHOD_EXECUTION_ENSURE: &str = "agents.v1.ensureExecution"; +pub(crate) const METHOD_DELETE: &str = "agents.v1.delete"; +pub(crate) const METHOD_STOP: &str = "agents.v1.stop"; +pub(crate) const METHOD_START: &str = "agents.v1.start"; +pub(crate) const METHOD_CONVERGE: &str = "agents.v1.converge"; +pub(crate) const METHOD_SSH_ACCESS: &str = "agents.v1.sshAccess"; +pub(crate) const METHOD_VNC_ACCESS: &str = "agents.v1.vncAccess"; +pub(crate) const METHOD_AUTH_LOGIN: &str = "authentication.v1.login"; +pub(crate) const METHOD_SESSION_ENSURE: &str = "sessions.v1.ensure"; +pub(crate) const METHOD_SESSION_GET: &str = "sessions.v1.get"; +pub(crate) const METHOD_SESSION_LIST: &str = "sessions.v1.list"; +pub(crate) const METHOD_SESSION_PROMPT: &str = "sessions.v1.prompt"; +pub(crate) const METHOD_SESSION_TURNS: &str = "sessions.v1.turns"; +pub(crate) const METHOD_SESSION_DELETE: &str = "sessions.v1.delete"; +pub(crate) const METHOD_SESSION_ARCHIVE: &str = "sessions.v1.archive"; +pub(crate) const METHOD_SESSION_UNARCHIVE: &str = "sessions.v1.unarchive"; + +pub(crate) const CODE_PARSE_ERROR: i32 = -32700; +pub(crate) const CODE_INVALID_REQUEST: i32 = -32600; +pub(crate) const CODE_METHOD_NOT_FOUND: i32 = -32601; +pub(crate) const CODE_INVALID_PARAMS: i32 = -32602; +pub(crate) const CODE_INTERNAL: i32 = -32603; +pub(crate) const CODE_NOT_FOUND: i32 = -32004; +pub(crate) const CODE_IMMUTABLE: i32 = -32009; +pub(crate) const CODE_UPDATING: i32 = -32010; +pub(crate) const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024; + +pub(crate) enum ReadMessage { + EndOfStream, + Complete(Vec), + TooLarge, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Request { + pub jsonrpc: String, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, + pub id: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Response { + pub jsonrpc: String, + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC error returned by the local control plane. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, thiserror::Error)] +#[error("{message}")] +pub struct ResponseError { + /// Stable JSON-RPC or application error code. + pub code: i32, + /// Human-readable error message. + pub message: String, +} + +impl ResponseError { + /// Returns whether the daemon rejected the request's desired state or parameters. + #[must_use] + pub const fn is_invalid_params(&self) -> bool { + self.code == CODE_INVALID_PARAMS + } + + /// Returns whether the addressed resource does not exist. + #[must_use] + pub const fn is_not_found(&self) -> bool { + self.code == CODE_NOT_FOUND + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct NameParams { + pub name: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ExecutionEnsureParams { + pub name: String, + #[serde(default, skip_serializing_if = "is_false")] + pub follow: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResourcesWatchParams { + /// Revision of the previous reply; absent requests the current state now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProgressParams { + pub name: String, + /// Revision of the previous reply; absent requests the current state now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, + /// Output the caller already has, so the reply carries only later lines. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionParams { + pub agent: String, + pub name: crate::sessions::SessionName, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionEnsureParams { + pub agent: String, + pub name: crate::sessions::SessionName, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, + /// Provider-owned model and effort level; validated, otherwise opaque to the daemon. + #[serde(default, skip_serializing_if = "crate::ModelSelection::is_empty")] + pub model_selection: crate::ModelSelection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_prompt: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub follow: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionPromptParams { + pub agent: String, + pub name: crate::sessions::SessionName, + pub prompt: String, + #[serde(default, skip_serializing_if = "is_false")] + pub wait: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionTurnsParams { + pub agent: String, + pub name: crate::sessions::SessionName, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DirectoryParams { + pub directory: std::path::PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionListParams { + #[serde(default)] + pub agent: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LoginParams { + pub harness: crate::harness::Harness, + pub credential: String, + /// The credential was supplied by the caller rather than minted by the host login flow. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub imported: bool, +} + +/// Identity returned by the frozen lifecycle health method. +/// +/// Both fields are optional so an updater can identify the preview 1 daemon, +/// which did not report a build version. Normal commands require exact values. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonInfo { + /// Application protocol spoken by the daemon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol_version: Option, + /// Build version of the daemon executable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_version: Option, +} + +impl DaemonInfo { + /// Requires the daemon to be the exact counterpart of this client build. + /// + /// # Errors + /// + /// Returns an error containing both identities when either value differs. + pub fn require_compatible(&self) -> Result<(), crate::Error> { + if self.protocol_version.as_deref() == Some(PROTOCOL_VERSION) + && self.build_version.as_deref() == Some(crate::build_version()) + { + return Ok(()); + } + Err(crate::Error::Daemon(format!( + "running agentd is incompatible: protocol {:?}, build {:?}; agentctl expects protocol {PROTOCOL_VERSION:?}, build {:?}", + self.protocol_version, + self.build_version, + crate::build_version() + ))) + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ShutdownParams { + pub reason: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ShutdownResult { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +pub(crate) fn error_response(id: u64, code: i32, message: impl Into) -> Response { + Response { + jsonrpc: JSON_RPC_VERSION.into(), + id, + result: None, + error: Some(ResponseError { + code, + message: message.into(), + }), + } +} + +pub(crate) async fn read_message(reader: &mut R) -> std::io::Result { + let mut message = Vec::new(); + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + return Ok(if message.is_empty() { + ReadMessage::EndOfStream + } else { + ReadMessage::Complete(message) + }); + } + let end = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |position| position + 1); + if message.len().saturating_add(end) > MAX_MESSAGE_BYTES { + return Ok(ReadMessage::TooLarge); + } + let complete = available.get(end - 1) == Some(&b'\n'); + message.extend_from_slice(&available[..end]); + reader.consume(end); + if complete { + return Ok(ReadMessage::Complete(message)); + } + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_false(value: &bool) -> bool { + !*value +} diff --git a/agentctl/src/control_api/server.rs b/agentctl/src/control_api/server.rs new file mode 100644 index 0000000..0205d3f --- /dev/null +++ b/agentctl/src/control_api/server.rs @@ -0,0 +1,932 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + +use sandbox::LocalFuture; +use serde::Serialize; +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::Notify; + +use crate::{ + Agent, Error, control_plane, control_plane::WaitPolicy, harness, progress::AgentProgress, resources::Changes, + sessions, +}; + +use super::protocol::{ + CODE_IMMUTABLE, CODE_INTERNAL, CODE_INVALID_PARAMS, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_NOT_FOUND, + CODE_PARSE_ERROR, CODE_UPDATING, DirectoryParams, ExecutionEnsureParams, JSON_RPC_VERSION, LoginParams, + METHOD_APPLY, METHOD_AUTH_LOGIN, METHOD_CONVERGE, METHOD_DELETE, METHOD_EXECUTION_ENSURE, METHOD_GET, + METHOD_HEALTH, METHOD_LIST, METHOD_PROGRESS, METHOD_RESOLVE_DIRECTORY, METHOD_RESOURCES_WATCH, + METHOD_SESSION_ARCHIVE, METHOD_SESSION_DELETE, METHOD_SESSION_ENSURE, METHOD_SESSION_GET, METHOD_SESSION_LIST, + METHOD_SESSION_PROMPT, METHOD_SESSION_TURNS, METHOD_SESSION_UNARCHIVE, METHOD_SHUTDOWN, METHOD_SSH_ACCESS, + METHOD_START, METHOD_STOP, METHOD_VNC_ACCESS, NameParams, PROTOCOL_VERSION, ProgressParams, ReadMessage, Request, + ResourcesWatchParams, Response, SessionEnsureParams, SessionListParams, SessionParams, SessionPromptParams, + SessionTurnsParams, ShutdownParams, error_response, read_message, +}; + +/// Quiet period after a change before a progress reply, so a burst of byte +/// progress costs one reply. +const PROGRESS_SETTLE: Duration = Duration::from_millis(50); +/// Quiet period after a resource change before a watch replies, so a burst of +/// changes, such as byte progress during an image pull, costs one reply. +const WATCH_SETTLE: Duration = Duration::from_millis(150); +/// Longest a watch waits without a change; the unchanged reply tells the +/// watcher the daemon is still there. +const WATCH_KEEPALIVE: Duration = Duration::from_secs(30); + +/// Agent operations exposed through the Agent Control API. +pub trait AgentApi { + /// Creates or updates desired Agent state. + fn apply(&self, request: control_plane::ApplyRequest) -> LocalFuture<'_, Result>; + + /// Gets an Agent by name. + fn get<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; + + /// Lists every active Agent. + fn list(&self) -> LocalFuture<'_, Result, Error>>; + + /// Resolves an Agent from its persisted source directory. + fn resolve_directory<'a>( + &'a self, + directory: &'a std::path::Path, + variant: Option<&'a crate::AgentVariantName>, + ) -> LocalFuture<'a, Result>; + + /// Requests asynchronous deletion. + fn delete<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>>; + + /// Records whether an Agent's Sandbox runs; see [`control_plane::ControlPlane::set_run_state`]. + fn set_run_state<'a>(&'a self, name: &'a str, state: crate::RunState) -> LocalFuture<'a, Result>; + + /// Reads an Agent's stored status and its latest pass's progress, with + /// only the output after `output` when it names the same pass. + fn progress<'a>( + &'a self, + name: &'a str, + output: Option, + ) -> LocalFuture<'a, Result<(crate::Status, Option), Error>>; +} + +impl AgentApi for control_plane::ControlPlane { + fn apply(&self, request: control_plane::ApplyRequest) -> LocalFuture<'_, Result> { + Box::pin(async move { Self::apply(self, request).await }) + } + + fn get<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::get(self, name).await }) + } + + fn list(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { Self::list(self).await }) + } + + fn resolve_directory<'a>( + &'a self, + directory: &'a std::path::Path, + variant: Option<&'a crate::AgentVariantName>, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { self.resolve_directory_variant(directory, variant).await }) + } + + fn delete<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { Self::delete(self, name).await }) + } + + fn set_run_state<'a>(&'a self, name: &'a str, state: crate::RunState) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::set_run_state(self, name, state).await }) + } + + fn progress<'a>( + &'a self, + name: &'a str, + output: Option, + ) -> LocalFuture<'a, Result<(crate::Status, Option), Error>> { + Box::pin(async move { Self::progress(self, name, output).await }) + } +} + +/// Host-side authentication operations exposed through the local control API. +pub trait AuthenticationApi { + /// Stores a credential for one harness; `imported` marks one supplied by the caller + /// instead of minted by the host login flow. + fn login<'a>( + &'a self, + harness: harness::Harness, + credential: &'a str, + imported: bool, + ) -> LocalFuture<'a, Result>; +} + +impl AuthenticationApi for harness::AuthenticationManager { + fn login<'a>( + &'a self, + harness: harness::Harness, + credential: &'a str, + imported: bool, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.login(harness, zeroize::Zeroizing::new(credential.to_owned()), imported) + .await + }) + } +} + +/// Host-tracked session operations exposed through the local control API. +pub trait SessionApi { + /// Creates or resolves one named session attach target; see [`sessions::Service::ensure`]. + fn ensure<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + request: sessions::SessionRequest, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result>; + + /// Gets one named Session scoped to an Agent. + fn get<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + ) -> LocalFuture<'a, Result>; + + /// Lists tracked Sessions, optionally scoped to one Agent. + fn list<'a>(&'a self, agent: Option<&'a str>) -> LocalFuture<'a, Result, Error>>; + + /// Delivers a prompt to a running Session's harness, optionally waiting for + /// a completed turn and settled activity; see [`sessions::Service::prompt`]. + fn prompt<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + prompt: &'a str, + wait: bool, + timeout: Option, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Reads the harness transcript of a Session as ordered turns. + fn turns<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + last: Option, + ) -> LocalFuture<'a, Result, Error>>; + + /// Requests release of one Session; see [`sessions::Service::delete`]. + fn delete<'a>(&'a self, agent: &'a str, name: &'a sessions::SessionName) -> LocalFuture<'a, Result<(), Error>>; + + /// Archives or unarchives one Session; see [`sessions::Service::set_archived`]. + fn set_archived<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + archived: bool, + ) -> LocalFuture<'a, Result>; + + /// Lists Sessions whose work or terminal attachment prevents an upgrade. + fn upgrade_readiness(&self) -> LocalFuture<'_, Result>; +} + +impl SessionApi for sessions::Service { + fn ensure<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + request: sessions::SessionRequest, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::ensure(self, agent, name, request, wait).await }) + } + + fn prompt<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + prompt: &'a str, + wait: bool, + timeout: Option, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { Self::prompt(self, agent, name, prompt, wait, timeout).await }) + } + + fn turns<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + last: Option, + ) -> LocalFuture<'a, Result, Error>> { + Box::pin(async move { Self::turns(self, agent, name, last).await }) + } + + fn get<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::get(self, agent, name).await }) + } + + fn list<'a>(&'a self, agent: Option<&'a str>) -> LocalFuture<'a, Result, Error>> { + Box::pin(async move { Self::list(self, agent).await }) + } + + fn set_archived<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + archived: bool, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::set_archived(self, agent, name, archived).await }) + } + + fn delete<'a>(&'a self, agent: &'a str, name: &'a sessions::SessionName) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { Self::delete(self, agent, name).await }) + } + + fn upgrade_readiness(&self) -> LocalFuture<'_, Result> { + Box::pin(Self::upgrade_readiness(self)) + } +} + +/// Waiting for an Agent to converge, exposed through the local control API. +pub trait ConvergenceApi { + /// Waits until an Agent has its desired run state; see + /// [`control_plane::Convergence::converge`]. + fn converge<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>>; +} + +impl ConvergenceApi for control_plane::Convergence { + fn converge<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.converge(name, WaitPolicy::UntilConverged).await.map(drop) }) + } +} + +/// Transient Agent Execution target resolution exposed through the local control API. +pub trait ExecutionApi { + /// Converges an Agent and returns its exact ready Sandbox assignment. + fn ensure<'a>( + &'a self, + name: &'a str, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result>; +} + +impl ExecutionApi for crate::sandbox::ExecutionService { + fn ensure<'a>( + &'a self, + name: &'a str, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::ensure(self, name, wait).await }) + } +} + +/// SSH access descriptors exposed through the local control API. +pub trait SshAccessApi { + /// Describes the SSH access of a named Agent. + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; +} + +impl SshAccessApi for crate::ssh::Access { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::describe(self, name).await }) + } +} + +/// VNC access descriptors exposed through the local control API. +pub trait VncAccessApi { + /// Describes the VNC access of a named Agent. + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; +} + +impl VncAccessApi for crate::vnc::Access { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::describe(self, name).await }) + } +} + +/// Observes an isolated connection error without terminating the daemon. +pub type ErrorHandler = Rc; + +#[derive(Clone, Copy, Default, Eq, PartialEq)] +enum LifecycleState { + #[default] + Running, + Checking, + Draining, +} + +#[derive(Default)] +struct Lifecycle { + state: Cell, + active_mutations: Cell, + mutations_idle: Notify, + shutdown: Notify, +} + +impl Lifecycle { + fn admit_mutation(&self) -> Option> { + if self.state.get() != LifecycleState::Running { + return None; + } + self.active_mutations.set(self.active_mutations.get() + 1); + Some(MutationGuard { lifecycle: self }) + } + + async fn wait_for_mutations(&self) { + loop { + let notified = self.mutations_idle.notified(); + if self.active_mutations.get() == 0 { + return; + } + notified.await; + } + } +} + +struct MutationGuard<'a> { + lifecycle: &'a Lifecycle, +} + +impl Drop for MutationGuard<'_> { + fn drop(&mut self) { + let remaining = self.lifecycle.active_mutations.get() - 1; + self.lifecycle.active_mutations.set(remaining); + if remaining == 0 { + self.lifecycle.mutations_idle.notify_waiters(); + } + } +} + +struct ShutdownCheck<'a> { + lifecycle: &'a Lifecycle, + committed: bool, +} + +impl ShutdownCheck<'_> { + fn commit(mut self) { + self.lifecycle.state.set(LifecycleState::Draining); + self.lifecycle.shutdown.notify_waiters(); + self.committed = true; + } +} + +impl Drop for ShutdownCheck<'_> { + fn drop(&mut self) { + if !self.committed { + self.lifecycle.state.set(LifecycleState::Running); + } + } +} + +/// Serves the Agent Control API. +pub struct Server { + agents: Rc, + authentication: Rc, + convergence: Rc, + executions: Rc, + sessions: Rc, + ssh: Rc, + vnc: Rc, + changes: Changes, + on_error: ErrorHandler, + lifecycle: Lifecycle, +} + +impl Server { + /// Creates an Agent Control API server. + #[must_use] + #[allow( + clippy::too_many_arguments, + reason = "each API the server dispatches to is wired explicitly at the daemon boundary" + )] + pub fn new( + agents: Rc, + authentication: Rc, + convergence: Rc, + executions: Rc, + sessions: Rc, + ssh: Rc, + vnc: Rc, + changes: Changes, + on_error: ErrorHandler, + ) -> Self { + Self { + agents, + authentication, + convergence, + executions, + sessions, + ssh, + vnc, + changes, + on_error, + lifecycle: Lifecycle::default(), + } + } + + /// Listens on the platform's local socket implementation. + /// + /// # Errors + /// + /// Returns an error when the endpoint cannot be secured, bound, or served. + pub async fn serve_path(self: Rc, path: &std::path::Path) -> Result<(), Error> { + super::socket::serve(self, path).await + } + + /// Serves one JSON object per line until the client closes its stream. + /// + /// # Errors + /// + /// Returns an error when a message is malformed, exceeds the limit, or cannot be read or written. + pub async fn serve_connection(&self, stream: S) -> Result<(), Error> + where + S: AsyncRead + AsyncWrite + Unpin, + { + let mut stream = BufReader::new(stream); + loop { + if self.is_draining() { + return Ok(()); + } + let message = tokio::select! { + message = read_message(&mut stream) => message?, + () = self.shutdown_requested() => return Ok(()), + }; + let line = match message { + ReadMessage::EndOfStream => return Ok(()), + ReadMessage::Complete(line) => line, + ReadMessage::TooLarge => { + write_response( + stream.get_mut(), + &error_response(0, CODE_PARSE_ERROR, "JSON-RPC request exceeds 4 MiB"), + ) + .await?; + return Err(Error::Invalid("Agent Control API request exceeds 4 MiB".into())); + } + }; + + let request = match serde_json::from_slice::(&line) { + Ok(request) => request, + Err(error) => { + write_response( + stream.get_mut(), + &error_response(0, CODE_PARSE_ERROR, "invalid JSON-RPC request"), + ) + .await?; + return Err(Error::Json(error)); + } + }; + let response = if ends_with_its_client(&request.method) { + // A reply that is ready wins over a client that has half-closed. + tokio::select! { + biased; + response = self.handle(request) => response, + () = disconnected(&mut stream) => return Ok(()), + } + } else { + self.handle(request).await + }; + write_response(stream.get_mut(), &response).await?; + } + } + + pub(crate) fn report(&self, error: &Error) { + (self.on_error)(error); + } + + pub(crate) fn is_draining(&self) -> bool { + self.lifecycle.state.get() == LifecycleState::Draining + } + + pub(crate) async fn shutdown_requested(&self) { + if !self.is_draining() { + self.lifecycle.shutdown.notified().await; + } + } + + async fn handle(&self, request: Request) -> Response { + if request.jsonrpc != JSON_RPC_VERSION || request.method.is_empty() { + return error_response(request.id, CODE_INVALID_REQUEST, "invalid JSON-RPC 2.0 request"); + } + let _mutation = if is_mutating(&request.method) { + let Some(mutation) = self.lifecycle.admit_mutation() else { + return error_response(request.id, CODE_UPDATING, "Agent daemon is preparing for an upgrade"); + }; + Some(mutation) + } else { + None + }; + match request.method.as_str() { + METHOD_APPLY => self.handle_apply(request.id, request.params).await, + METHOD_HEALTH => result_response( + request.id, + Ok(serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "buildVersion": crate::build_version() + })), + ), + METHOD_SHUTDOWN => self.handle_shutdown(request.id, request.params).await, + METHOD_GET => self.handle_get(request.id, request.params).await, + METHOD_LIST => result_response(request.id, self.agents.list().await), + METHOD_PROGRESS => self.handle_progress(request.id, request.params).await, + METHOD_RESOURCES_WATCH => self.handle_resources_watch(request.id, request.params).await, + METHOD_RESOLVE_DIRECTORY => self.handle_resolve_directory(request.id, request.params).await, + METHOD_EXECUTION_ENSURE => self.handle_execution_ensure(request.id, request.params).await, + METHOD_DELETE => self.handle_delete(request.id, request.params).await, + METHOD_CONVERGE => self.handle_converge(request.id, request.params).await, + METHOD_STOP => { + self.handle_run_state(request.id, request.params, crate::RunState::Stopped) + .await + } + METHOD_START => { + self.handle_run_state(request.id, request.params, crate::RunState::Running) + .await + } + METHOD_SSH_ACCESS => self.handle_ssh_access(request.id, request.params).await, + METHOD_VNC_ACCESS => self.handle_vnc_access(request.id, request.params).await, + METHOD_AUTH_LOGIN => self.handle_auth_login(request.id, request.params).await, + METHOD_SESSION_ENSURE => self.handle_session_ensure(request.id, request.params).await, + METHOD_SESSION_GET => self.handle_session_get(request.id, request.params).await, + METHOD_SESSION_LIST => self.handle_session_list(request.id, request.params).await, + METHOD_SESSION_PROMPT => self.handle_session_prompt(request.id, request.params).await, + METHOD_SESSION_TURNS => self.handle_session_turns(request.id, request.params).await, + METHOD_SESSION_DELETE => self.handle_session_delete(request.id, request.params).await, + METHOD_SESSION_ARCHIVE => self.handle_session_archive(request.id, request.params, true).await, + METHOD_SESSION_UNARCHIVE => self.handle_session_archive(request.id, request.params, false).await, + _ => error_response(request.id, CODE_METHOD_NOT_FOUND, "method not found"), + } + } + + async fn handle_shutdown(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "shutdown reason is required"); + }; + if params.reason != "upgrade" { + return error_response(id, CODE_INVALID_PARAMS, "unsupported shutdown reason"); + } + if self.lifecycle.state.get() != LifecycleState::Running { + return error_response(id, CODE_UPDATING, "Agent daemon is already preparing for an upgrade"); + } + self.lifecycle.state.set(LifecycleState::Checking); + let check = ShutdownCheck { + lifecycle: &self.lifecycle, + committed: false, + }; + let readiness = tokio::time::timeout(Duration::from_mins(1), async { + let readiness = self.sessions.upgrade_readiness().await?; + if !readiness.blockers.is_empty() { + return Ok(readiness); + } + self.lifecycle.wait_for_mutations().await; + self.sessions.upgrade_readiness().await + }) + .await; + match readiness { + Ok(Ok(readiness)) if readiness.blockers.is_empty() => { + check.commit(); + result_response(id, Ok(serde_json::json!({"warnings": readiness.warnings}))) + } + Ok(Ok(readiness)) => error_response( + id, + CODE_INVALID_PARAMS, + format!("active Sessions block the upgrade: {}", readiness.blockers.join(", ")), + ), + Ok(Err(error)) => result_response::(id, Err(error)), + Err(_) => error_response(id, CODE_UPDATING, "Agent did not finish preparing for an upgrade"), + } + } + + async fn handle_apply(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "invalid apply parameters"); + }; + result_response(id, self.agents.apply(params).await) + } + + async fn handle_get(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.agents.get(¶ms.name).await) + } + + async fn handle_resolve_directory(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "directory is required"); + }; + result_response( + id, + self.agents + .resolve_directory(¶ms.directory, params.variant.as_ref()) + .await, + ) + } + + async fn handle_delete(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response( + id, + self.agents.delete(¶ms.name).await.map(|()| serde_json::json!({})), + ) + } + + /// Waits until the Agent has its desired run state, then returns it as + /// `agents.v1.get` does. Draining ends the wait, so an upgrade is never held + /// by a waiter; the desired state is stored, so nothing is lost. + async fn handle_converge(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + let converged = tokio::select! { + converged = self.convergence.converge(¶ms.name) => converged, + () = self.shutdown_requested() => { + return error_response( + id, + CODE_UPDATING, + "Agent daemon is preparing for an upgrade; run the command again once it is back", + ); + } + }; + let agent = match converged { + Ok(()) => self.agents.get(¶ms.name).await, + Err(error) => Err(error), + }; + result_response(id, agent) + } + + async fn handle_run_state(&self, id: u64, value: Value, state: crate::RunState) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.agents.set_run_state(¶ms.name, state).await) + } + + async fn handle_ssh_access(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.ssh.describe(¶ms.name).await) + } + + /// Long-polls for a change after the caller's revision, then returns the + /// Agent's status and progress. Draining returns at once so an upgrade is + /// never held by a follower. + async fn handle_progress(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response( + id, + CODE_INVALID_PARAMS, + "name is required, and after must be a revision", + ); + }; + tokio::select! { + _changed = self.changes.changed_since(params.after, PROGRESS_SETTLE, WATCH_KEEPALIVE) => {} + () = self.shutdown_requested() => {} + } + let revision = self.changes.revision(); + let progress = self + .agents + .progress(¶ms.name, params.output) + .await + .map(|(status, provisioning)| AgentProgress { + revision, + status, + provisioning, + }); + result_response(id, progress) + } + + /// Long-polls for a resource change after the caller's revision, then + /// returns every Agent and Session. Draining returns at once so an upgrade + /// is never held by a watcher. + async fn handle_resources_watch(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "after must be a resource revision"); + }; + tokio::select! { + _changed = self.changes.changed_since(params.after, WATCH_SETTLE, WATCH_KEEPALIVE) => {} + () = self.shutdown_requested() => {} + } + let revision = self.changes.revision(); + let resources = async { + Ok(crate::resources::Resources { + revision, + agents: self.agents.list().await?, + sessions: self.sessions.list(None).await?, + }) + }; + result_response(id, resources.await) + } + + async fn handle_vnc_access(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.vnc.describe(¶ms.name).await) + } + + async fn handle_execution_ensure(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "name is required"); + }; + if params.name.is_empty() { + return error_response(id, CODE_INVALID_PARAMS, "name is required"); + } + result_response( + id, + self.executions.ensure(¶ms.name, wait_policy(params.follow)).await, + ) + } + + async fn handle_auth_login(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "harness and credential are required"); + }; + result_response( + id, + self.authentication + .login(params.harness, ¶ms.credential, params.imported) + .await, + ) + } + + async fn handle_session_ensure(&self, id: u64, value: Value) -> Response { + let params = match serde_json::from_value::(value) { + Ok(params) => params, + // The selections carry their own validation, so name the decoding failure + // instead of blaming the two required fields. + Err(error) => { + return error_response( + id, + CODE_INVALID_PARAMS, + format!("agent and session name are required, and selections must be valid: {error}"), + ); + } + }; + let wait = wait_policy(params.follow); + let request = sessions::SessionRequest { + harness: params.harness, + model_selection: params.model_selection, + initial_prompt: params.initial_prompt, + }; + result_response( + id, + self.sessions.ensure(¶ms.agent, ¶ms.name, request, wait).await, + ) + } + + async fn handle_session_prompt(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent, session name and prompt are required"); + }; + result_response( + id, + self.sessions + .prompt(¶ms.agent, ¶ms.name, ¶ms.prompt, params.wait, params.timeout) + .await + .map(|()| serde_json::json!({})), + ) + } + + async fn handle_session_turns(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response(id, self.sessions.turns(¶ms.agent, ¶ms.name, params.last).await) + } + + async fn handle_session_get(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response(id, self.sessions.get(¶ms.agent, ¶ms.name).await) + } + + async fn handle_session_delete(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response( + id, + self.sessions + .delete(¶ms.agent, ¶ms.name) + .await + .map(|()| serde_json::json!({})), + ) + } + + async fn handle_session_archive(&self, id: u64, value: Value, archived: bool) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response( + id, + self.sessions.set_archived(¶ms.agent, ¶ms.name, archived).await, + ) + } + + async fn handle_session_list(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "invalid Session list parameters"); + }; + result_response(id, self.sessions.list(params.agent.as_deref()).await) + } +} + +const fn wait_policy(follow: bool) -> WaitPolicy { + if follow { + WaitPolicy::UntilConverged + } else { + WaitPolicy::FirstPass + } +} + +/// Whether a request waits and may stop when its client disconnects. Each +/// only reads or waits, or, like `sessions.v1.ensure`, its writes are each +/// complete on their own. Every other request runs to completion, so an +/// interrupted command never leaves a change half made. +fn ends_with_its_client(method: &str) -> bool { + matches!( + method, + METHOD_PROGRESS + | METHOD_RESOURCES_WATCH + | METHOD_CONVERGE + | METHOD_EXECUTION_ENSURE + | METHOD_SESSION_ENSURE + | METHOD_SESSION_TURNS + ) +} + +/// Completes once the client has closed its end. A client sends nothing while +/// it waits for a reply, so data here is a pipelined request, left for the +/// next read. +async fn disconnected(reader: &mut R) { + use tokio::io::AsyncBufReadExt as _; + match reader.fill_buf().await { + Ok(buffer) if !buffer.is_empty() => std::future::pending().await, + Ok(_) | Err(_) => {} + } +} + +fn is_mutating(method: &str) -> bool { + matches!( + method, + METHOD_APPLY + | METHOD_DELETE + | METHOD_STOP + | METHOD_START + | METHOD_EXECUTION_ENSURE + | METHOD_AUTH_LOGIN + | METHOD_SESSION_ENSURE + | METHOD_SESSION_PROMPT + | METHOD_SESSION_DELETE + | METHOD_SESSION_ARCHIVE + | METHOD_SESSION_UNARCHIVE + ) +} + +fn name_params(value: Value) -> Result { + serde_json::from_value::(value) + .ok() + .filter(|params| !params.name.is_empty()) + .ok_or_else(|| error_response(0, CODE_INVALID_PARAMS, "name is required")) +} + +const fn response_with_id(id: u64, mut response: Response) -> Response { + response.id = id; + response +} + +fn result_response(id: u64, result: Result) -> Response { + match result { + Ok(value) => serde_json::to_value(value).map_or_else( + |_| error_response(id, CODE_INTERNAL, "encode response result"), + |result| Response { + jsonrpc: JSON_RPC_VERSION.into(), + id, + result: Some(result), + error: None, + }, + ), + Err(Error::NotFound) => error_response(id, CODE_NOT_FOUND, Error::NotFound.to_string()), + Err(Error::Immutable(field)) => error_response(id, CODE_IMMUTABLE, Error::Immutable(field).to_string()), + Err(Error::Conflict) => error_response(id, CODE_IMMUTABLE, Error::Conflict.to_string()), + Err(Error::Invalid(message)) => error_response(id, CODE_INVALID_PARAMS, message), + Err(error @ Error::Stopped(_)) => error_response(id, CODE_INVALID_PARAMS, error.to_string()), + Err(error) => error_response(id, CODE_INTERNAL, error.to_string()), + } +} + +async fn write_response(writer: &mut W, response: &Response) -> Result<(), Error> { + let mut bytes = serde_json::to_vec(response)?; + bytes.push(b'\n'); + writer.write_all(&bytes).await?; + writer.flush().await?; + Ok(()) +} diff --git a/agentctl/src/control_api/socket.rs b/agentctl/src/control_api/socket.rs new file mode 100644 index 0000000..fa26e49 --- /dev/null +++ b/agentctl/src/control_api/socket.rs @@ -0,0 +1,232 @@ +use std::{path::PathBuf, rc::Rc, time::Duration}; + +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; +use sandbox::LocalFuture; + +use crate::Error; + +use super::{Connector, Server, client::Connection}; + +const MAX_CONCURRENT_CONNECTIONS: usize = 128; +const CONNECTION_DRAIN_TIMEOUT: Duration = Duration::from_mins(1); +type ConnectionFuture = futures_util::future::LocalBoxFuture<'static, ()>; + +async fn drain_connections(connections: &mut FuturesUnordered, timeout: Duration) { + if tokio::time::timeout(timeout, async { while connections.next().await.is_some() {} }) + .await + .is_err() + { + tracing::warn!("cancelled Control API calls that did not finish during the shutdown drain"); + } +} + +/// Connector for the fixed per-user Agent Control API socket path. +pub(super) struct PathConnector { + path: PathBuf, +} + +impl PathConnector { + #[must_use] + pub(super) const fn new(path: PathBuf) -> Self { + Self { path } + } +} + +#[cfg(unix)] +impl Connector for PathConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let stream = tokio::net::UnixStream::connect(&self.path).await?; + Ok(Box::new(stream) as Box) + }) + } +} + +#[cfg(target_os = "windows")] +impl Connector for PathConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + use tokio_util::compat::FuturesAsyncReadCompatExt as _; + + let stream = win_uds::net::AsyncStream::connect(&self.path).await?.compat(); + Ok(Box::new(stream) as Box) + }) + } +} + +#[cfg(unix)] +pub(crate) async fn serve(server: Rc, path: &std::path::Path) -> Result<(), Error> { + use std::os::unix::fs::FileTypeExt; + + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("local API socket has no parent directory".into()))?; + std::fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() => std::fs::remove_file(path)?, + Ok(_) => return Err(Error::Invalid("local API path exists and is not a socket".into())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(Error::Io(error)), + } + let listener = tokio::net::UnixListener::bind(path)?; + crate::local::home::secure_file(path)?; + let mut connections = FuturesUnordered::::new(); + + loop { + if server.is_draining() { + break; + } + tokio::select! { + accepted = listener.accept(), if connections.len() < MAX_CONCURRENT_CONNECTIONS => { + let (stream, _) = accepted?; + let connection_server = server.clone(); + connections.push(async move { + if let Err(error) = connection_server.serve_connection(stream).await { + connection_server.report(&error); + } + }.boxed_local()); + } + Some(()) = connections.next(), if !connections.is_empty() => {} + () = server.shutdown_requested() => break, + } + } + drain_connections(&mut connections, CONNECTION_DRAIN_TIMEOUT).await; + Ok(()) +} + +#[cfg(target_os = "windows")] +pub(crate) async fn serve(server: Rc, path: &std::path::Path) -> Result<(), Error> { + use tokio_util::compat::FuturesAsyncReadCompatExt as _; + + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("local API socket has no parent directory".into()))?; + std::fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + sweep_quarantined_socket_directories(parent); + match std::fs::symlink_metadata(path) { + Ok(_) => { + // Windows leaves the AF_UNIX path behind after an abnormal exit. + // Refuse a path with a live listener, but remove an unreachable + // entry before binding. agentd holds the exclusive home lock while + // calling this function, so another daemon cannot race recovery. + if win_uds::net::AsyncStream::connect(path).await.is_ok() { + return Err(Error::Invalid("local API path is already occupied".into())); + } + // afd.sys can keep a stale socket file undeletable and unbindable + // until reboot. Renaming its directory aside still works then, so + // quarantine it and recreate the directory before binding. + if std::fs::remove_file(path).is_err() { + quarantine_socket_directory(parent)?; + std::fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(Error::Io(error)), + } + // The socket inherits the user-only ACL from the home directory secured + // above; icacls cannot open an AF_UNIX socket reparse point (error 1920). + let listener = win_uds::net::AsyncListener::bind(path)?; + let _cleanup = SocketCleanup(path.to_path_buf()); + let mut connections = FuturesUnordered::::new(); + + loop { + if server.is_draining() { + break; + } + tokio::select! { + accepted = listener.accept(), if connections.len() < MAX_CONCURRENT_CONNECTIONS => { + let (stream, _) = accepted?; + let connection_server = server.clone(); + connections.push(async move { + if let Err(error) = connection_server.serve_connection(stream.compat()).await { + connection_server.report(&error); + } + }.boxed_local()); + } + Some(()) = connections.next(), if !connections.is_empty() => {} + () = server.shutdown_requested() => break, + } + } + drain_connections(&mut connections, CONNECTION_DRAIN_TIMEOUT).await; + Ok(()) +} + +#[cfg(target_os = "windows")] +const QUARANTINE_INFIX: &str = ".stale-"; +#[cfg(target_os = "windows")] +const QUARANTINE_ATTEMPTS: u32 = 1000; + +/// Renames the socket directory to an unused `.stale-` sibling. +#[cfg(target_os = "windows")] +fn quarantine_socket_directory(directory: &std::path::Path) -> Result<(), Error> { + let name = directory + .file_name() + .ok_or_else(|| Error::Invalid("local API socket directory has no name".into()))?; + for attempt in 0..QUARANTINE_ATTEMPTS { + let mut candidate = name.to_os_string(); + candidate.push(format!("{QUARANTINE_INFIX}{attempt}")); + let candidate = directory.with_file_name(candidate); + if candidate.exists() { + continue; + } + match std::fs::rename(directory, &candidate) { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(Error::Io(error)), + } + } + Err(Error::Invalid( + "no free quarantine name for the local API socket directory".into(), + )) +} + +/// Best-effort removal of quarantined socket directories; stale `AF_UNIX` +/// files become deletable again after a reboot. +#[cfg(target_os = "windows")] +fn sweep_quarantined_socket_directories(directory: &std::path::Path) { + let (Some(parent), Some(name)) = (directory.parent(), directory.file_name().and_then(|name| name.to_str())) else { + return; + }; + let prefix = format!("{name}{QUARANTINE_INFIX}"); + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_str().is_some_and(|name| name.starts_with(&prefix)) { + let _ignored = std::fs::remove_dir_all(entry.path()); + } + } +} + +#[cfg(target_os = "windows")] +struct SocketCleanup(PathBuf); + +#[cfg(target_os = "windows")] +impl Drop for SocketCleanup { + fn drop(&mut self) { + let _ignored = std::fs::remove_file(&self.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + async fn shutdown_drain_is_bounded_by_its_deadline() { + let mut connections = FuturesUnordered::new(); + connections.push(std::future::pending::<()>().boxed_local()); + + tokio::time::timeout( + Duration::from_millis(100), + drain_connections(&mut connections, Duration::ZERO), + ) + .await + .expect("bounded drain"); + assert_eq!(connections.len(), 1); + drop(connections); + } +} diff --git a/agentctl/src/control_plane/controller.rs b/agentctl/src/control_plane/controller.rs new file mode 100644 index 0000000..7ce01fe --- /dev/null +++ b/agentctl/src/control_plane/controller.rs @@ -0,0 +1,49 @@ +//! Agent specialization of the generic keyed reconciliation controller. + +use std::{rc::Rc, time::Duration}; + +use crate::{Error, control_plane::AgentId, controller}; + +use super::SharedAgentStore; + +/// Observes recoverable Agent reconciliation errors without stopping the controller. +pub type ErrorHandler = controller::ErrorHandler; + +/// A handle for requesting immediate Agent convergence. +pub type Wakeup = controller::Wakeup; + +struct Source(SharedAgentStore); + +impl controller::Source for Source { + fn list_keys(&self) -> ::sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.0 + .list() + .await + .map(|records| records.into_iter().map(|record| record.id).collect()) + }) + } +} + +/// Generic keyed reconciliation specialized for durable Agents. +pub struct Controller(controller::Controller); + +impl Controller { + /// Creates an Agent controller and its independently shareable wake-up handle. + #[must_use] + pub fn new( + store: SharedAgentStore, + reconciler: Rc>, + interval: Duration, + on_error: ErrorHandler, + ) -> (Self, Wakeup) { + let (controller, wakeup) = + controller::Controller::new(Rc::new(Source(store)), reconciler, interval, "Agent", on_error); + (Self(controller), wakeup) + } + + /// Reconciles existing Agents immediately and then continuously. + pub async fn run(self) { + self.0.run().await; + } +} diff --git a/agentctl/src/control_plane/convergence.rs b/agentctl/src/control_plane/convergence.rs new file mode 100644 index 0000000..0c7680c --- /dev/null +++ b/agentctl/src/control_plane/convergence.rs @@ -0,0 +1,128 @@ +//! Requests converging an Agent and waiting for the outcome. +//! +//! A waiter reads readiness and failure from the stored Agent and rereads it +//! whenever the daemon-wide revision advances, so it can skip intermediate +//! states but never miss the terminal one. Progress is observed separately, +//! through the Agent's provisioning state. + +use std::time::Duration; + +use crate::{AgentId, Error, FailureKind, ReconcileFailure, resources::Changes}; + +use super::{SharedAgentStore, Wakeup}; + +/// Longest a waiter goes without rereading the stored Agent. +const RECHECK_INTERVAL: Duration = Duration::from_secs(30); +/// How long a waiter lets changes gather before rereading the stored Agent. +/// Every progress event of any Agent advances the revision, so a waiter +/// rereads once per burst instead of once per event. +const SETTLE: Duration = Duration::from_millis(50); + +/// How long a request waits for the Agent it woke. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WaitPolicy { + /// Returns after one reconciliation pass with that pass's outcome. + FirstPass, + /// Keeps waiting through transient failures, which the background controller + /// retries, until the Agent has its desired run state or that state is + /// invalid. A running Agent's guest recorded as unresponsive ends the wait + /// instead. + UntilConverged, +} + +/// Wakes Agent convergence and lets a request wait for it. +#[derive(Clone)] +pub struct Convergence { + wakeup: Wakeup, + store: SharedAgentStore, + changes: Changes, +} + +impl Convergence { + /// Pairs the controller's wake-up handle with the stored Agents and their revision. + #[must_use] + pub fn new(wakeup: Wakeup, store: SharedAgentStore, changes: Changes) -> Self { + Self { wakeup, store, changes } + } + + /// Wakes convergence of the named Agent, waits according to `wait`, and + /// returns the Agent as stored when the wait ended. Converged means its + /// desired run state was recorded for its generation: Ready when it runs, + /// stopped when it is stopped. A caller that needs the Sandbox running + /// refuses a stopped Agent itself, before and after converging. + /// + /// # Errors + /// + /// Returns `Error::Invalid` when desired state must change, the first pass's + /// failure under [`WaitPolicy::FirstPass`], `Error::SandboxUnresponsive` + /// when a running Agent's guest is unresponsive, `Error::Conflict` when the + /// Agent is being or was deleted, `Error::NotFound` when it does not exist, + /// or a storage error. + pub async fn converge(&self, name: &str, wait: WaitPolicy) -> Result { + let record = self.store.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + let id = record.id; + let woken = self.wakeup.reconcile(id).await; + let record = self.get(id).await?; + match (wait, woken) { + (WaitPolicy::FirstPass, Ok(())) => return Ok(record), + (WaitPolicy::FirstPass, Err(failure)) => { + // A stalled guest is reported as the stall, not as a daemon failure. + return Err(record.agent.status.unresponsive().map_or_else( + || failure.into(), + |stalled| Error::SandboxUnresponsive(stalled.detail()), + )); + } + (WaitPolicy::UntilConverged, Err(failure)) if failure.kind == FailureKind::Invalid => { + return Err(failure.into()); + } + (WaitPolicy::UntilConverged, _) => {} + } + loop { + let revision = self.changes.revision(); + let record = self.get(id).await?; + if let Some(outcome) = outcome(&record) { + return outcome.map(|()| record); + } + self.changes + .changed_since(Some(revision), SETTLE, RECHECK_INTERVAL) + .await; + } + } + + /// Reads the waited-on Agent, which must still exist. + async fn get(&self, id: AgentId) -> Result { + match self.store.get(id).await { + Ok(record) if record.agent.metadata.deletion_timestamp.is_none() => Ok(record), + Ok(_) | Err(Error::NotFound) => Err(Error::Conflict), + Err(error) => Err(error), + } + } +} + +/// Ends a wait on `record`: `Ok` once a pass for its generation recorded its +/// desired run state, an error when it cannot get there without a change, and +/// `None` while it still may. +fn outcome(record: &super::AgentRecord) -> Option> { + let status = &record.agent.status; + let stopped = record.agent.spec.is_stopped(); + if status.observed_generation == record.agent.metadata.generation { + if (stopped && status.is_stopped()) || (!stopped && status.is_ready()) { + return Some(Ok(())); + } + if let Some(message) = status.invalid() { + return Some(Err(ReconcileFailure { + kind: FailureKind::Invalid, + message, + } + .into())); + } + } + // A stopped Agent reaches nothing in its guest, so a stall recorded before it stopped is stale. + if !stopped && let Some(stalled) = status.unresponsive() { + return Some(Err(Error::SandboxUnresponsive(stalled.detail()))); + } + None +} diff --git a/agentctl/src/control_plane/memory.rs b/agentctl/src/control_plane/memory.rs new file mode 100644 index 0000000..f7f3816 --- /dev/null +++ b/agentctl/src/control_plane/memory.rs @@ -0,0 +1,181 @@ +//! Single-threaded in-memory Agent Control Plane components. + +use std::{cell::RefCell, collections::BTreeMap}; + +use sandbox::LocalFuture; +use time::OffsetDateTime; + +use crate::{AgentId, Error, Status, resources::Changes}; + +use super::{AgentRecord, AgentStore}; + +/// In-memory Agent store with generation-based compare-and-swap writes. +/// +/// Like the database, every successful write advances its change history. +#[derive(Default)] +pub struct InMemoryAgentStore { + state: RefCell, + changes: Changes, +} + +#[derive(Default)] +struct State { + records: BTreeMap, + active_names: BTreeMap, +} + +impl InMemoryAgentStore { + /// Creates an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates an empty store whose writes advance `changes`. + #[must_use] + pub fn with_changes(changes: Changes) -> Self { + Self { + changes, + ..Self::default() + } + } + + fn changed(&self, result: Result) -> Result { + if result.is_ok() { + self.changes.bump(); + } + result + } +} + +impl AgentStore for InMemoryAgentStore { + fn get(&self, id: AgentId) -> LocalFuture<'_, Result> { + Box::pin(async move { + let state = self.state.borrow(); + let record = state.records.get(&id).ok_or(Error::NotFound)?; + (state.active_names.get(&record.agent.metadata.name) == Some(&id)) + .then(|| record.clone()) + .ok_or(Error::NotFound) + }) + } + + fn get_by_name<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + let state = self.state.borrow(); + let id = state.active_names.get(name).ok_or(Error::NotFound)?; + state.records.get(id).cloned().ok_or(Error::NotFound) + }) + } + + fn list(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let state = self.state.borrow(); + Ok(state + .active_names + .values() + .filter_map(|id| state.records.get(id)) + .cloned() + .collect()) + }) + } + + fn put(&self, mut record: AgentRecord, expected_generation: u64) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let result = (|| { + record.agent.status.progress = None; + record.agent.status.provenance = None; + let id = record.id; + let name = record.agent.metadata.name.clone(); + let mut state = self.state.borrow_mut(); + if expected_generation == 0 { + if state.active_names.contains_key(&name) || state.records.contains_key(&id) { + return Err(Error::Conflict); + } + state.active_names.insert(name, id); + state.records.insert(id, record); + return Ok(()); + } + + let active_id = state.active_names.get(&name).copied().ok_or(Error::Conflict)?; + if active_id != id { + return Err(Error::Conflict); + } + let current = state.records.get_mut(&id).ok_or(Error::Conflict)?; + if current.agent.metadata.generation != expected_generation + || current.agent.metadata.deletion_timestamp.is_some() + { + return Err(Error::Conflict); + } + *current = record; + Ok(()) + })(); + self.changed(result) + }) + } + + fn update_status( + &self, + id: AgentId, + generation: u64, + mut status: Status, + ) -> LocalFuture<'_, Result> { + Box::pin(async move { + let result = (|| { + status.progress = None; + status.provenance = None; + let mut state = self.state.borrow_mut(); + let name = state + .records + .get(&id) + .map(|record| record.agent.metadata.name.clone()) + .ok_or(Error::NotFound)?; + if state.active_names.get(&name) != Some(&id) { + return Err(Error::NotFound); + } + let record = state.records.get_mut(&id).ok_or(Error::NotFound)?; + if record.agent.metadata.generation != generation { + return Err(Error::Conflict); + } + status.stamp_transitions(&record.agent.status, OffsetDateTime::now_utc()); + record.agent.status = status.clone(); + Ok(status) + })(); + self.changed(result) + }) + } + + fn mark_deleting<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + let result = (|| { + let mut state = self.state.borrow_mut(); + let id = state.active_names.get(name).copied().ok_or(Error::NotFound)?; + let record = state.records.get_mut(&id).ok_or(Error::NotFound)?; + if record.agent.metadata.deletion_timestamp.is_none() { + record.agent.metadata.deletion_timestamp = Some(OffsetDateTime::now_utc()); + } + Ok(record.clone()) + })(); + self.changed(result) + }) + } + + fn finalize_deletion(&self, id: AgentId, generation: u64) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let result = (|| { + let mut state = self.state.borrow_mut(); + let record = state.records.get_mut(&id).ok_or(Error::NotFound)?; + if record.agent.metadata.generation != generation || record.agent.metadata.deletion_timestamp.is_none() + { + return Err(Error::Conflict); + } + let name = record.agent.metadata.name.clone(); + if state.active_names.get(&name) != Some(&id) { + return Err(Error::NotFound); + } + state.active_names.remove(&name); + Ok(()) + })(); + self.changed(result) + }) + } +} diff --git a/agentctl/src/control_plane/mod.rs b/agentctl/src/control_plane/mod.rs new file mode 100644 index 0000000..d76d735 --- /dev/null +++ b/agentctl/src/control_plane/mod.rs @@ -0,0 +1,48 @@ +//! Declarative Agent storage, reconciliation, and continuous repair. + +mod controller; +mod convergence; +pub mod memory; +mod reconciler; +mod resource; +mod service; + +use std::rc::Rc; + +use ::sandbox::LocalFuture; + +use crate::{Error, Status}; + +pub use controller::{Controller, ErrorHandler, Wakeup}; +pub use convergence::{Convergence, WaitPolicy}; +pub use reconciler::{Reconciler, SessionNotifier}; +pub use resource::{AgentId, AgentRecord, ENV_FILE}; +pub use service::{ApplyRequest, ControlPlane, Notifier}; + +/// Separates desired-state writes from reconciler status writes using generation checks. +pub trait AgentStore { + /// Gets an active Agent record by immutable identity. + fn get(&self, id: AgentId) -> LocalFuture<'_, Result>; + + /// Gets an active Agent record by its user-facing name. + fn get_by_name<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; + + /// Lists an independent snapshot of every record. + fn list(&self) -> LocalFuture<'_, Result, Error>>; + + /// Creates or replaces desired state if the stored generation still matches. + fn put(&self, record: AgentRecord, expected_generation: u64) -> LocalFuture<'_, Result<(), Error>>; + + /// Replaces observed state if the reconciled generation is still current, + /// stamping condition transition times against the stored status, and + /// returns the status as stored. + fn update_status(&self, id: AgentId, generation: u64, status: Status) -> LocalFuture<'_, Result>; + + /// Atomically records the first deletion request. + fn mark_deleting<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; + + /// Finalizes a soft-deleted record if its desired generation has not changed. + fn finalize_deletion(&self, id: AgentId, generation: u64) -> LocalFuture<'_, Result<(), Error>>; +} + +pub(crate) type SharedAgentStore = Rc; diff --git a/agentctl/src/control_plane/reconciler.rs b/agentctl/src/control_plane/reconciler.rs new file mode 100644 index 0000000..405b5e8 --- /dev/null +++ b/agentctl/src/control_plane/reconciler.rs @@ -0,0 +1,527 @@ +use std::rc::Rc; + +use crate::{Condition, ConditionStatus, Error, FailureKind, ReconcileFailure, Status}; + +use super::{AgentRecord, SharedAgentStore}; +use crate::progress::{ProvisioningState, SandboxObserver}; +use crate::sandbox::responsiveness::stall_detail; + +/// Receives low-latency hints when an Agent transition affects its Sessions. +pub trait SessionNotifier { + /// Wakes every durable Session owned by the Agent incarnation. + fn notify(&self, id: crate::AgentId); + + /// Reconciles every durable Session owned by the Agent incarnation and + /// completes once each has finished a pass that began after this call. + fn settle(&self, id: crate::AgentId) -> ::sandbox::LocalFuture<'_, ()>; +} + +/// Converges one stored Agent generation without owning an API request. +pub struct Reconciler { + store: SharedAgentStore, + sandboxes: Rc, + sessions: Option>, + ssh: Option>, + vnc: Option>, + provisioning: ProvisioningState, +} + +impl Reconciler { + /// Creates an Agent reconciler over persistent resources and runtime-resolved Sandboxes. + #[must_use] + pub fn new( + store: SharedAgentStore, + sandboxes: Rc, + provisioning: ProvisioningState, + ) -> Self { + Self { + store, + sandboxes, + sessions: None, + ssh: None, + vnc: None, + provisioning, + } + } + + /// Reconciles declared SSH access after the Sandbox is set up. + #[must_use] + pub fn with_ssh_access(mut self, ssh: Rc) -> Self { + self.ssh = Some(ssh); + self + } + + /// Reconciles declared VNC access after the Sandbox is set up. + #[must_use] + pub fn with_vnc_access(mut self, vnc: Rc) -> Self { + self.vnc = Some(vnc); + self + } + + /// Wakes dependent Sessions when readiness or Sandbox identity changes. + #[must_use] + pub fn with_session_notifier(mut self, sessions: Rc) -> Self { + self.sessions = Some(sessions); + self + } + + /// Converges the latest generation of one Agent and records what it observed. + /// + /// # Errors + /// + /// Returns an error when storage or sandbox lifecycle convergence fails. + pub async fn reconcile(&self, id: crate::AgentId) -> Result<(), Error> { + let mut record = match self.store.get(id).await { + Ok(record) => record, + Err(Error::NotFound) => return Ok(()), + Err(error) => return Err(error), + }; + if record.agent.metadata.deletion_timestamp.is_some() { + return self.release(&record).await; + } + if record.agent.spec.is_stopped() { + return self.stop(&record).await; + } + + if record.agent.status.sandbox.is_none() { + let provider = match self.sandboxes.resolve(&record).await { + Ok(provider) => provider, + Err(error) => { + self.record_failure(&record, "ProviderResolutionFailed", &ReconcileFailure::classify(&error)) + .await?; + return Err(error); + } + }; + let status = Status::observed( + record.agent.metadata.generation, + Some(crate::sandbox::Assignment::Selected { provider }), + vec![condition( + Condition::READY, + ConditionStatus::False, + "ProviderSelected", + "Sandbox provisioning has not completed", + )], + ); + record.agent.status = self.update_status(&record, status, None).await?; + } + + if recorded_by_stop(&record.agent.status) { + let starting = not_ready(&record, Condition::REASON_STARTING, ""); + record.agent.status = self.update_status(&record, starting, None).await?; + } + + let status = &record.agent.status; + let observer = if status.is_ready() && status.observed_generation == record.agent.metadata.generation { + SandboxObserver::resync(record.id, self.provisioning.clone()) + } else { + SandboxObserver::new(record.id, self.provisioning.clone()) + }; + let ensured = match self.sandboxes.ensure(&record, observer.reporter()).await { + Ok(ensured) => ensured, + Err(error @ Error::SandboxUnresponsive(_)) => { + let assignment = record.agent.status.sandbox.clone(); + return self.record_unresponsive(&record, assignment, &observer, error).await; + } + Err(error) => return self.record_ensure_failure(&record, &observer, error).await, + }; + + let provider = record + .agent + .status + .sandbox + .as_ref() + .ok_or_else(|| Error::Database("persisted Sandbox Provider assignment disappeared".into()))? + .provider() + .clone(); + + let assignment = crate::sandbox::Assignment::Materialized { + provider, + id: ensured.id, + harnesses: ensured.harnesses.clone(), + }; + let mut conditions = vec![condition( + Condition::SANDBOX_READY, + ConditionStatus::True, + "SandboxRunning", + "", + )]; + self.reconcile_declared_access(&record, &ensured.sandbox, &assignment, &mut conditions, &observer) + .await?; + conditions.insert( + 1, + responsive_condition(self.sandboxes.reports_heartbeat(&ensured.sandbox.snapshot().id)), + ); + conditions.push(condition(Condition::READY, ConditionStatus::True, "SandboxReady", "")); + let status = Status::observed(record.agent.metadata.generation, Some(assignment), conditions); + // As on failure, readiness is stored before followers see the pass end. + self.update_status(&record, status, None).await?; + observer.succeeded(); + if ensured.runtime_restarted { + self.notify_sessions(record.id); + } + Ok(()) + } + + /// Reconciles every access capability the platform offers, in order. + async fn reconcile_declared_access( + &self, + record: &AgentRecord, + sandbox: &::sandbox::SandboxHandle, + assignment: &crate::sandbox::Assignment, + conditions: &mut Vec, + observer: &SandboxObserver, + ) -> Result<(), Error> { + let id = &sandbox.snapshot().id; + if let Some(ssh) = &self.ssh { + let pass = self.sandboxes.guard_guest(record, id, ssh.reconcile(record, sandbox)); + self.reconcile_access(SSH, pass, record, assignment, conditions, observer) + .await?; + } + if let Some(vnc) = &self.vnc { + let pass = self.sandboxes.guard_guest(record, id, vnc.reconcile(record, sandbox)); + self.reconcile_access(VNC, pass, record, assignment, conditions, observer) + .await?; + } + Ok(()) + } + + /// Runs one access capability's pass and appends its condition. A failure + /// is recorded as the Agent's `Ready=False` before it is returned. + async fn reconcile_access( + &self, + kind: AccessKind, + pass: impl Future>, + record: &AgentRecord, + assignment: &crate::sandbox::Assignment, + conditions: &mut Vec, + observer: &SandboxObserver, + ) -> Result<(), Error> { + let phase = if (kind.declared)(&record.agent.spec) { + Some(observer.reporter().start_phase(kind.phase).await) + } else { + None + }; + match pass.await { + Ok(true) => { + if let Some(phase) = phase { + phase.complete().await; + } + conditions.push(condition(kind.condition, ConditionStatus::True, kind.ready_reason, "")); + Ok(()) + } + Ok(false) => Ok(()), + Err(error @ Error::SandboxUnresponsive(_)) => { + conditions.clear(); + self.record_unresponsive(record, Some(assignment.clone()), observer, error) + .await + } + Err(error) => { + let failure = ReconcileFailure::classify(&error); + conditions.push(condition( + kind.condition, + ConditionStatus::False, + "ReconcileFailed", + &failure.message, + )); + conditions.push(condition( + Condition::READY, + ConditionStatus::False, + kind.failed_reason, + &failure.message, + )); + let status = Status::observed( + record.agent.metadata.generation, + Some(assignment.clone()), + std::mem::take(conditions), + ); + let stored = self.update_status(record, status, Some(failure.kind)).await; + observer.failed(&failure); + stored?; + Err(error) + } + } + } + + /// Records a failed Sandbox ensure or setup as the Agent's `Ready=False` and returns `error`. + async fn record_ensure_failure( + &self, + record: &AgentRecord, + observer: &SandboxObserver, + error: Error, + ) -> Result<(), Error> { + let failure = ReconcileFailure::classify(&error); + let message = error.to_string(); + let status = Status::observed( + record.agent.metadata.generation, + record.agent.status.sandbox.clone(), + vec![ + condition( + Condition::READY, + ConditionStatus::False, + "SandboxReconcileFailed", + &message, + ), + condition( + Condition::SANDBOX_READY, + ConditionStatus::False, + "ReconcileFailed", + &message, + ), + ], + ); + // The failure class is stored before followers see the pass fail. + let stored = self.update_status(record, status, Some(failure.kind)).await; + observer.failed(&failure); + stored?; + Err(error) + } + + /// Records that the Sandbox's guest stopped responding and returns `error`. + /// + /// A stall is only found in work after the Sandbox started, so the Sandbox + /// itself is running; only the guest inside it has stopped. + async fn record_unresponsive( + &self, + record: &AgentRecord, + assignment: Option, + observer: &SandboxObserver, + error: Error, + ) -> Result<(), Error> { + let failure = ReconcileFailure::classify(&error); + let mut conditions = vec![condition( + Condition::SANDBOX_READY, + ConditionStatus::True, + "SandboxRunning", + "", + )]; + conditions.push(condition( + Condition::SANDBOX_RESPONSIVE, + ConditionStatus::False, + "HeartbeatStale", + &stall_detail(), + )); + conditions.push(condition( + Condition::READY, + ConditionStatus::False, + "SandboxUnresponsive", + &failure.message, + )); + let status = Status::observed(record.agent.metadata.generation, assignment, conditions); + let stored = self.update_status(record, status, Some(failure.kind)).await; + observer.failed(&failure); + stored?; + Err(error) + } + + /// Stops the Sandbox of an Agent whose run state is Stopped and records it + /// as stopped. The Sandbox keeps its identity, storage and assignment, so a + /// start boots the same disk. Nothing reaches into the guest, so a guest + /// that stopped responding cannot hold the stop up. + async fn stop(&self, record: &AgentRecord) -> Result<(), Error> { + let current = record.agent.status.observed_generation == record.agent.metadata.generation; + let already_stopped = current && record.agent.status.is_stopped(); + if !(current && recorded_by_stop(&record.agent.status)) { + // Not Ready before the VM goes away, so Sessions are told and go Idle first. + let stopping = not_ready(record, Condition::REASON_STOPPING, ""); + self.update_status(record, stopping, None).await?; + } + let observer = if already_stopped { + SandboxObserver::resync(record.id, self.provisioning.clone()) + } else { + SandboxObserver::new(record.id, self.provisioning.clone()) + }; + let phase = observer.reporter().start_phase(crate::progress::SANDBOX_STOP).await; + if let Err(error) = self.sandboxes.stop(record).await { + let failure = ReconcileFailure::classify(&error); + let stored = self.record_failure(record, Condition::REASON_STOPPING, &failure).await; + observer.failed(&failure); + stored?; + return Err(error); + } + phase.complete().await; + let hint = format!("run `agentctl start agent/{}` to start it", record.agent.metadata.name); + let stopped = Status::observed( + record.agent.metadata.generation, + record.agent.status.sandbox.clone(), + vec![ + condition( + Condition::SANDBOX_READY, + ConditionStatus::False, + Condition::REASON_STOPPED, + "", + ), + condition( + Condition::READY, + ConditionStatus::False, + Condition::REASON_STOPPED, + &hint, + ), + ], + ); + self.update_status(record, stopped, None).await?; + if !already_stopped && let Some(sessions) = &self.sessions { + // The next pass, such as a start, waits until every Session has seen + // the stop, so a Session pass that began before it cannot relaunch + // its harness in the started VM. + sessions.settle(record.id).await; + } + observer.succeeded(); + Ok(()) + } + + async fn release(&self, record: &AgentRecord) -> Result<(), Error> { + self.sandboxes.release(record).await?; + if let Some(ssh) = &self.ssh { + ssh.remove(record).await?; + } + if let Some(vnc) = &self.vnc { + vnc.forget(record.id); + } + self.notify_sessions(record.id); + self.store + .finalize_deletion(record.id, record.agent.metadata.generation) + .await?; + self.provisioning.forget(record.id); + Ok(()) + } + + async fn record_failure( + &self, + record: &AgentRecord, + reason: &str, + failure: &ReconcileFailure, + ) -> Result<(), Error> { + self.update_status(record, not_ready(record, reason, &failure.message), Some(failure.kind)) + .await + .map(drop) + } + + /// Records the pass's observed status and failure class and returns it as stored. + async fn update_status( + &self, + record: &AgentRecord, + mut status: Status, + failure: Option, + ) -> Result { + status.failure = failure; + // A resync that observes what is already stored writes nothing, so it + // advances no revision and wakes no watcher. + let stored = Status { + progress: None, + provenance: None, + ..record.agent.status.clone() + }; + let mut unchanged = status.clone(); + unchanged.stamp_transitions(&stored, time::OffsetDateTime::UNIX_EPOCH); + if unchanged == stored { + return Ok(stored); + } + let notify = session_relevant_transition(&record.agent.status, &status); + let stored = self + .store + .update_status(record.id, record.agent.metadata.generation, status) + .await?; + if notify { + self.notify_sessions(record.id); + } + Ok(stored) + } + + fn notify_sessions(&self, id: crate::AgentId) { + if let Some(sessions) = &self.sessions { + sessions.notify(id); + } + } +} + +impl crate::controller::Reconcile for Reconciler { + fn reconcile(&self, id: crate::AgentId) -> ::sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { Self::reconcile(self, id).await }) + } +} + +/// How one access capability reports its pass in progress and conditions. +struct AccessKind { + declared: fn(&crate::Spec) -> bool, + phase: ::sandbox::Phase, + condition: &'static str, + ready_reason: &'static str, + failed_reason: &'static str, +} + +const SSH: AccessKind = AccessKind { + declared: crate::Spec::ssh_access, + phase: crate::progress::SSH_ACCESS, + condition: Condition::SSH_READY, + ready_reason: "ServerRunning", + failed_reason: "SshAccessFailed", +}; + +const VNC: AccessKind = AccessKind { + declared: crate::Spec::vnc_access, + phase: crate::progress::VNC_ACCESS, + condition: Condition::VNC_READY, + ready_reason: "BridgeListening", + failed_reason: "VncAccessFailed", +}; + +fn condition(kind: &str, status: ConditionStatus, reason: &str, message: &str) -> Condition { + Condition { + kind: kind.into(), + status, + reason: reason.into(), + message: message.into(), + last_transition_time: None, + } +} + +/// Reports the guest's heartbeat after a pass whose guest work finished. A +/// Sandbox that reports no heartbeat gives no evidence either way. +fn responsive_condition(reports_heartbeat: bool) -> Condition { + if reports_heartbeat { + condition( + Condition::SANDBOX_RESPONSIVE, + ConditionStatus::True, + "HeartbeatAdvancing", + "", + ) + } else { + condition( + Condition::SANDBOX_RESPONSIVE, + ConditionStatus::Unknown, + "HeartbeatNotObserved", + "", + ) + } +} + +/// A status with only `Ready=False` for `reason`, keeping the record's Sandbox assignment. +fn not_ready(record: &AgentRecord, reason: &str, message: &str) -> Status { + Status::observed( + record.agent.metadata.generation, + record.agent.status.sandbox.clone(), + vec![condition(Condition::READY, ConditionStatus::False, reason, message)], + ) +} + +/// Whether `status` was recorded by a pass that stopped, or tried to stop, the Sandbox. +fn recorded_by_stop(status: &Status) -> bool { + status + .ready_condition() + .is_some_and(|ready| ready.reason == Condition::REASON_STOPPED || ready.reason == Condition::REASON_STOPPING) +} + +fn session_relevant_transition(previous: &Status, current: &Status) -> bool { + previous.is_ready() != current.is_ready() + || recorded_by_stop(previous) != recorded_by_stop(current) + || previous.sandbox.as_ref().and_then(crate::sandbox::Assignment::id) + != current.sandbox.as_ref().and_then(crate::sandbox::Assignment::id) + || previous + .sandbox + .as_ref() + .and_then(crate::sandbox::Assignment::installed_harnesses) + != current + .sandbox + .as_ref() + .and_then(crate::sandbox::Assignment::installed_harnesses) +} diff --git a/agentctl/src/control_plane/resource.rs b/agentctl/src/control_plane/resource.rs new file mode 100644 index 0000000..4d389da --- /dev/null +++ b/agentctl/src/control_plane/resource.rs @@ -0,0 +1,99 @@ +//! Internal identity and persisted representation of an Agent resource. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Agent, Error}; + +/// Immutable identity of one Agent incarnation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct AgentId(Uuid); + +impl AgentId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the underlying UUID. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for AgentId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for AgentId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Stored desired and observed Agent state plus local source provenance. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct AgentRecord { + /// Immutable identity of this Agent incarnation. + pub id: AgentId, + /// Absolute directory against which manifest-relative sources are resolved. + pub source_directory: std::path::PathBuf, + /// Absolute path of the manifest last applied, when the client reported it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_path: Option, + /// Absolute path of the environment file, when it is not [`ENV_FILE`] beside the manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_file: Option, + /// Desired state and most recently observed status. + pub agent: Agent, +} + +/// Default environment file name, resolved in the source directory. +pub const ENV_FILE: &str = ".env"; + +impl AgentRecord { + /// Returns the host file that supplies declared manifest values. + #[must_use] + pub fn env_file_path(&self) -> std::path::PathBuf { + self.env_file + .clone() + .unwrap_or_else(|| self.source_directory.join(ENV_FILE)) + } + + /// Derives the Provider-independent Sandbox name for this Agent incarnation. + /// + /// # Errors + /// + /// Returns an error only if the stable Agent identity cannot form a valid Sandbox name. + pub fn sandbox_name(&self) -> Result<::sandbox::SandboxName, Error> { + ::sandbox::SandboxName::new(format!("agent-{}", self.id)) + .map_err(|error| Error::Database(format!("Agent ID cannot identify its Sandbox: {error}"))) + } + + /// Refuses work that needs the Sandbox running while the Agent is stopped. + /// + /// # Errors + /// + /// Returns [`Error::Stopped`] when the Agent's run state is Stopped. + pub fn reject_stopped(&self) -> Result<(), Error> { + if self.agent.spec.is_stopped() { + return Err(Error::Stopped(self.agent.metadata.name.clone())); + } + Ok(()) + } + + /// Derives the hostname the Sandbox reports: the Agent name. + /// + /// # Errors + /// + /// Returns an error only if the validated Agent name cannot form a hostname. + pub fn sandbox_hostname(&self) -> Result<::sandbox::Hostname, Error> { + ::sandbox::Hostname::new(self.agent.metadata.name.clone()) + .map_err(|error| Error::Database(format!("Agent name cannot be its Sandbox hostname: {error}"))) + } +} diff --git a/agentctl/src/control_plane/service.rs b/agentctl/src/control_plane/service.rs new file mode 100644 index 0000000..02eda51 --- /dev/null +++ b/agentctl/src/control_plane/service.rs @@ -0,0 +1,644 @@ +use std::{path::PathBuf, rc::Rc}; + +use ignore::WalkBuilder; + +use crate::{Agent, AgentId, Error, MountSpec, progress::ProvisioningState}; + +use super::{AgentRecord, SharedAgentStore, Wakeup}; + +/// Desired state supplied by a local API client. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ApplyRequest { + /// Absolute directory against which local manifest sources are resolved. + pub source_directory: PathBuf, + /// Absolute path of the manifest being applied, recorded for discovery. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_path: Option, + /// Absolute path of the file supplying declared manifest values. Defaults to `.env` beside the + /// manifest; omitted on an update keeps the recorded path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_file: Option, + /// Fail instead of updating when the name already identifies an Agent. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub create_only: bool, + /// Agent manifest to store. + pub agent: Agent, +} + +/// Wakes reconciliation after desired state changes. +pub trait Notifier { + /// Schedules reconciliation without blocking the API request. + fn notify(&self, id: crate::AgentId); +} + +impl Notifier for Wakeup { + fn notify(&self, id: crate::AgentId) { + self.notify(id); + } +} + +/// Agent Control Plane facade for desired-state operations. +pub struct ControlPlane { + store: SharedAgentStore, + notifier: Rc, + provisioning: ProvisioningState, +} + +impl ControlPlane { + /// Creates an Agent Control Plane facade. + #[must_use] + pub fn new(store: SharedAgentStore, notifier: Rc) -> Self { + Self { + store, + notifier, + provisioning: ProvisioningState::default(), + } + } + + /// Projects the reconciler's in-memory provisioning state onto returned Agents. + #[must_use] + pub fn with_provisioning(mut self, provisioning: ProvisioningState) -> Self { + self.provisioning = provisioning; + self + } + + /// Stores desired state and returns without waiting for reconciliation. + /// + /// # Errors + /// + /// Returns an error when the request is invalid, changes an immutable field, conflicts with deletion, + /// or cannot be stored. + pub async fn apply(&self, request: ApplyRequest) -> Result { + validate_request_paths(&request)?; + + let mut desired = request.agent; + desired.clear_managed_fields(); + resolve_mount_sources(&mut desired, &request.source_directory).await?; + desired.validate()?; + reject_dot_env_in_bind_mounts(&desired).await?; + let run_state = desired.spec.run_state; + + loop { + let result = match self.store.get_by_name(&desired.metadata.name).await { + Ok(current) => { + if request.create_only { + return Err(Error::Invalid(format!( + "an Agent named {:?} already exists", + desired.metadata.name + ))); + } + if current.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if current.source_directory != request.source_directory { + return Err(Error::Immutable("sourceDirectory")); + } + validate_immutable_fields(¤t, &desired)?; + desired.spec.set_run_state(applied_run_state(run_state, Some(¤t))); + let manifest_path = request.manifest_path.clone().or_else(|| current.manifest_path.clone()); + let env_file = request.env_file.clone().or_else(|| current.env_file.clone()); + self.reject_exposed_secret_files( + current.id, + &request.source_directory, + env_file.as_deref(), + &desired, + ) + .await?; + if current.agent.spec == desired.spec + && current.manifest_path == manifest_path + && current.env_file == env_file + { + self.notifier.notify(current.id); + return Ok(self.resource(current)); + } + + let expected_generation = current.agent.metadata.generation; + desired.metadata.generation = expected_generation + 1; + desired.status = current.agent.status; + self.store + .put( + AgentRecord { + id: current.id, + source_directory: request.source_directory.clone(), + manifest_path: manifest_path.clone(), + env_file: env_file.clone(), + agent: desired.clone(), + }, + expected_generation, + ) + .await + .map(|()| (current.id, manifest_path, env_file)) + } + Err(Error::NotFound) => { + let id = AgentId::generate(); + desired.metadata.generation = 1; + desired.spec.set_run_state(applied_run_state(run_state, None)); + self.reject_exposed_secret_files( + id, + &request.source_directory, + request.env_file.as_deref(), + &desired, + ) + .await?; + self.store + .put( + AgentRecord { + id, + source_directory: request.source_directory.clone(), + manifest_path: request.manifest_path.clone(), + env_file: request.env_file.clone(), + agent: desired.clone(), + }, + 0, + ) + .await + .map(|()| (id, request.manifest_path.clone(), request.env_file.clone())) + } + Err(error) => return Err(error), + }; + + match result { + Err(Error::Conflict) => {} + Err(error) => return Err(error), + Ok((id, manifest_path, env_file)) => { + self.notifier.notify(id); + desired.status.provenance = Some(crate::Provenance { + source_directory: request.source_directory, + manifest_path, + env_file, + }); + return Ok(desired); + } + } + } + } + + /// Rejects desired state that would expose a selected secret file inside a Sandbox. + /// + /// Secret files hold the real values that mediation exists to keep out of Sandboxes. A bind + /// mount whose source contains this Agent's selected non-default secret file or another active + /// Agent's selected secret file would hand those values to the guest, so the combination is + /// refused at apply time. Default `.env` files are covered by the bind-source scan above. Bind + /// mount sources are canonical by this point. + async fn reject_exposed_secret_files( + &self, + id: AgentId, + source_directory: &std::path::Path, + env_file: Option<&std::path::Path>, + desired: &Agent, + ) -> Result<(), Error> { + let mut secret_files = Vec::new(); + if !desired.spec.secrets.is_empty() { + let path = env_file.map_or_else(|| source_directory.join(super::resource::ENV_FILE), PathBuf::from); + if env_file.is_some() || tokio::fs::try_exists(&path).await? { + secret_files.push((desired.metadata.name.clone(), canonical_secret_file(&path).await)); + } + } + let mut mounts = bind_mount_sources(desired); + for other in self.store.list().await? { + if other.id == id || other.agent.metadata.deletion_timestamp.is_some() { + continue; + } + if !other.agent.spec.secrets.is_empty() { + let path = other.env_file_path(); + if other.env_file.is_some() || tokio::fs::try_exists(&path).await? { + secret_files.push((other.agent.metadata.name.clone(), canonical_secret_file(&path).await)); + } + } + if !desired.spec.secrets.is_empty() { + mounts.extend( + bind_mount_sources(&other.agent) + .into_iter() + .map(|(_, source)| (format!("Agent {:?}", other.agent.metadata.name), source)), + ); + } + } + for (owner, secret_file) in &secret_files { + for (mount, source) in &mounts { + if secret_file.starts_with(source) { + return Err(Error::Invalid(format!( + "{mount} bind-mounts {} which contains the secret file {} of Agent {owner:?}; \ + the Sandbox would see its real values. Keep secret files outside mounted directories, \ + for example with `agentctl apply --env-file`", + source.display(), + secret_file.display(), + ))); + } + } + } + Ok(()) + } + + /// Gets desired and most recently observed state. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist or storage fails. + pub async fn get(&self, name: &str) -> Result { + self.store.get_by_name(name).await.map(|record| self.resource(record)) + } + + /// Lists every active Agent ordered by name. + /// + /// # Errors + /// + /// Returns an error when storage cannot be read. + pub async fn list(&self) -> Result, Error> { + self.store + .list() + .await + .map(|records| records.into_iter().map(|record| self.resource(record)).collect()) + } + + /// Reads an Agent's stored status and the complete progress of its latest + /// pass. When `output` names that pass, only later output is included. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist or storage fails. + pub async fn progress( + &self, + name: &str, + output: Option, + ) -> Result<(crate::Status, Option), Error> { + let record = self.store.get_by_name(name).await?; + let provisioning = self.provisioning.get(record.id).map(|mut provisioning| { + if let Some(output) = output.filter(|output| output.pass == provisioning.pass) { + provisioning.progress = provisioning.progress.output_from(output.sequence); + } + provisioning + }); + Ok((record.agent.status, provisioning)) + } + + /// Resolves the closest Agent source directory containing `directory`. + /// + /// # Errors + /// + /// Returns an error when no Agent matches, multiple Agents share the closest + /// source directory, or storage cannot be read. + pub async fn resolve_directory(&self, directory: &std::path::Path) -> Result { + self.resolve_directory_variant(directory, None).await + } + + /// Resolves the closest Agent associated with `directory`, optionally by + /// the variant encoded in its recorded leaf manifest filename. + /// + /// When several closest Agents tie without an explicit variant, exactly one + /// Agent originating from the default `agent.yaml` manifest is preferred. + /// + /// # Errors + /// + /// Returns an error when no Agent matches, selection remains ambiguous, or + /// storage cannot be read. + pub async fn resolve_directory_variant( + &self, + directory: &std::path::Path, + variant: Option<&crate::AgentVariantName>, + ) -> Result { + if !directory.is_absolute() { + return Err(Error::Invalid("directory must be absolute".into())); + } + let directory = canonical_or_original(directory).await; + let mut matches = Vec::new(); + for record in self.store.list().await? { + let mut closest_depth: Option = None; + for source in association_directories(&record) { + let source = canonical_or_original(source).await; + if directory.starts_with(&source) { + closest_depth = Some(closest_depth.unwrap_or_default().max(source.components().count())); + } + } + if let Some(depth) = closest_depth { + matches.push((record, depth)); + } + } + let Some(depth) = matches.iter().map(|(_, depth)| *depth).max() else { + return Err(Error::NotFound); + }; + matches.retain(|(_, candidate_depth)| *candidate_depth == depth); + if let Some(variant) = variant { + let filename = variant.filename(); + matches.retain(|(record, _)| { + record + .manifest_path + .as_deref() + .and_then(std::path::Path::file_name) + .is_some_and(|name| name == filename.as_str()) + }); + if matches.is_empty() { + return Err(Error::Invalid(format!( + "no Agent associated with this directory was applied from {filename}" + ))); + } + } else if matches.len() > 1 { + let defaults = matches + .iter() + .enumerate() + .filter_map(|(index, (record, _))| { + let filename = record + .manifest_path + .as_deref() + .and_then(std::path::Path::file_name) + .or_else(|| Some(std::ffi::OsStr::new(crate::manifest::MANIFEST_FILE))); + (filename == Some(std::ffi::OsStr::new(crate::manifest::MANIFEST_FILE))).then_some(index) + }) + .collect::>(); + if let [index] = defaults.as_slice() { + return Ok(self.resource(matches.swap_remove(*index).0)); + } + } + if matches.len() != 1 { + let mut names = matches + .iter() + .map(|(record, _)| record.agent.metadata.name.clone()) + .collect::>(); + names.sort(); + return Err(Error::Invalid(format!( + "multiple Agents were applied from this directory ({}); specify --agent or --variant", + names.join(", ") + ))); + } + matches + .pop() + .map(|(record, _)| self.resource(record)) + .ok_or(Error::NotFound) + } + + /// Records whether an Agent's Sandbox runs and returns the Agent as stored, + /// without waiting for the reconciler to stop or start it. A change is a new + /// generation; setting the run state the Agent already has changes nothing. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist, is being deleted, or + /// cannot be stored. + pub async fn set_run_state(&self, name: &str, state: crate::RunState) -> Result { + loop { + let mut record = self.store.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if record.agent.spec.run_state() != state { + let expected_generation = record.agent.metadata.generation; + record.agent.spec.set_run_state(state); + record.agent.metadata.generation = expected_generation + 1; + match self.store.put(record.clone(), expected_generation).await { + Ok(()) => {} + Err(Error::Conflict) => continue, + Err(error) => return Err(error), + } + } + self.notifier.notify(record.id); + return Ok(self.resource(record)); + } + } + + /// Marks an Agent for asynchronous release. Repeated deletion is safe. + /// + /// # Errors + /// + /// Returns an error when the deletion marker cannot be stored. + pub async fn delete(&self, name: &str) -> Result<(), Error> { + match self.store.mark_deleting(name).await { + Ok(record) => { + self.notifier.notify(record.id); + Ok(()) + } + Err(Error::NotFound) => Ok(()), + Err(error) => Err(error), + } + } +} + +/// Rejects any bind source containing a `.env` file, case-insensitively and regardless of ignores. +/// +/// Filesystem traversal is blocking and may cover a whole checkout, so it stays off the local +/// async runtime. Directories named `.env` are allowed. Symbolic links are not followed, but a +/// link itself named `.env` is rejected. +async fn reject_dot_env_in_bind_mounts(agent: &Agent) -> Result<(), Error> { + let mounts = bind_mount_sources(agent); + tokio::task::spawn_blocking(move || { + for (field, source) in mounts { + for result in WalkBuilder::new(&source) + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false) + .follow_links(false) + .build() + { + let entry = result.map_err(|error| { + Error::Invalid(format!( + "cannot inspect {field}.source {} for .env files: {error}", + source.display() + )) + })?; + let is_directory = entry.file_type().is_some_and(|kind| kind.is_dir()); + let is_env_file = entry + .file_name() + .as_encoded_bytes() + .eq_ignore_ascii_case(super::resource::ENV_FILE.as_bytes()); + if !is_directory && is_env_file { + return Err(Error::Invalid(format!( + "{field} bind-mounts {} which contains .env at {}; the Sandbox would see its real values. \ + Remove the file or keep it outside mounted directories", + source.display(), + entry.path().display(), + ))); + } + } + } + Ok(()) + }) + .await + .map_err(|error| Error::Daemon(format!("bind-mount .env inspection failed: {error}")))? +} + +impl ControlPlane { + /// Converts a stored record to its API representation, projecting + /// provisioning progress and provenance into status. + fn resource(&self, record: AgentRecord) -> Agent { + let mut agent = record.agent; + agent.status.progress = self.provisioning.summary(record.id); + agent.status.provenance = Some(crate::Provenance { + source_directory: record.source_directory, + manifest_path: record.manifest_path, + env_file: record.env_file, + }); + agent + } +} + +/// The run state an apply stores: the manifest's, else the Agent's own, so +/// applying a manifest that omits it never stops or starts the Agent. +fn applied_run_state(requested: Option, current: Option<&AgentRecord>) -> crate::RunState { + requested + .or_else(|| current.map(|current| current.agent.spec.run_state())) + .unwrap_or_default() +} + +fn validate_immutable_fields(current: &AgentRecord, desired: &Agent) -> Result<(), Error> { + if current.agent.spec.sandbox.image != desired.spec.sandbox.image { + return Err(Error::Immutable("spec.sandbox.image")); + } + if current.agent.spec.sandbox.platform != desired.spec.sandbox.platform { + return Err(Error::Immutable("spec.sandbox.platform")); + } + if current.agent.spec.sandbox.init_system != desired.spec.sandbox.init_system { + return Err(Error::Immutable("spec.sandbox.initSystem")); + } + if current.agent.spec.sandbox.resources.root_filesystem().mode() + != desired.spec.sandbox.resources.root_filesystem().mode() + { + return Err(Error::Immutable("spec.sandbox.resources.rootFilesystem.mode")); + } + if current.agent.spec.sandbox.mounts != desired.spec.sandbox.mounts { + return Err(Error::Immutable("spec.sandbox.mounts")); + } + if current.agent.spec.home != desired.spec.home { + return Err(Error::Immutable("spec.home")); + } + if current.agent.spec.instructions != desired.spec.instructions { + return Err(Error::Immutable("spec.instructions")); + } + if current.agent.spec.skills != desired.spec.skills { + return Err(Error::Immutable("spec.skills")); + } + let current_kinds = current + .agent + .spec + .harnesses + .iter() + .map(|harness| harness.kind) + .collect::>(); + let desired_kinds = desired + .spec + .harnesses + .iter() + .map(|harness| harness.kind) + .collect::>(); + if current_kinds != desired_kinds { + return Err(Error::Immutable("spec.harnesses.type")); + } + let current_auth = current + .agent + .spec + .harnesses + .iter() + .map(|harness| (harness.kind, harness.auth)) + .collect::>(); + let desired_auth = desired + .spec + .harnesses + .iter() + .map(|harness| (harness.kind, harness.auth)) + .collect::>(); + if current_auth != desired_auth { + return Err(Error::Immutable("spec.harnesses.auth")); + } + Ok(()) +} + +async fn resolve_mount_sources(agent: &mut Agent, source_directory: &std::path::Path) -> Result<(), Error> { + for (index, mount) in agent.spec.sandbox.mounts.iter_mut().enumerate() { + let MountSpec::Bind { source, .. } = mount else { + continue; + }; + let unresolved = if source.is_absolute() { + source.clone() + } else { + source_directory.join(&*source) + }; + let resolved = tokio::fs::canonicalize(&unresolved).await.map_err(|error| { + Error::Invalid(format!( + "spec.sandbox.mounts[{index}].source {} cannot be resolved: {error}", + unresolved.display() + )) + })?; + if !tokio::fs::metadata(&resolved).await?.is_dir() { + return Err(Error::Invalid(format!( + "spec.sandbox.mounts[{index}].source {} must identify a directory", + resolved.display() + ))); + } + *source = resolved; + } + Ok(()) +} + +fn validate_request_paths(request: &ApplyRequest) -> Result<(), Error> { + if !request.source_directory.is_absolute() { + return Err(Error::Invalid("sourceDirectory must be absolute".into())); + } + if let Some(manifest) = &request.manifest_path + && manifest.parent() != Some(request.source_directory.as_path()) + { + return Err(Error::Invalid( + "manifestPath must name a file in sourceDirectory".into(), + )); + } + if let Some(env_file) = &request.env_file + && !env_file.is_absolute() + { + return Err(Error::Invalid("envFile must be absolute".into())); + } + Ok(()) +} + +fn bind_mount_sources(agent: &Agent) -> Vec<(String, PathBuf)> { + agent + .spec + .sandbox + .mounts + .iter() + .enumerate() + .filter_map(|(index, mount)| match mount { + MountSpec::Bind { source, .. } => Some((format!("spec.sandbox.mounts[{index}]"), source.clone())), + MountSpec::Tmpfs { .. } => None, + }) + .collect() +} + +async fn canonical_or_original(path: &std::path::Path) -> PathBuf { + tokio::fs::canonicalize(path) + .await + .unwrap_or_else(|_| path.to_path_buf()) +} + +/// Canonical form of a secret file for comparison against canonical bind mount sources. +/// +/// The file, and any number of its parent directories, may not exist yet. The nearest existing +/// ancestor is canonicalized and the missing components appended, so a symlink anywhere above +/// the file still compares equal to the resolved mount source. +async fn canonical_secret_file(path: &std::path::Path) -> PathBuf { + let mut missing = Vec::new(); + let mut ancestor = path; + loop { + if let Ok(canonical) = tokio::fs::canonicalize(ancestor).await { + return missing + .iter() + .rev() + .fold(canonical, |joined, component| joined.join(component)); + } + match (ancestor.parent(), ancestor.file_name()) { + (Some(parent), Some(name)) => { + missing.push(name); + ancestor = parent; + } + _ => return path.to_path_buf(), + } + } +} + +fn association_directories(record: &AgentRecord) -> impl Iterator { + std::iter::once(record.source_directory.as_path()).chain(record.agent.spec.sandbox.mounts.iter().filter_map( + |mount| match mount { + MountSpec::Bind { source, .. } => Some(source.as_path()), + MountSpec::Tmpfs { .. } => None, + }, + )) +} diff --git a/agentctl/src/controller.rs b/agentctl/src/controller.rs new file mode 100644 index 0000000..e35d514 --- /dev/null +++ b/agentctl/src/controller.rs @@ -0,0 +1,263 @@ +//! Generic keyed at-least-once reconciliation scheduling. + +use std::{ + collections::{BTreeMap, BTreeSet}, + rc::Rc, + time::Duration, +}; + +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; +use tokio::{ + sync::{mpsc, oneshot}, + time::{Instant, MissedTickBehavior}, +}; + +use crate::Error; + +const MAX_CONCURRENT_RECONCILES: usize = 16; +const WAKEUP_QUEUE_CAPACITY: usize = 1_024; + +/// One at-least-once convergence pass over a durable resource key. +pub trait Reconcile { + /// Converges one resource and records its observed state. + fn reconcile(&self, key: Key) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; +} + +/// Lists durable resource keys for startup and repair scans. +pub(crate) trait Source { + fn list_keys(&self) -> ::sandbox::LocalFuture<'_, Result, Error>>; +} + +/// Observes recoverable reconciliation errors without stopping the controller. +pub(crate) type ErrorHandler = Rc, &Error)>; + +struct Request { + key: Key, + response: Option>>, +} + +/// Whether a failed reconciliation pass can succeed later without operator action. +#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FailureKind { + /// Desired state must change before another pass can succeed. + Invalid, + /// A later pass may succeed without any change to desired state. + Transient, +} + +/// One classified reconciliation failure. +#[derive(Clone, Debug)] +pub struct ReconcileFailure { + /// Classification decided once at the reconcile boundary. + pub kind: FailureKind, + /// Human-readable failure detail. + pub message: String, +} + +impl ReconcileFailure { + /// Classifies a reconciliation error by its variant, never by its message. + /// + /// Invalid desired state, an immutable field that changed, and a Sandbox + /// request the Provider rejects or cannot support are permanent until the + /// operator changes something; everything else is retried. + #[must_use] + pub fn classify(error: &Error) -> Self { + let permanent = match error { + Error::Invalid(_) | Error::Immutable(_) => true, + Error::Sandbox(sandbox) => matches!( + sandbox.kind(), + ::sandbox::ErrorKind::InvalidRequest + | ::sandbox::ErrorKind::Immutable + | ::sandbox::ErrorKind::Unsupported + ), + _ => false, + }; + Self { + kind: if permanent { + FailureKind::Invalid + } else { + FailureKind::Transient + }, + message: match error { + Error::Invalid(message) => message.clone(), + error => error.to_string(), + }, + } + } +} + +impl std::fmt::Display for ReconcileFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl From for Error { + fn from(failure: ReconcileFailure) -> Self { + match failure.kind { + FailureKind::Invalid => Self::Invalid(failure.message), + FailureKind::Transient => Self::Daemon(failure.message), + } + } +} + +/// A handle for requesting immediate keyed convergence. +pub struct Wakeup { + sender: mpsc::Sender>, + resource: &'static str, +} + +impl Clone for Wakeup { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + resource: self.resource, + } + } +} + +impl Wakeup { + /// Queues convergence and waits for the resulting reconciliation pass. + /// + /// # Errors + /// + /// Returns the pass's classified failure, or a transient failure when the controller stops. + pub async fn reconcile(&self, key: Key) -> Result<(), ReconcileFailure> { + let (response, receiver) = oneshot::channel(); + self.sender + .send(Request { + key, + response: Some(response), + }) + .await + .map_err(|_| transient(format!("{} controller stopped", self.resource)))?; + receiver + .await + .map_err(|_| transient(format!("{} controller dropped a response", self.resource)))? + } + + /// Provides a best-effort low-latency hint for already-durable state. + pub fn notify(&self, key: Key) { + let _ignored = self.sender.try_send(Request { key, response: None }); + } +} + +type Response = oneshot::Sender>; +type ReconcileResult = (Key, Vec, Result<(), Error>); +type ReconcileFuture = futures_util::future::LocalBoxFuture<'static, ReconcileResult>; + +/// Continuously schedules independent reconciliations keyed by durable identity. +/// +/// At most one reconciliation runs for a key. A wakeup received during a pass +/// schedules a subsequent pass, and waiters complete only after the pass that +/// observed their request. +pub(crate) struct Controller { + source: Rc>, + reconciler: Rc>, + receiver: mpsc::Receiver>, + interval: Duration, + on_error: ErrorHandler, +} + +impl Controller +where + Key: Copy + Ord + 'static, +{ + pub(crate) fn new( + source: Rc>, + reconciler: Rc>, + interval: Duration, + resource: &'static str, + on_error: ErrorHandler, + ) -> (Self, Wakeup) { + assert!(!interval.is_zero(), "reconciliation interval must be non-zero"); + let (sender, receiver) = mpsc::channel(WAKEUP_QUEUE_CAPACITY); + ( + Self { + source, + reconciler, + receiver, + interval, + on_error, + }, + Wakeup { sender, resource }, + ) + } + + pub(crate) async fn run(mut self) { + let mut ticker = tokio::time::interval_at(Instant::now() + self.interval, self.interval); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut pending = BTreeMap::>::new(); + let mut running = BTreeSet::new(); + let mut reconciliations = FuturesUnordered::>::new(); + self.enqueue_all(&mut pending).await; + + loop { + self.start_pending(&mut pending, &mut running, &reconciliations); + tokio::select! { + biased; + request = self.receiver.recv() => { + let Some(request) = request else { return; }; + enqueue(request, &mut pending); + while let Ok(request) = self.receiver.try_recv() { + enqueue(request, &mut pending); + } + } + _ = ticker.tick() => self.enqueue_all(&mut pending).await, + Some((key, responses, result)) = reconciliations.next(), if !reconciliations.is_empty() => { + running.remove(&key); + if let Err(error) = &result { + (self.on_error)(Some(key), error); + } + let response = result.as_ref().copied().map_err(ReconcileFailure::classify); + for sender in responses { + let _ignored = sender.send(response.clone()); + } + } + } + } + } + + async fn enqueue_all(&self, pending: &mut BTreeMap>) { + match self.source.list_keys().await { + Ok(keys) => { + for key in keys { + pending.entry(key).or_default(); + } + } + Err(error) => (self.on_error)(None, &error), + } + } + + fn start_pending( + &self, + pending: &mut BTreeMap>, + running: &mut BTreeSet, + reconciliations: &FuturesUnordered>, + ) { + while running.len() < MAX_CONCURRENT_RECONCILES { + let Some(key) = pending.keys().find(|key| !running.contains(key)).copied() else { + break; + }; + let responses = pending.remove(&key).unwrap_or_default(); + running.insert(key); + let reconciler = self.reconciler.clone(); + reconciliations.push(async move { (key, responses, reconciler.reconcile(key).await) }.boxed_local()); + } + } +} + +const fn transient(message: String) -> ReconcileFailure { + ReconcileFailure { + kind: FailureKind::Transient, + message, + } +} + +fn enqueue(request: Request, pending: &mut BTreeMap>) { + let responses = pending.entry(request.key).or_default(); + if let Some(response) = request.response { + responses.push(response); + } +} diff --git a/agentctl/src/environment.rs b/agentctl/src/environment.rs new file mode 100644 index 0000000..64fcacc --- /dev/null +++ b/agentctl/src/environment.rs @@ -0,0 +1,141 @@ +//! Explicit non-secret environment imported from an Agent's environment file. + +use std::collections::BTreeMap; + +use zeroize::Zeroizing; + +use crate::{Error, control_plane::AgentRecord}; + +pub(crate) async fn resolve(record: &AgentRecord) -> Result, Error> { + if record.agent.spec.environment.is_empty() { + return Ok(BTreeMap::new()); + } + let values = read(&record.env_file_path()).await?; + record + .agent + .spec + .environment + .iter() + .map(|variable| { + let value = required(&values, variable.source())?; + Ok((variable.name.clone(), value.to_owned())) + }) + .collect() +} + +pub(crate) async fn read(path: &std::path::Path) -> Result>, Error> { + let Some(bytes) = read_if_exists(path).await? else { + return Err(Error::Invalid(format!( + "manifest values require the environment file {} (default: .env beside the manifest; override with `agentctl apply --env-file`)", + path.display() + ))); + }; + parse(&bytes) +} + +pub(crate) async fn read_or_empty(path: &std::path::Path) -> Result>, Error> { + let Some(bytes) = read_if_exists(path).await? else { + return Ok(BTreeMap::new()); + }; + parse(&bytes) +} + +async fn read_if_exists(path: &std::path::Path) -> Result>>, Error> { + match tokio::fs::read(path).await { + Ok(bytes) => Ok(Some(Zeroizing::new(bytes))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(Error::Io(error)), + } +} + +fn parse(bytes: &[u8]) -> Result>, Error> { + let text = std::str::from_utf8(bytes).map_err(|_| Error::Invalid(".env must be UTF-8".into()))?; + let mut values = BTreeMap::new(); + for (line_index, original) in text.lines().enumerate() { + let line = original.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((name, value)) = line.split_once('=') else { + return Err(Error::Invalid(format!( + "invalid .env assignment on line {}", + line_index + 1 + ))); + }; + let name = name.trim(); + if !portable_name(name) { + return Err(Error::Invalid(format!( + "invalid .env variable name on line {}", + line_index + 1 + ))); + } + let value = unquote(value.trim()) + .ok_or_else(|| Error::Invalid(format!("unbalanced .env quotes on line {}", line_index + 1)))?; + if values.insert(name.into(), Zeroizing::new(value.into())).is_some() { + return Err(Error::Invalid(format!("duplicate .env variable {name:?}"))); + } + } + Ok(values) +} + +pub(crate) fn optional<'a>(values: &'a BTreeMap>, name: &str) -> Option<&'a str> { + values + .get(name) + .map(|value| value.as_str()) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn required<'a>(values: &'a BTreeMap>, name: &str) -> Result<&'a str, Error> { + let value = values + .get(name) + .ok_or_else(|| Error::Invalid(format!(".env does not define required variable {name:?}")))?; + if value.is_empty() { + return Err(Error::Invalid(format!(".env variable {name:?} must not be empty"))); + } + Ok(value) +} + +fn portable_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +fn unquote(value: &str) -> Option<&str> { + match value.as_bytes().first() { + Some(b'"') => value.strip_prefix('"')?.strip_suffix('"'), + Some(b'\'') => value.strip_prefix('\'')?.strip_suffix('\''), + _ if value.ends_with(['"', '\'']) => None, + _ => Some(value), + } +} + +#[cfg(test)] +mod tests { + use super::{optional, read_or_empty}; + use std::collections::BTreeMap; + use zeroize::Zeroizing; + + #[tokio::test] + async fn missing_optional_environment_file_is_empty() -> Result<(), crate::Error> { + let directory = tempfile::tempdir()?; + let values = read_or_empty(&directory.path().join("missing.env")).await?; + + assert!(values.is_empty()); + Ok(()) + } + + #[test] + fn optional_values_omit_missing_and_empty_entries() { + let values = BTreeMap::from([ + ("EMPTY".into(), Zeroizing::new(String::new())), + ("PRESENT".into(), Zeroizing::new("value".into())), + ]); + + assert_eq!(optional(&values, "MISSING"), None); + assert_eq!(optional(&values, "EMPTY"), None); + assert_eq!(optional(&values, "PRESENT"), Some("value")); + } +} diff --git a/agentctl/src/harness/claude_code/authentication.rs b/agentctl/src/harness/claude_code/authentication.rs new file mode 100644 index 0000000..fd01fd4 --- /dev/null +++ b/agentctl/src/harness/claude_code/authentication.rs @@ -0,0 +1,238 @@ +//! Claude Code host-side authentication. +//! +//! The agent stack holds its **own** long-lived Claude token, minted on the +//! host by `claude setup-token` and delivered through `agentctl claude login`. +//! It is a separate OAuth grant from the user's interactive Claude Code login, +//! so importing it neither reads nor rotates the host login: both keep working. +//! A setup token is long-lived and self-contained, so there is no refresh +//! token and no host-side refresh loop. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{Error, harness::ImportedAuthentication, persistence}; + +use super::{ACCESS_SECRET, API_HOST, PROVIDER, SETUP_TOKEN_PREFIX}; + +/// Endpoint used to validate a freshly supplied token before it is stored. +const CLAUDE_PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile"; +const CLAUDE_OAUTH_BETA: &str = "oauth-2025-04-20"; +const VALIDATE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Owns the agent stack's Claude token on the trusted host. +pub(in crate::harness) struct Authentication { + database: persistence::Database, + client: reqwest::Client, + profile_url: String, +} + +impl Authentication { + /// Creates a harness authentication manager over the shared database owner. + #[must_use] + pub(in crate::harness) fn new(database: persistence::Database) -> Self { + Self { + database, + client: reqwest::Client::new(), + profile_url: CLAUDE_PROFILE_URL.into(), + } + } + + /// Validates a host-minted setup token and stores it as the agent credential. + /// + /// Token material is never returned or logged. + /// + /// # Errors + /// + /// Returns an error when the token is malformed, rejected by Claude, or cannot be persisted. + pub(in crate::harness) async fn login(&self, token: Zeroizing) -> Result { + let token = Zeroizing::new(token.trim().to_owned()); + if !token.starts_with(SETUP_TOKEN_PREFIX) { + return Err(Error::Invalid( + "Claude token is not a setup token; run `claude setup-token` to mint one".into(), + )); + } + self.validate(&token).await?; + let metadata = ClaudeMetadata { + kind: CredentialKind::SetupToken, + }; + self.database + .put_provider_account(persistence::ProviderAccountWrite { + provider: PROVIDER.into(), + credentials: vec![persistence::StoredSecret { + name: ACCESS_SECRET.into(), + value: Zeroizing::new(token.as_bytes().to_vec()), + }], + metadata_json: serde_json::to_string(&metadata)?, + }) + .await?; + Ok(ImportedAuthentication { + provider: PROVIDER.into(), + ready: true, + }) + } + + /// Confirms the token is recognized by Claude before it is trusted. + /// + /// This catches a mistyped or truncated token, not scope: a setup token is + /// scoped for inference, not profile access, so the profile endpoint + /// answers `403` (authenticated, forbidden) for a good token and `401` + /// (unauthenticated) for a bad one. Only `401` rejects. A network failure + /// or server error never blocks login — the token already passed its format + /// check, and mediation surfaces any real problem on first use. + async fn validate(&self, token: &str) -> Result<(), Error> { + let sent = self + .client + .get(&self.profile_url) + .timeout(VALIDATE_REQUEST_TIMEOUT) + .bearer_auth(token) + .header("anthropic-beta", CLAUDE_OAUTH_BETA) + .send() + .await; + let Ok(response) = sent else { + tracing::warn!("could not reach Claude to validate the token; storing it anyway"); + return Ok(()); + }; + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + Err(Error::Invalid( + "Claude rejected the token; mint a fresh one with `claude setup-token`".into(), + )) + } else { + if !status.is_success() && status != reqwest::StatusCode::FORBIDDEN { + tracing::warn!(%status, "unexpected HTTP status while validating the token; storing it anyway"); + } + Ok(()) + } + } + + #[cfg(test)] + fn with_profile_url(mut self, profile_url: String) -> Self { + self.profile_url = profile_url; + self + } +} + +/// How a stored Claude credential was obtained. A future `agentctl`-driven +/// PKCE grant can extend this without a schema change. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +enum CredentialKind { + /// A long-lived token minted by `claude setup-token`. + SetupToken, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeMetadata { + kind: CredentialKind, +} + +/// Confirms a stored Claude credential exists for mediation. +pub(super) async fn is_ready(database: &persistence::Database) -> Result { + database.provider_account_exists(PROVIDER).await +} + +/// The host that the stored Claude token is mediated to. +pub(super) const fn mediated_host() -> &'static str { + API_HOST +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use sandbox::secret_store::{SecretReference, SecretStore as _}; + use tempfile::TempDir; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + use super::*; + + async fn serve_once(status: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind profile endpoint"); + let endpoint = format!("http://{}/profile", listener.local_addr().expect("local address")); + tokio::task::spawn_local(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut chunk = [0_u8; 1_024]; + let _ = stream.read(&mut chunk).await; + let body = br#"{"account":{}}"#; + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .as_bytes(), + ) + .await + .expect("write headers"); + stream.write_all(body).await.expect("write body"); + }); + endpoint + } + + #[tokio::test(flavor = "local")] + async fn stores_a_validated_setup_token_without_a_refresh_secret() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let endpoint = serve_once("200 OK").await; + let manager = Authentication::new(database.clone()).with_profile_url(endpoint); + + let imported = manager + .login(Zeroizing::new("sk-ant-oat01-token-canary\n".into())) + .await + .expect("login"); + assert_eq!(imported.provider, "claude"); + assert!(imported.ready); + + let access = database + .resolve(&SecretReference::from_opaque("claude-access-token")) + .await + .expect("access token"); + assert_eq!(access.expose(), b"sk-ant-oat01-token-canary"); + assert!(is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn rejects_a_non_setup_token_without_a_network_call() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + // Unroutable profile URL proves format validation happens before the network call. + let manager = Authentication::new(database.clone()).with_profile_url("http://127.0.0.1:1/profile".into()); + let error = manager + .login(Zeroizing::new("not-a-real-token".into())) + .await + .expect_err("reject"); + assert!(!error.to_string().contains("not-a-real-token")); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn rejects_only_an_unauthenticated_token() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let endpoint = serve_once("401 Unauthorized").await; + let manager = Authentication::new(database.clone()).with_profile_url(endpoint); + manager + .login(Zeroizing::new("sk-ant-oat01-bad".into())) + .await + .expect_err("reject"); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn accepts_a_forbidden_response_because_setup_tokens_lack_profile_scope() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + // A valid setup token is scoped for inference, not profile, so the + // profile endpoint answers 403 — which must still be accepted. + let endpoint = serve_once("403 Forbidden").await; + let manager = Authentication::new(database.clone()).with_profile_url(endpoint); + manager + .login(Zeroizing::new("sk-ant-oat01-valid-but-scoped".into())) + .await + .expect("accept scoped token"); + assert!(is_ready(&database).await.expect("readiness")); + } +} diff --git a/agentctl/src/harness/claude_code/bootstrap/linux.rs b/agentctl/src/harness/claude_code/bootstrap/linux.rs new file mode 100644 index 0000000..c4590b9 --- /dev/null +++ b/agentctl/src/harness/claude_code/bootstrap/linux.rs @@ -0,0 +1,93 @@ +//! Linux Sandbox configuration for mediated Claude Code authentication. + +use sandbox::SandboxHandle; + +use crate::{ + Error, + sandbox::platform::{files::write_if_changed, run_checked}, +}; + +use super::super::ACCESS_PLACEHOLDER; + +/// Days before Claude Code's retention sweep deletes an untouched transcript: effectively never. +const TRANSCRIPT_RETENTION_DAYS: u32 = 36_500; + +pub(super) async fn configure( + sandbox: &SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + let skills_path = format!("{home}/.claude/skills"); + let config = format!("{home}/.claude"); + let hooks_path = format!("{config}/hooks"); + let credentials_path = format!("{config}/.credentials.json"); + let hook_path = format!("{config}/hooks/activity-hook.mjs"); + let settings_path = format!("{config}/agent-settings.json"); + let status_line_path = format!("{config}/status-line.mjs"); + let instructions_path = format!("{config}/CLAUDE.md"); + run_checked(sandbox, "/usr/bin/mkdir", ["-p", hooks_path.as_str()]).await?; + let credentials = serde_json::to_vec(&serde_json::json!({ + "claudeAiOauth": { + "accessToken": ACCESS_PLACEHOLDER, + "refreshToken": "agent-mediated-refresh-placeholder-not-a-real-credential", + "expiresAt": 4_102_444_800_000_i64, + "refreshTokenExpiresAt": 4_102_444_800_000_i64, + "scopes": ["user:inference"] + } + }))?; + write_if_changed(sandbox, &credentials_path, &credentials).await?; + if let Some(instructions) = instructions { + write_if_changed(sandbox, &instructions_path, instructions).await?; + } + write_if_changed(sandbox, &hook_path, super::super::hooks::script()?.as_bytes()).await?; + write_if_changed(sandbox, &status_line_path, super::super::status_line::script()).await?; + // HACK: the mediated setup token is inference-only, so Claude Code cannot read the account's + // plan entitlement and gates Fable behind a usage-credits prompt. Declaring the subscription + // type and rate-limit tier in the settings env satisfies the client-side plan-inclusion check + // (the literal "max" tier is what the check looks for, regardless of the real plan); the server + // still authorizes inference independently. Both are required — the type alone unblocks Max + // models but not Fable. Remove when github.com/anthropics/claude-code#79360 ships. + let settings = serde_json::to_vec(&serde_json::json!({ + "env": { + "CLAUDE_CODE_SUBSCRIPTION_TYPE": "max", + "CLAUDE_CODE_RATE_LIMIT_TIER": "default_claude_max_5x" + }, + "hooks": super::super::hooks::configuration(&hook_path), + "statusLine": super::super::status_line::configuration(&status_line_path), + // Claude Code deletes transcripts untouched for 30 days by default, so an Idle Session + // would resume into a fresh conversation. Discarding a conversation is the platform's call. + "cleanupPeriodDays": TRANSCRIPT_RETENTION_DAYS + }))?; + write_if_changed(sandbox, &settings_path, &settings).await?; + // Runtime file transfer writes as the Sandbox supervisor (root), while + // executions run as the image user. Correct only the directories and files + // managed above: recursive ownership walks would traverse the growing + // harness state tree on every reconciliation pass. + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "/usr/bin/chown", + "agent:agent", + config.as_str(), + hooks_path.as_str(), + credentials_path.as_str(), + hook_path.as_str(), + settings_path.as_str(), + status_line_path.as_str(), + ], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["600", credentials_path.as_str()]).await?; + if instructions.is_some() { + run_checked( + sandbox, + "/usr/bin/sudo", + ["/usr/bin/chown", "agent:agent", instructions_path.as_str()], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["644", instructions_path.as_str()]).await?; + } + crate::harness::skills::install_linux(sandbox, &skills_path, skills).await +} diff --git a/agentctl/src/harness/claude_code/bootstrap/mod.rs b/agentctl/src/harness/claude_code/bootstrap/mod.rs new file mode 100644 index 0000000..59abd96 --- /dev/null +++ b/agentctl/src/harness/claude_code/bootstrap/mod.rs @@ -0,0 +1,12 @@ +//! Sandbox-platform-specific Claude Code configuration. + +mod linux; + +pub(super) async fn configure_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), crate::Error> { + linux::configure(sandbox, home, instructions, skills).await +} diff --git a/agentctl/src/harness/claude_code/hooks.rs b/agentctl/src/harness/claude_code/hooks.rs new file mode 100644 index 0000000..96c4cbe --- /dev/null +++ b/agentctl/src/harness/claude_code/hooks.rs @@ -0,0 +1,82 @@ +//! Claude Code's hook events and what each one tells the platform. + +use crate::{harness::hook_script::HookScript, sessions::ActivityEvent}; + +/// Every Claude Code hook event the platform folds. `SessionStart` also carries +/// the native session ID and transcript location; the rest are activity signals +/// that keep a working Session from looking idle and let an orchestrator wait. +const EVENTS: &[(&str, ActivityEvent)] = &[ + ("SessionStart", ActivityEvent::SessionStart), + ("UserPromptSubmit", ActivityEvent::TurnStarted), + ("Stop", ActivityEvent::TurnCompleted), + ("StopFailure", ActivityEvent::TurnCompleted), + ("PermissionRequest", ActivityEvent::WaitingForInput), + ("Notification", ActivityEvent::WaitingForInput), +]; + +/// `Notification` types that mean Claude Code is blocked on the operator; +/// authentication and dialog notifications carry no activity signal. +const WAITING_NOTIFICATIONS: &[&str] = &["permission_prompt", "idle_prompt"]; + +/// `SessionStart` sources that begin a conversation the platform tracks. +const SESSION_START_MATCHER: &str = "startup|resume|clear|compact"; + +const SCRIPT: HookScript<'static> = HookScript { + events: EVENTS, + waiting_notifications: WAITING_NOTIFICATIONS, +}; + +/// Renders Claude Code's activity hook script. +/// +/// # Errors +/// +/// Returns an error when the event table cannot be encoded. +pub(super) fn script() -> Result { + SCRIPT.render() +} + +/// The `hooks` value of Claude Code's settings, registering the script for +/// exactly the events in the table. +pub(super) fn configuration(hook_path: &str) -> serde_json::Value { + let command = serde_json::json!({ "type": "command", "command": format!("node {hook_path}") }); + let hooks = SCRIPT + .event_names() + .map(|event| { + let mut entry = serde_json::json!({ "hooks": [command] }); + if event == "SessionStart" { + entry["matcher"] = SESSION_START_MATCHER.into(); + } + (event.to_owned(), serde_json::Value::Array(vec![entry])) + }) + .collect::>(); + serde_json::Value::Object(hooks) +} + +#[cfg(test)] +mod tests { + use super::{EVENTS, configuration, script}; + use crate::harness::hook_script::embedded_events; + + #[test] + fn the_script_embeds_the_table_and_the_configuration_registers_it() { + let script = script().expect("script renders"); + let embedded = embedded_events(&script); + assert_eq!(embedded.len(), EVENTS.len()); + for (name, event) in EVENTS { + assert!(embedded.iter().any(|(n, e)| n == name && e == event), "{name}"); + } + assert!(script.contains(r#"const WAITING_NOTIFICATIONS = ["permission_prompt","idle_prompt"];"#)); + + let configuration = configuration("/home/agent/.claude/hooks/activity-hook.mjs"); + let registered = configuration.as_object().expect("hooks object"); + assert_eq!(registered.len(), EVENTS.len()); + for (name, _) in EVENTS { + let entry = ®istered[*name][0]; + assert_eq!( + entry["hooks"][0]["command"], + "node /home/agent/.claude/hooks/activity-hook.mjs" + ); + assert_eq!(entry.get("matcher").is_some(), *name == "SessionStart"); + } + } +} diff --git a/agentctl/src/harness/claude_code/mod.rs b/agentctl/src/harness/claude_code/mod.rs new file mode 100644 index 0000000..46bbae8 --- /dev/null +++ b/agentctl/src/harness/claude_code/mod.rs @@ -0,0 +1,338 @@ +//! Claude Code harness adapter. + +use std::fmt::Write as _; + +use crate::{ + Error, + harness::{LaunchRequest, MediatedSecret, ProcessLaunch, shell_single_quoted}, + persistence, +}; +use sandbox::secret_store::SecretReference; + +pub(super) mod authentication; +mod bootstrap; +mod hooks; +mod status_line; +pub(super) mod transcript; + +const PROVIDER: &str = "claude"; +const ACCESS_SECRET: &str = "claude-access-token"; +pub(super) const ACCESS_ENVIRONMENT: &str = "CLAUDE_CODE_OAUTH_TOKEN"; +const ACCESS_PLACEHOLDER: &str = "sk-ant-oat01-agent-mediated-placeholder-not-a-real-credential"; +/// Second binding on the same credential, under a name the harness does not +/// scrub. Claude Code removes `CLAUDE_CODE_OAUTH_TOKEN` from every process it +/// spawns, so a Session cannot read its own placeholder to hand to a nested +/// `agentd`; this name survives, as the Codex one already does. +const NESTED_ENVIRONMENT: &str = "AGENT_CLAUDE_ACCESS_TOKEN"; +/// A binding needs its own placeholder, and one placeholder may not contain +/// another, so this is not a spelling of `ACCESS_PLACEHOLDER`. A nested Agent +/// therefore sends this value outward and the outer mediation resolves it to +/// the same stored credential. +const NESTED_PLACEHOLDER: &str = "sk-ant-oat01-agent-mediated-nested-placeholder-not-a-real-credential"; +const API_HOST: &str = "api.anthropic.com"; +/// Fullscreen Claude owns an alternate-screen viewport whose redraws can corrupt under tmux; +/// normal-screen output remains stable and gives tmux durable scrollback. +const DISABLE_ALTERNATE_SCREEN_ENVIRONMENT: &str = "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"; +/// The model recorded for Sessions that predate recorded selections. The adapter +/// launched every Session on this alias from preview 2 until selections arrived, +/// because the mediated setup token cannot enumerate models and Fable never +/// appeared in the `/model` picker. Preview 1 Sessions ran on Claude Code's own +/// default; recording the alias for them too keeps every earlier conversation on +/// one known model instead of whatever the harness defaults to next. Manifests +/// now declare the default for new Sessions. +pub(super) const MODEL_LAUNCHED_BEFORE_SELECTION: &str = "fable"; + +pub(super) async fn authentication_ready(database: &persistence::Database) -> Result { + authentication::is_ready(database).await +} + +pub(super) async fn prepare(database: &persistence::Database) -> Result, Error> { + if !authentication::is_ready(database).await? { + return Err(Error::Invalid( + "Claude Code authentication is not ready; run `agentctl claude login`".into(), + )); + } + Ok(vec![ + MediatedSecret { + environment: ACCESS_ENVIRONMENT, + placeholder: ACCESS_PLACEHOLDER.into(), + reference: SecretReference::from_opaque(ACCESS_SECRET), + allowed_hosts: vec![authentication::mediated_host().into()], + }, + MediatedSecret { + environment: NESTED_ENVIRONMENT, + placeholder: NESTED_PLACEHOLDER.into(), + reference: SecretReference::from_opaque(ACCESS_SECRET), + allowed_hosts: vec![authentication::mediated_host().into()], + }, + ]) +} + +pub(super) fn conflicts_with_managed_secret(name: &str, placeholder: Option<&str>) -> bool { + matches!(name, ACCESS_ENVIRONMENT | NESTED_ENVIRONMENT) + || matches!(placeholder, Some(ACCESS_PLACEHOLDER | NESTED_PLACEHOLDER)) +} + +pub(super) fn manages_environment(name: &str) -> bool { + matches!( + name, + ACCESS_ENVIRONMENT + | NESTED_ENVIRONMENT + | "CLAUDE_CONFIG_DIR" + | DISABLE_ALTERNATE_SCREEN_ENVIRONMENT + | "DISABLE_AUTOUPDATER" + ) +} + +/// Long-lived Claude setup tokens carry this prefix. +const SETUP_TOKEN_PREFIX: &str = "sk-ant-oat"; + +/// Mints a long-lived Claude token on the host with `claude setup-token`. +/// +/// Runs the harness CLI to completion with the terminal attached so its own +/// browser-redirect OAuth flow (and the ephemeral localhost callback it starts) +/// can finish; it is never timed out. The token is read from stdout, with a +/// paste fallback when the harness prints it only to the terminal. +/// +/// # Errors +/// +/// Returns an error when the harness CLI is missing, fails, or yields no token. +pub(super) fn acquire_host_token() -> Result, Error> { + use std::process::{Command, Stdio}; + + eprintln!("Minting a long-lived Claude token with `claude setup-token`."); + eprintln!("A browser window will open — approve the request, then return here."); + let output = Command::new("claude") + .arg("setup-token") + .stdin(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|error| { + Error::Invalid(format!( + "could not run `claude setup-token` (is Claude Code installed on this host?): {error}" + )) + })? + .wait_with_output() + .map_err(|error| Error::Invalid(format!("`claude setup-token` did not run: {error}")))?; + if !output.status.success() { + return Err(Error::Invalid("`claude setup-token` did not complete".into())); + } + if let Some(token) = String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .find(|word| word.starts_with(SETUP_TOKEN_PREFIX)) + { + return Ok(zeroize::Zeroizing::new(token.to_owned())); + } + prompt_for_token() +} + +/// Reads a token pasted by the user when it did not appear on stdout. +fn prompt_for_token() -> Result, Error> { + use std::io::Write as _; + + eprint!("Paste the Claude token shown above: "); + std::io::stderr().flush().ok(); + let mut line = zeroize::Zeroizing::new(String::new()); + std::io::stdin() + .read_line(&mut line) + .map_err(|error| Error::Invalid(format!("could not read the pasted token: {error}")))?; + let token = zeroize::Zeroizing::new(line.trim().to_owned()); + if token.is_empty() { + return Err(Error::Invalid("no Claude token was provided".into())); + } + Ok(token) +} + +pub(super) async fn bootstrap_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + bootstrap::configure_linux(sandbox, home, instructions, skills).await +} + +pub(super) async fn verify_linux( + sandbox: &sandbox::SandboxHandle, + expected_version: Option<&str>, +) -> Result<(), Error> { + let output = super::version_output(sandbox, "claude").await?; + if !output.status.success() { + let message = format!("`claude --version` exited with code {}", output.status.code); + // 126/127 mean the image does not provide the harness; retrying cannot change that. + // Any other failure this early in the guest's life may be transient. + return Err(if matches!(output.status.code, 126 | 127) { + Error::Invalid(format!("Claude Code is missing: {message}")) + } else { + Error::SandboxSetup(message) + }); + } + let stdout = std::str::from_utf8(&output.stdout) + .map_err(|_| Error::SandboxSetup("`claude --version` returned non-UTF-8 output".into()))?; + let installed = stdout + .split_whitespace() + .next() + .ok_or_else(|| Error::SandboxSetup("`claude --version` returned no version".into()))?; + if let Some(expected) = expected_version.filter(|expected| *expected != installed) { + return Err(Error::Invalid(format!( + "declared Claude Code version {expected:?} does not match installed version {installed:?}" + ))); + } + Ok(()) +} + +pub(super) fn launch_linux(request: &LaunchRequest<'_>) -> ProcessLaunch { + let config = format!("{}/.claude", request.home); + let mut base = format!("claude --dangerously-skip-permissions --settings {config}/agent-settings.json"); + // Claude Code takes a model alias (`fable`, `opus`) or full model name, and one of its own + // effort levels. Both are opaque here and apply to fresh and resumed conversations alike. + if let Some(model) = &request.model_selection.model { + let _infallible = write!(base, " --model {}", shell_single_quoted(model.as_str())); + } + if let Some(effort) = &request.model_selection.effort { + let _infallible = write!(base, " --effort {}", shell_single_quoted(effort.as_str())); + } + // A fresh conversation may start on a positional prompt; `--` keeps a prompt + // that begins with `-` from being read as an option. + let fresh = request.initial_prompt.map_or_else( + || base.clone(), + |message| format!("{base} -- {}", shell_single_quoted(message)), + ); + // Claude Code currently reports UUID conversation IDs. Keep that + // harness-specific constraint out of the generic Session reconciler. + let resume = request.resume.and_then(|native| native.parse::().ok()); + let command = match resume { + // SessionStart can report an ID before Claude creates its JSONL. Treat + // the harness-owned transcript as the authority for resumability so + // an untouched Session can still wake from Idle as a fresh Session. + Some(native) => format!( + "if /usr/bin/find {config}/projects -type f -name '{native}.jsonl' -print -quit 2>/dev/null \ + | /usr/bin/grep -q .; then exec {base} --resume {native}; else exec {fresh}; fi" + ), + None => fresh, + }; + ProcessLaunch { + command, + // Launch-only overrides keep the tmux session non-interactive and its + // conversation in tmux history without depending on image ENV. + environment: vec![ + ("CLAUDE_CONFIG_DIR".into(), config), + (DISABLE_ALTERNATE_SCREEN_ENVIRONMENT.into(), "1".into()), + ("DISABLE_AUTOUPDATER".into(), "1".into()), + ], + } +} + +#[cfg(test)] +mod tests { + use crate::harness::{Effort, LaunchRequest, Model, ModelSelection}; + + const UNSELECTED: ModelSelection = ModelSelection { + model: None, + effort: None, + }; + + fn request<'a>(resume: Option<&'a str>, initial_prompt: Option<&'a str>) -> LaunchRequest<'a> { + LaunchRequest { + home: "/home/agent", + resume, + initial_prompt, + model_selection: &UNSELECTED, + } + } + + #[test] + fn the_nested_binding_is_a_distinct_unambiguous_setup_token() { + // The Network Backend rejects bindings whose placeholders repeat or contain one another. + assert_ne!(super::ACCESS_PLACEHOLDER, super::NESTED_PLACEHOLDER); + assert!(!super::ACCESS_PLACEHOLDER.contains(super::NESTED_PLACEHOLDER)); + assert!(!super::NESTED_PLACEHOLDER.contains(super::ACCESS_PLACEHOLDER)); + // `agentctl claude login` only accepts a setup token, so a nested Agent can chain on this. + assert!(super::NESTED_PLACEHOLDER.starts_with(super::SETUP_TOKEN_PREFIX)); + } + + #[test] + fn a_manifest_cannot_redeclare_either_claude_binding() { + for name in [super::ACCESS_ENVIRONMENT, super::NESTED_ENVIRONMENT] { + assert!(super::manages_environment(name)); + assert!(super::conflicts_with_managed_secret(name, None)); + } + for placeholder in [super::ACCESS_PLACEHOLDER, super::NESTED_PLACEHOLDER] { + assert!(super::conflicts_with_managed_secret("UNRELATED", Some(placeholder))); + } + } + + #[test] + fn resume_launch_requires_a_native_transcript() { + let native = "160cdb4b-5997-464c-9d22-602786eb45d4"; + let launch = super::launch_linux(&request(Some(native), None)); + + assert!(launch.command.contains("/home/agent/.claude/projects")); + assert!(launch.command.contains("160cdb4b-5997-464c-9d22-602786eb45d4.jsonl")); + assert!(launch.command.contains("--resume 160cdb4b-5997-464c-9d22-602786eb45d4")); + assert!(launch.command.contains("else exec claude")); + assert!(launch.environment.contains(&("DISABLE_AUTOUPDATER".into(), "1".into()))); + } + + #[test] + fn launches_in_tmux_scrollback_instead_of_the_alternate_screen() { + let launch = super::launch_linux(&request(None, None)); + + assert!( + launch + .environment + .contains(&(super::DISABLE_ALTERNATE_SCREEN_ENVIRONMENT.into(), "1".into())) + ); + assert!(super::manages_environment(super::DISABLE_ALTERNATE_SCREEN_ENVIRONMENT)); + } + + #[test] + fn non_uuid_native_id_is_not_a_claude_resume_target() { + let launch = super::launch_linux(&request(Some("opaque-harness-id"), None)); + + assert!(!launch.command.contains("--resume")); + } + + #[test] + fn a_fresh_launch_passes_the_first_prompt_as_one_quoted_argument() { + let launch = super::launch_linux(&request(None, Some("fix it's\nbroken"))); + + assert!( + // `--` keeps a prompt that starts with `-` or names a subcommand positional. + launch.command.ends_with(" -- 'fix it'\\''s\nbroken'"), + "{}", + launch.command + ); + assert!(!launch.command.contains("--resume")); + } + + #[test] + fn launches_select_no_model_or_effort_unless_the_session_carries_them() { + let launch = super::launch_linux(&request(None, None)); + + assert!(!launch.command.contains("--model")); + assert!(!launch.command.contains("--effort")); + } + + #[test] + fn model_and_effort_apply_to_fresh_and_resumed_conversations() { + let selection = ModelSelection { + model: Some(Model::new("fable").expect("model")), + effort: Some(Effort::new("xhigh").expect("effort")), + }; + let launch = super::launch_linux(&LaunchRequest { + model_selection: &selection, + ..request(Some("160cdb4b-5997-464c-9d22-602786eb45d4"), Some("go")) + }); + + assert_eq!( + launch.command.matches("--model 'fable' --effort 'xhigh'").count(), + 2, + "{}", + launch.command + ); + assert!(launch.command.contains("--effort 'xhigh' --resume 160cdb4b")); + assert!(launch.command.contains("--effort 'xhigh' -- 'go'")); + } +} diff --git a/agentctl/src/harness/claude_code/status_line.mjs b/agentctl/src/harness/claude_code/status_line.mjs new file mode 100644 index 0000000..3af6bf3 --- /dev/null +++ b/agentctl/src/harness/claude_code/status_line.mjs @@ -0,0 +1,64 @@ +import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; + +let raw = ""; +process.stdin.setEncoding("utf8"); +for await (const chunk of process.stdin) raw += chunk; + +let state; +try { + state = JSON.parse(raw); +} catch { + process.exit(0); +} + +const clean = (value) => String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, ""); +const percentage = (value) => { + if (value === null || value === undefined) return null; + const number = Number(value); + return Number.isFinite(number) ? Math.round(Math.min(100, Math.max(0, number))) : null; +}; +const remaining = (window) => { + const used = percentage(window?.used_percentage); + return used === null ? null : 100 - used; +}; +const compactPath = (path) => { + const home = homedir(); + if (path === home) return "~"; + return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +}; +const branch = (cwd) => { + try { + return execFileSync("git", ["-C", cwd, "symbolic-ref", "--quiet", "--short", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 250, + }).trim(); + } catch { + return ""; + } +}; + +const segments = []; +const model = [state.model?.display_name, state.effort?.level].filter(Boolean).map(clean).join(" "); +if (model) segments.push(model); + +const cwd = state.workspace?.current_dir ?? state.cwd; +if (cwd) { + segments.push(clean(compactPath(cwd))); + const currentBranch = branch(cwd); + if (currentBranch) segments.push(clean(currentBranch)); +} + +const contextUsed = percentage(state.context_window?.used_percentage); +if (contextUsed !== null) segments.push(`Context ${contextUsed}% used`); + +const fiveHourLeft = remaining(state.rate_limits?.five_hour); +if (fiveHourLeft !== null) segments.push(`5h ${fiveHourLeft}% left`); +const weeklyLeft = remaining(state.rate_limits?.seven_day); +if (weeklyLeft !== null) segments.push(`weekly ${weeklyLeft}% left`); + +if (state.version) segments.push(clean(state.version)); +if (typeof state.fast_mode === "boolean") segments.push(`Fast ${state.fast_mode ? "on" : "off"}`); + +process.stdout.write(segments.join(" · ")); diff --git a/agentctl/src/harness/claude_code/status_line.rs b/agentctl/src/harness/claude_code/status_line.rs new file mode 100644 index 0000000..e57b295 --- /dev/null +++ b/agentctl/src/harness/claude_code/status_line.rs @@ -0,0 +1,12 @@ +//! Claude Code status-line configuration for managed Agent Sessions. + +pub(super) const fn script() -> &'static [u8] { + include_bytes!("status_line.mjs") +} + +pub(super) fn configuration(script_path: &str) -> serde_json::Value { + serde_json::json!({ + "type": "command", + "command": format!("node {script_path}") + }) +} diff --git a/agentctl/src/harness/claude_code/transcript.rs b/agentctl/src/harness/claude_code/transcript.rs new file mode 100644 index 0000000..42cd184 --- /dev/null +++ b/agentctl/src/harness/claude_code/transcript.rs @@ -0,0 +1,389 @@ +//! Parsing the Claude Code JSONL transcript into runtime-neutral turns. +//! +//! Claude Code appends one JSON object per line. An operator prompt is a +//! `user` entry whose `message.content` is a string; tool results are `user` +//! entries with a `tool_result` block array; each assistant content block is +//! its own `assistant` entry sharing one `message.id`. The file also records +//! entries that are not conversation: subagent side chains, meta entries, +//! compaction summaries and text the client injects on the user's behalf +//! (slash-command echoes, background task notifications, system reminders). +//! Input the operator sends while a turn is running is not a `user` entry at +//! all: it is an `attachment` of type `queued_command` absorbed into the turn. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::{ + Error, + sessions::{Message, Part, Role, Turn}, +}; + +/// Parses Claude Code JSONL into ordered turns. +/// +/// # Errors +/// +/// Returns an error when the transcript is not UTF-8. Lines that are not JSON +/// objects are skipped, so a partially written trailing line never fails a read. +pub(crate) fn parse(bytes: &[u8]) -> Result, Error> { + let text = + std::str::from_utf8(bytes).map_err(|error| Error::Session(format!("transcript is not UTF-8: {error}")))?; + let mut builder = Builder::default(); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + if let Ok(entry) = serde_json::from_str::(line) { + builder.push(&entry); + } + } + Ok(builder.turns) +} + +/// Drops records before the first operator prompt in a bounded file suffix. +pub(crate) fn trim_partial(bytes: &[u8]) -> &[u8] { + let mut offset = 0; + for line in bytes.split_inclusive(|byte| *byte == b'\n') { + if serde_json::from_slice::(line).is_ok_and(|entry| starts_turn(&entry)) { + return &bytes[offset..]; + } + offset += line.len(); + } + &bytes[bytes.len()..] +} + +fn starts_turn(entry: &Value) -> bool { + if flag(entry, "isSidechain") || flag(entry, "isMeta") || flag(entry, "isCompactSummary") { + return false; + } + if entry.get("type").and_then(Value::as_str) != Some("user") { + return false; + } + let Some(message) = entry.get("message") else { + return false; + }; + match message.get("content") { + Some(Value::String(text)) => !is_injected(text), + Some(Value::Array(blocks)) => { + let text = blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::(); + !text.is_empty() && !is_injected(&text) + } + _ => false, + } +} + +#[derive(Default)] +struct Builder { + turns: Vec, + /// Location of each recorded tool call by Claude's `tool_use` ID, so a + /// later `tool_result` can mark it failed. + tool_calls: HashMap, + /// `message.id` of the assistant message the last assistant entry belongs + /// to; consecutive entries with the same ID are one message. + assistant_message: Option, +} + +impl Builder { + fn push(&mut self, entry: &Value) { + if flag(entry, "isSidechain") || flag(entry, "isMeta") || flag(entry, "isCompactSummary") { + return; + } + match entry.get("type").and_then(Value::as_str) { + Some("user") => { + if let Some(message) = entry.get("message") { + self.push_user(message); + } + } + Some("assistant") => { + if let Some(message) = entry.get("message") { + self.push_assistant(message); + } + } + Some("attachment") => { + if let Some(attachment) = entry.get("attachment") { + self.push_attachment(attachment); + } + } + _ => {} + } + } + + /// Operator input absorbed into the running turn joins that turn as a user + /// message; other attachments (environment, reminders) are not conversation. + fn push_attachment(&mut self, attachment: &Value) { + if attachment.get("type").and_then(Value::as_str) != Some("queued_command") + || attachment + .get("origin") + .and_then(|origin| origin.get("kind")) + .and_then(Value::as_str) + != Some("human") + { + return; + } + let Some(prompt) = attachment.get("prompt").and_then(Value::as_str) else { + return; + }; + self.assistant_message = None; + self.current_turn().messages.push(Message { + role: Role::User, + parts: vec![Part::Text { + text: prompt.to_owned(), + }], + }); + } + + fn current_turn(&mut self) -> &mut Turn { + if self.turns.is_empty() { + self.turns.push(Turn::default()); + } + let last = self.turns.len() - 1; + &mut self.turns[last] + } + + fn push_user(&mut self, message: &Value) { + match message.get("content") { + Some(Value::String(text)) => self.push_prompt(text), + Some(Value::Array(blocks)) => { + let mut prompt = String::new(); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("tool_result") => self.record_tool_result(block), + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + prompt.push_str(text); + } + } + _ => {} + } + } + if !prompt.is_empty() { + self.push_prompt(&prompt); + } + } + _ => {} + } + } + + /// Starts a turn on an operator prompt; injected client text is not one. + fn push_prompt(&mut self, text: &str) { + if is_injected(text) { + return; + } + self.assistant_message = None; + self.turns.push(Turn { + messages: vec![Message { + role: Role::User, + parts: vec![Part::Text { text: text.to_owned() }], + }], + }); + } + + fn record_tool_result(&mut self, block: &Value) { + if block.get("is_error").and_then(Value::as_bool) != Some(true) { + return; + } + let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else { + return; + }; + if let Some(&(turn, message, part)) = self.tool_calls.get(id) + && let Some(Part::ToolCall { failed, .. }) = self + .turns + .get_mut(turn) + .and_then(|turn| turn.messages.get_mut(message)) + .and_then(|message| message.parts.get_mut(part)) + { + *failed = true; + } + } + + fn push_assistant(&mut self, message: &Value) { + let Some(blocks) = message.get("content").and_then(Value::as_array) else { + return; + }; + let id = message.get("id").and_then(Value::as_str); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + self.append_assistant_part(id, Part::Text { text: text.to_owned() }); + } + } + Some("tool_use") => { + if let Some(name) = block.get("name").and_then(Value::as_str) { + let location = self.append_assistant_part( + id, + Part::ToolCall { + name: name.to_owned(), + failed: false, + }, + ); + if let Some(tool_use) = block.get("id").and_then(Value::as_str) { + self.tool_calls.insert(tool_use.to_owned(), location); + } + } + } + _ => {} + } + } + } + + /// Appends a part to the current assistant message, opening a new message + /// when the entry belongs to a different API response, and a new turn when + /// the transcript starts mid-conversation. + fn append_assistant_part(&mut self, id: Option<&str>, part: Part) -> (usize, usize, usize) { + if self.turns.is_empty() { + self.turns.push(Turn::default()); + } + let turn_index = self.turns.len() - 1; + let turn = &mut self.turns[turn_index]; + let continues = id.is_some() && id == self.assistant_message.as_deref(); + if !continues || turn.messages.last().is_none_or(|last| last.role != Role::Assistant) { + turn.messages.push(Message { + role: Role::Assistant, + parts: Vec::new(), + }); + self.assistant_message = id.map(str::to_owned); + } + let message_index = turn.messages.len() - 1; + let message = &mut turn.messages[message_index]; + message.parts.push(part); + (turn_index, message_index, message.parts.len() - 1) + } +} + +fn flag(entry: &Value, name: &str) -> bool { + entry.get(name).and_then(Value::as_bool) == Some(true) +} + +/// Recognizes the harness's local-command and notification envelopes. Ordinary +/// XML is operator input; explicitly human queued attachments bypass this filter. +fn is_injected(text: &str) -> bool { + let trimmed = text.trim(); + [ + "command-name", + "local-command-stdout", + "local-command-stderr", + "task-notification", + "system-reminder", + ] + .iter() + .any(|tag| trimmed.starts_with(&format!("<{tag}>")) && trimmed.contains(&format!(""))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = include_str!("../../../tests/fixtures/claude-code-transcript.jsonl"); + + #[test] + fn a_bounded_suffix_discards_a_partial_turn() { + let suffix = concat!( + r#"{"type":"assistant","message":{"id":"old","content":[{"type":"text","text":"partial"}]}}"#, + "\n", + r#"{"type":"user","message":{"content":"complete"}}"#, + "\n", + ); + + let turns = parse(trim_partial(suffix.as_bytes())).expect("suffix"); + + assert!(matches!(turns[0].messages[0].parts[0], Part::Text { ref text } if text == "complete")); + } + + #[test] + fn recorded_transcript_yields_operator_turns_only() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let prompts = turns + .iter() + .map(|turn| match &turn.messages[0].parts[0] { + Part::Text { text } => text.as_str(), + Part::ToolCall { .. } => panic!("a turn starts with the prompt"), + }) + .collect::>(); + assert_eq!(prompts, ["Rename the helper and run the tests.", "Now commit it."]); + } + + #[test] + fn input_absorbed_mid_turn_joins_the_running_turn() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let second = &turns[1]; + let users = second + .messages + .iter() + .filter(|message| message.role == Role::User) + .map(|message| match &message.parts[0] { + Part::Text { text } => text.as_str(), + Part::ToolCall { .. } => panic!("user text"), + }) + .collect::>(); + assert_eq!(users, ["Now commit it.", "Use a conventional commit message."]); + assert_eq!(turns.len(), 2, "absorbed input does not start a turn"); + } + + #[test] + fn tool_results_mark_failures_and_the_final_message_follows_the_last_tool_call() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let first = &turns[0]; + let tools = first + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + assert_eq!(tools, [("Edit", false), ("Bash", true), ("Bash", false)]); + assert!( + matches!(first.messages.last().and_then(|message| message.parts.last()), Some(Part::Text { text }) if text == "Renamed and the tests pass.") + ); + } + + #[test] + fn assistant_blocks_of_one_response_form_one_message() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let assistant_messages = turns[0] + .messages + .iter() + .filter(|message| message.role == Role::Assistant) + .count(); + // Response 1: commentary + Edit; response 2: Bash; response 3: Bash; response 4: answer. + assert_eq!(assistant_messages, 4); + assert_eq!(turns[0].messages[1].parts.len(), 2); + } + + #[test] + fn injected_markup_is_not_a_prompt() { + for injected in [ + "/clear\nclear", + "\nx\n", + "\nnote\n", + ] { + assert!(is_injected(injected), "{injected}"); + } + assert!(!is_injected("
hi")); + assert!(!is_injected("Compare and ")); + assert!(!is_injected("plain prompt")); + } +} + +#[cfg(test)] +mod input_preservation_tests { + #[test] + fn human_xml_prompts_survive_normal_and_queued_records() { + let prompt = "Reply exactly DONE"; + let records = [ + serde_json::json!({"type":"user", "message":{"content":prompt}}), + serde_json::json!({"type":"attachment", "attachment":{"type":"queued_command", "origin":{"kind":"human"}, "prompt":prompt}}), + ]; + for record in records { + let turns = super::parse(record.to_string().as_bytes()).expect("parse"); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].messages[0].parts[0], + crate::sessions::Part::Text { text: prompt.into() } + ); + } + } +} diff --git a/agentctl/src/harness/codex/authentication.rs b/agentctl/src/harness/codex/authentication.rs new file mode 100644 index 0000000..ec60f97 --- /dev/null +++ b/agentctl/src/harness/codex/authentication.rs @@ -0,0 +1,686 @@ +//! Codex CLI host-side `ChatGPT` subscription authentication. + +use std::{cell::RefCell, time::Instant, time::SystemTime}; + +use base64::Engine as _; +use sandbox::secret_store::{SecretMaterial, SecretReference, SecretStore as _}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{Error, harness::ImportedAuthentication, persistence}; + +use super::{ACCESS_PLACEHOLDER, ACCESS_SECRET, ACCOUNT_SECRET, PROVIDER, REFRESH_PLACEHOLDER, REFRESH_SECRET}; + +const REFRESH_URL: &str = "https://auth.openai.com/oauth/token"; +const OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const REFRESH_AHEAD_SECONDS: i64 = 5 * 60; +const TRANSIENT_FAILURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Owns a `ChatGPT` OAuth grant used only by the Agent stack. +pub(in crate::harness) struct Authentication { + database: persistence::Database, + client: reqwest::Client, + refresh_url: String, + refresh_lock: tokio::sync::Mutex<()>, + refresh_failure: RefCell>, +} + +impl Authentication { + #[must_use] + pub(in crate::harness) fn new(database: persistence::Database) -> Self { + Self { + database, + client: reqwest::Client::new(), + refresh_url: REFRESH_URL.into(), + refresh_lock: tokio::sync::Mutex::new(()), + refresh_failure: RefCell::new(None), + } + } + + /// Imports the independent `ChatGPT` grant produced in agentctl's private Codex home. + /// + /// An `auth.json` carrying the platform's own placeholders is the credential an Agent + /// Sandbox already holds. Importing it makes this `agentd` a nested one: the tokens are + /// stored verbatim, never refreshed, and the enclosing Sandbox's mediator substitutes the + /// real grant. No real credential exists at this level. An `imported` credential must be + /// such a placeholder file: importing a user's real `auth.json` would let this `agentd` and + /// the user's own Codex CLI rotate the same refresh token against each other. + pub(in crate::harness) async fn login( + &self, + credential: Zeroizing, + imported: bool, + ) -> Result { + let source: LoginFile = serde_json::from_str(&credential) + .map_err(|_| Error::Invalid("Codex login did not produce valid authentication data".into()))?; + if source.auth_mode.as_deref() != Some("chatgpt") { + return Err(Error::Invalid( + "Codex login did not produce a ChatGPT subscription grant".into(), + )); + } + let tokens = source + .tokens + .ok_or_else(|| Error::Invalid("Codex login did not produce ChatGPT tokens".into()))?; + let access_token = Zeroizing::new(tokens.access_token.trim().to_owned()); + let refresh_token = Zeroizing::new(tokens.refresh_token.trim().to_owned()); + let account_id = tokens + .account_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| Error::Invalid("Codex login did not identify a ChatGPT account".into()))?; + if access_token.is_empty() || refresh_token.is_empty() { + return Err(Error::Invalid("Codex login produced incomplete ChatGPT tokens".into())); + } + let kind = if *refresh_token == REFRESH_PLACEHOLDER && *access_token == ACCESS_PLACEHOLDER { + CredentialKind::Mediated + } else if imported { + return Err(Error::Invalid( + "only an Agent's mediated Codex credential file can be imported; run `agentctl codex login` on the host to sign in".into(), + )); + } else { + CredentialKind::ChatgptOauth + }; + let metadata = CodexMetadata { + kind, + account_id, + expires_at: jwt_expiry(&access_token)?, + }; + self.store(&access_token, &refresh_token, &metadata).await?; + self.refresh_failure.borrow_mut().take(); + Ok(ImportedAuthentication { + provider: PROVIDER.into(), + ready: true, + }) + } + + pub(in crate::harness) async fn resolve_access(&self) -> Result { + self.refresh_if_needed() + .await + .map_err(|error| sandbox::Error::Backend(error.to_string()))?; + self.database + .resolve(&SecretReference::from_opaque(ACCESS_SECRET)) + .await + } + + #[allow(clippy::option_if_let_else)] + async fn refresh_if_needed(&self) -> Result<(), Error> { + let _refresh = self.refresh_lock.lock().await; + let metadata = self.metadata().await?; + if matches!(metadata.kind, CredentialKind::Mediated) + || metadata.expires_at > unix_time()?.saturating_add(REFRESH_AHEAD_SECONDS) + { + return Ok(()); + } + if let Some(error) = self.cached_refresh_failure() { + return Err(error); + } + + let result = self.refresh(&metadata).await; + match &result { + Ok(()) => { + self.refresh_failure.borrow_mut().take(); + } + Err(failure) => { + let cached = match failure { + RefreshFailure::Permanent(message) => CachedRefreshFailure::Permanent(message.clone()), + RefreshFailure::Transient(message) => CachedRefreshFailure::Transient { + message: message.clone(), + retry_at: Instant::now() + TRANSIENT_FAILURE_COOLDOWN, + }, + }; + *self.refresh_failure.borrow_mut() = Some(cached); + } + } + result.map_err(RefreshFailure::into_error) + } + + fn cached_refresh_failure(&self) -> Option { + let mut cached = self.refresh_failure.borrow_mut(); + match cached.as_ref() { + Some(CachedRefreshFailure::Permanent(message)) => Some(Error::Invalid(message.clone())), + Some(CachedRefreshFailure::Transient { message, retry_at }) if Instant::now() < *retry_at => { + Some(Error::Invalid(message.clone())) + } + Some(CachedRefreshFailure::Transient { .. }) | None => { + cached.take(); + None + } + } + } + + async fn refresh(&self, metadata: &CodexMetadata) -> Result<(), RefreshFailure> { + let stored = self + .database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .map_err(|error| { + RefreshFailure::permanent(format!( + "Codex refresh credential is unavailable: {error}; run `agentctl codex login` again" + )) + })?; + let refresh_token = std::str::from_utf8(stored.expose()).map_err(|_| { + RefreshFailure::permanent("Codex refresh credential is invalid; run `agentctl codex login` again") + })?; + let response = self + .client + .post(&self.refresh_url) + .timeout(REQUEST_TIMEOUT) + .json(&RefreshRequest { + client_id: OAUTH_CLIENT_ID, + grant_type: "refresh_token", + refresh_token, + }) + .send() + .await + .map_err(|_| RefreshFailure::transient("could not reach OpenAI to refresh Codex authentication"))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let code = refresh_error_code(&body); + let invalid_grant = status == reqwest::StatusCode::BAD_REQUEST + && code + .as_deref() + .is_some_and(|code| code.eq_ignore_ascii_case("invalid_grant")); + let permanent = status == reqwest::StatusCode::UNAUTHORIZED + || invalid_grant + || matches!( + code.as_deref(), + Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated") + ); + if permanent { + let reason = match code.as_deref() { + Some("refresh_token_expired") => "the refresh token has expired", + Some("refresh_token_reused") => "the refresh token was already used", + Some("refresh_token_invalidated") => "the refresh token was revoked", + _ => "OpenAI rejected the refresh grant", + }; + return Err(RefreshFailure::permanent(format!( + "Codex authentication can no longer be refreshed because {reason}; run `agentctl codex login` again" + ))); + } + return Err(RefreshFailure::transient(format!( + "OpenAI temporarily failed to refresh Codex authentication (HTTP {status})" + ))); + } + let response: RefreshResponse = response + .json() + .await + .map_err(|_| RefreshFailure::transient("OpenAI returned an invalid Codex refresh response"))?; + let access_token = Zeroizing::new(response.access_token.trim().to_owned()); + if access_token.is_empty() { + return Err(RefreshFailure::transient("OpenAI returned an empty Codex access token")); + } + let refresh_token = Zeroizing::new( + response + .refresh_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(refresh_token) + .to_owned(), + ); + let replacement = CodexMetadata { + kind: CredentialKind::ChatgptOauth, + account_id: metadata.account_id.clone(), + expires_at: jwt_expiry(&access_token) + .map_err(|_| RefreshFailure::transient("OpenAI returned an invalid Codex access token"))?, + }; + self.store(&access_token, &refresh_token, &replacement) + .await + .map_err(|_| RefreshFailure::transient("could not store refreshed Codex authentication")) + } + + async fn store(&self, access_token: &str, refresh_token: &str, metadata: &CodexMetadata) -> Result<(), Error> { + self.database + .put_provider_account(persistence::ProviderAccountWrite { + provider: PROVIDER.into(), + credentials: vec![ + persistence::StoredSecret { + name: ACCESS_SECRET.into(), + value: Zeroizing::new(access_token.as_bytes().to_vec()), + }, + persistence::StoredSecret { + name: REFRESH_SECRET.into(), + value: Zeroizing::new(refresh_token.as_bytes().to_vec()), + }, + persistence::StoredSecret { + name: ACCOUNT_SECRET.into(), + value: Zeroizing::new(metadata.account_id.as_bytes().to_vec()), + }, + ], + metadata_json: serde_json::to_string(metadata)?, + }) + .await + } + + async fn metadata(&self) -> Result { + let metadata = self + .database + .provider_account_metadata(PROVIDER) + .await? + .ok_or_else(|| Error::Invalid("Codex authentication is not ready; run `agentctl codex login`".into()))?; + serde_json::from_str(&metadata) + .map_err(|_| Error::Invalid("stored Codex authentication metadata is invalid; log in again".into())) + } + + #[cfg(test)] + fn with_refresh_url(mut self, refresh_url: String) -> Self { + self.refresh_url = refresh_url; + self + } +} + +pub(super) async fn selected_account_id(database: &persistence::Database) -> Result { + let metadata = database + .provider_account_metadata(PROVIDER) + .await? + .ok_or_else(|| Error::Invalid("Codex authentication is not ready; run `agentctl codex login`".into()))?; + let metadata: CodexMetadata = serde_json::from_str(&metadata) + .map_err(|_| Error::Invalid("stored Codex authentication metadata is invalid; log in again".into()))?; + Ok(metadata.account_id) +} + +#[derive(Clone)] +enum RefreshFailure { + Permanent(String), + Transient(String), +} + +impl RefreshFailure { + fn permanent(message: impl Into) -> Self { + Self::Permanent(message.into()) + } + + fn transient(message: impl Into) -> Self { + Self::Transient(message.into()) + } + + fn into_error(self) -> Error { + Error::Invalid(match self { + Self::Permanent(message) | Self::Transient(message) => message, + }) + } +} + +enum CachedRefreshFailure { + Permanent(String), + Transient { message: String, retry_at: Instant }, +} + +#[derive(Deserialize)] +struct LoginFile { + auth_mode: Option, + tokens: Option, +} + +#[derive(Deserialize)] +struct LoginTokens { + access_token: String, + refresh_token: String, + account_id: Option, +} + +#[derive(Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum CredentialKind { + /// A real `ChatGPT` OAuth grant, refreshed by this `agentd`. + ChatgptOauth, + /// Placeholders from an enclosing Sandbox; an outer mediator holds the real grant. + Mediated, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CodexMetadata { + kind: CredentialKind, + account_id: String, + expires_at: i64, +} + +#[derive(Serialize)] +struct RefreshRequest<'a> { + client_id: &'static str, + grant_type: &'static str, + refresh_token: &'a str, +} + +#[derive(Deserialize)] +struct RefreshResponse { + access_token: String, + refresh_token: Option, +} + +fn refresh_error_code(body: &str) -> Option { + let value = serde_json::from_str::(body).ok()?; + let code = match value.get("error") { + Some(serde_json::Value::String(code)) => Some(code.clone()), + Some(serde_json::Value::Object(error)) => error.get("code")?.as_str().map(str::to_owned), + _ => value.get("code")?.as_str().map(str::to_owned), + }?; + Some(code.to_ascii_lowercase()) +} + +fn jwt_expiry(token: &str) -> Result { + let encoded = token + .split('.') + .nth(1) + .ok_or_else(|| Error::Invalid("Codex access token is not a JWT".into()))?; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| Error::Invalid("Codex access token has an invalid JWT payload".into()))?; + let claims: JwtClaims = serde_json::from_slice(&payload) + .map_err(|_| Error::Invalid("Codex access token has invalid JWT claims".into()))?; + Ok(claims.exp) +} + +fn unix_time() -> Result { + let seconds = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| Error::Invalid("host clock is before the Unix epoch".into()))? + .as_secs(); + i64::try_from(seconds).map_err(|_| Error::Invalid("host clock cannot be represented".into())) +} + +#[derive(Deserialize)] +struct JwtClaims { + exp: i64, +} + +pub(super) async fn is_ready(database: &persistence::Database) -> Result { + let Some(metadata) = database.provider_account_metadata(PROVIDER).await? else { + return Ok(false); + }; + if serde_json::from_str::(&metadata).is_err() { + return Ok(false); + } + Ok(database + .resolve(&SecretReference::from_opaque(ACCESS_SECRET)) + .await + .is_ok() + && database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .is_ok() + && database + .resolve(&SecretReference::from_opaque(ACCOUNT_SECRET)) + .await + .is_ok()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use std::{cell::Cell, rc::Rc}; + + use tempfile::TempDir; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + use super::*; + + fn jwt(exp: i64) -> String { + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::json!({ "exp": exp }).to_string()); + format!("header.{payload}.signature") + } + + fn login_file(access_token: &str, refresh_token: &str) -> Zeroizing { + Zeroizing::new( + serde_json::json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "header.payload.signature", + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": "account-canary" + }, + "last_refresh": "2026-08-24T00:00:00Z" + }) + .to_string(), + ) + } + + async fn serve_refresh(access_token: String, refresh_token: Option<&'static str>) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind refresh endpoint"); + let endpoint = format!("http://{}/oauth/token", listener.local_addr().expect("local address")); + tokio::task::spawn_local(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut request = [0_u8; 4_096]; + let read = stream.read(&mut request).await.expect("read request"); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.contains("refresh-canary")); + assert!(request.contains(OAUTH_CLIENT_ID)); + let body = serde_json::json!({ + "access_token": access_token, + "refresh_token": refresh_token, + }) + .to_string(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .expect("write response"); + }); + endpoint + } + + async fn serve_refresh_failure(status: &str, body: &'static str) -> (String, Rc>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind refresh endpoint"); + let endpoint = format!("http://{}/oauth/token", listener.local_addr().expect("local address")); + let requests = Rc::new(Cell::new(0)); + let observed = Rc::clone(&requests); + let status = status.to_owned(); + tokio::task::spawn_local(async move { + loop { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut request = [0_u8; 4_096]; + let _read = stream.read(&mut request).await.expect("read request"); + observed.set(observed.get() + 1); + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .expect("write response"); + } + }); + (endpoint, requests) + } + + #[tokio::test(flavor = "local")] + async fn imports_only_the_required_chatgpt_grant_state() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let manager = Authentication::new(database.clone()); + let access_token = jwt(unix_time().expect("time") + 3_600); + + let imported = manager + .login(login_file(&access_token, "refresh-canary"), false) + .await + .expect("login"); + + assert_eq!(imported.provider, "codex"); + assert!(imported.ready); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(ACCESS_SECRET)) + .await + .expect("access token") + .expose(), + access_token.as_bytes() + ); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .expect("refresh token") + .expose(), + b"refresh-canary" + ); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(ACCOUNT_SECRET)) + .await + .expect("account ID") + .expose(), + b"account-canary" + ); + assert!(is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn rejects_api_key_authentication_data() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let manager = Authentication::new(database.clone()); + + let error = manager + .login( + Zeroizing::new( + serde_json::json!({ "auth_mode": "apikey", "OPENAI_API_KEY": "secret-canary" }).to_string(), + ), + false, + ) + .await + .expect_err("reject API key"); + + assert!(error.to_string().contains("ChatGPT subscription grant")); + assert!(!error.to_string().contains("secret-canary")); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn placeholder_credentials_are_stored_verbatim_and_never_refreshed() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let (endpoint, refresh_calls) = serve_refresh_failure("500 Internal Server Error", "{}").await; + let manager = Authentication::new(database.clone()).with_refresh_url(endpoint); + let credential = Zeroizing::new( + serde_json::json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": ACCESS_PLACEHOLDER, + "access_token": ACCESS_PLACEHOLDER, + "refresh_token": REFRESH_PLACEHOLDER, + "account_id": "account-test" + }, + "last_refresh": "2026-08-24T00:00:00Z" + }) + .to_string(), + ); + manager.login(credential, true).await.expect("placeholder login"); + + let resolved = manager.resolve_access().await.expect("placeholder access token"); + + assert_eq!(resolved.expose(), ACCESS_PLACEHOLDER.as_bytes()); + assert_eq!( + selected_account_id(&database).await.expect("account ID"), + "account-test" + ); + assert_eq!(refresh_calls.get(), 0); + assert!(is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn a_real_credential_file_cannot_be_imported() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let manager = Authentication::new(database.clone()); + let access = jwt(unix_time().expect("time") + 3_600); + + let error = manager + .login(login_file(&access, "refresh-canary"), true) + .await + .expect_err("a real ChatGPT grant must not be imported"); + + assert!(error.to_string().contains("agentctl codex login")); + assert!(!error.to_string().contains("refresh-canary")); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn refreshes_and_rotates_before_resolving_the_access_token() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let old_access = jwt(unix_time().expect("time") - 1); + let new_access = jwt(unix_time().expect("time") + 3_600); + let endpoint = serve_refresh(new_access.clone(), Some("rotated-refresh")).await; + let manager = Authentication::new(database.clone()).with_refresh_url(endpoint); + manager + .login(login_file(&old_access, "refresh-canary"), false) + .await + .expect("login"); + + let (first, second) = tokio::join!(manager.resolve_access(), manager.resolve_access()); + let first = first.expect("first resolved token"); + let second = second.expect("second resolved token"); + + assert_eq!(first.expose(), new_access.as_bytes()); + assert_eq!(second.expose(), new_access.as_bytes()); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .expect("rotated refresh token") + .expose(), + b"rotated-refresh" + ); + } + + #[tokio::test(flavor = "local")] + async fn transient_refresh_failures_have_a_cooldown_without_login_guidance() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let expired_access = jwt(unix_time().expect("time") - 1); + let (endpoint, requests) = serve_refresh_failure("502 Bad Gateway", r#"{"error":"upstream_error"}"#).await; + let manager = Authentication::new(database).with_refresh_url(endpoint); + manager + .login(login_file(&expired_access, "refresh-canary"), false) + .await + .expect("login"); + + let first = manager.resolve_access().await.expect_err("first refresh fails"); + let second = manager.resolve_access().await.expect_err("cooldown retains failure"); + + assert_eq!(requests.get(), 1); + assert!(first.to_string().contains("temporarily failed")); + assert!(!first.to_string().contains("codex login")); + assert_eq!(first.to_string(), second.to_string()); + } + + #[tokio::test(flavor = "local")] + async fn terminal_refresh_failures_require_login_and_are_not_retried() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let expired_access = jwt(unix_time().expect("time") - 1); + let (endpoint, requests) = + serve_refresh_failure("400 Bad Request", r#"{"error":{"code":"refresh_token_reused"}}"#).await; + let manager = Authentication::new(database).with_refresh_url(endpoint); + manager + .login(login_file(&expired_access, "refresh-canary"), false) + .await + .expect("login"); + + let first = manager.resolve_access().await.expect_err("first refresh fails"); + let second = manager.resolve_access().await.expect_err("terminal failure retained"); + + assert_eq!(requests.get(), 1); + assert!(first.to_string().contains("already used")); + assert!(first.to_string().contains("agentctl codex login")); + assert_eq!(first.to_string(), second.to_string()); + } +} diff --git a/agentctl/src/harness/codex/bootstrap/linux.rs b/agentctl/src/harness/codex/bootstrap/linux.rs new file mode 100644 index 0000000..ae8d350 --- /dev/null +++ b/agentctl/src/harness/codex/bootstrap/linux.rs @@ -0,0 +1,185 @@ +//! Linux Sandbox configuration for mediated Codex CLI authentication. + +use sandbox::SandboxHandle; + +use crate::{ + Error, + sandbox::platform::{ + files::{read_existing, write_if_changed}, + run_checked, + }, +}; + +use super::super::{ACCESS_PLACEHOLDER, ACCOUNT_ENVIRONMENT, REFRESH_PLACEHOLDER}; + +pub(super) async fn configure( + sandbox: &SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + let skills_path = format!("{home}/.agents/skills"); + let config = format!("{home}/.codex"); + let hooks_path = format!("{config}/hooks"); + let auth_path = format!("{config}/auth.json"); + let hook_path = format!("{config}/hooks/activity-hook.mjs"); + let hooks_config_path = format!("{config}/hooks.json"); + let instructions_path = format!("{config}/AGENTS.md"); + + run_checked(sandbox, "/usr/bin/mkdir", ["-p", hooks_path.as_str()]).await?; + let account_id = sandbox + .snapshot() + .environment + .get(ACCOUNT_ENVIRONMENT) + .cloned() + .ok_or_else(|| Error::SandboxSetup("Codex account ID was not prepared for this Sandbox".into()))?; + // Codex must believe it owns a normal ChatGPT login while the real, + // rotating grant remains host-only. The fake JWT expiry and fresh refresh + // timestamp suppress proactive guest refresh; a 401 can only attempt the + // deliberately unusable placeholder refresh token. + let now = time::OffsetDateTime::now_utc(); + let last_refresh = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| Error::SandboxSetup(format!("could not format Codex refresh time: {error}")))?; + let auth = serde_json::json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": ACCESS_PLACEHOLDER, + "access_token": ACCESS_PLACEHOLDER, + "refresh_token": REFRESH_PLACEHOLDER, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }); + let existing = read_existing(sandbox, &auth_path, AUTH_MAX_BYTES).await; + if auth_needs_refresh(existing.as_deref(), &auth, now) { + write_if_changed(sandbox, &auth_path, &serde_json::to_vec(&auth)?).await?; + } + if let Some(instructions) = instructions { + write_if_changed(sandbox, &instructions_path, instructions).await?; + } + write_if_changed(sandbox, &hook_path, super::super::hooks::script()?.as_bytes()).await?; + let hooks = serde_json::to_vec(&super::super::hooks::configuration(&hook_path))?; + write_if_changed(sandbox, &hooks_config_path, &hooks).await?; + + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "/usr/bin/chown", + "agent:agent", + config.as_str(), + hooks_path.as_str(), + auth_path.as_str(), + hook_path.as_str(), + hooks_config_path.as_str(), + ], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["600", auth_path.as_str()]).await?; + if instructions.is_some() { + run_checked( + sandbox, + "/usr/bin/sudo", + ["/usr/bin/chown", "agent:agent", instructions_path.as_str()], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["644", instructions_path.as_str()]).await?; + } + crate::harness::skills::install_linux(sandbox, &skills_path, skills).await +} + +/// How long the placeholder login's refresh timestamp may age before setup rewrites it. +/// +/// Codex only refreshes proactively once the timestamp is much older than this, and a +/// rewrite every reconciliation pass would make the file churn for no reason. +const AUTH_REFRESH_MAX_AGE: time::Duration = time::Duration::hours(24); +/// Longest login file setup parses; the Sandbox user can write the file, so a larger one is +/// replaced rather than read. +const AUTH_MAX_BYTES: usize = 1024 * 1024; + +/// Whether the placeholder login on disk must be replaced by `desired`. +/// +/// The two differ in `last_refresh` on every pass by construction; that alone does not warrant a +/// rewrite until the recorded timestamp is older than [`AUTH_REFRESH_MAX_AGE`]. Anything else +/// unreadable, unparsable or different does. +fn auth_needs_refresh(existing: Option<&[u8]>, desired: &serde_json::Value, now: time::OffsetDateTime) -> bool { + let Some(mut existing) = existing.and_then(|bytes| serde_json::from_slice::(bytes).ok()) else { + return true; + }; + let Some(recorded) = existing + .get("last_refresh") + .and_then(serde_json::Value::as_str) + .and_then(|value| time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok()) + else { + return true; + }; + if now - recorded > AUTH_REFRESH_MAX_AGE || recorded > now { + return true; + } + if let Some(object) = existing.as_object_mut() { + object.remove("last_refresh"); + } + let mut desired = desired.clone(); + if let Some(object) = desired.as_object_mut() { + object.remove("last_refresh"); + } + existing != desired +} + +#[cfg(test)] +mod tests { + use super::auth_needs_refresh; + + fn desired() -> serde_json::Value { + serde_json::json!({ + "auth_mode": "chatgpt", + "tokens": {"access_token": "placeholder"}, + "last_refresh": "2026-09-17T12:00:00Z", + }) + } + + fn at(rfc3339: &str) -> time::OffsetDateTime { + time::OffsetDateTime::parse(rfc3339, &time::format_description::well_known::Rfc3339).expect("timestamp") + } + + #[test] + fn keeps_a_recent_equivalent_login() { + let existing = + br#"{"auth_mode":"chatgpt","tokens":{"access_token":"placeholder"},"last_refresh":"2026-09-17T09:00:00Z"}"#; + assert!(!auth_needs_refresh( + Some(existing), + &desired(), + at("2026-09-17T12:00:00Z") + )); + } + + #[test] + fn refreshes_a_stale_timestamp() { + let existing = + br#"{"auth_mode":"chatgpt","tokens":{"access_token":"placeholder"},"last_refresh":"2026-09-15T09:00:00Z"}"#; + assert!(auth_needs_refresh( + Some(existing), + &desired(), + at("2026-09-17T12:00:00Z") + )); + } + + #[test] + fn rewrites_when_the_login_differs_or_is_unreadable() { + let existing = + br#"{"auth_mode":"chatgpt","tokens":{"access_token":"other"},"last_refresh":"2026-09-17T09:00:00Z"}"#; + assert!(auth_needs_refresh( + Some(existing), + &desired(), + at("2026-09-17T12:00:00Z") + )); + assert!(auth_needs_refresh( + Some(b"not json"), + &desired(), + at("2026-09-17T12:00:00Z") + )); + assert!(auth_needs_refresh(None, &desired(), at("2026-09-17T12:00:00Z"))); + } +} diff --git a/agentctl/src/harness/codex/bootstrap/mod.rs b/agentctl/src/harness/codex/bootstrap/mod.rs new file mode 100644 index 0000000..2117d60 --- /dev/null +++ b/agentctl/src/harness/codex/bootstrap/mod.rs @@ -0,0 +1,12 @@ +//! Sandbox-platform-specific Codex CLI configuration. + +mod linux; + +pub(super) async fn configure_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), crate::Error> { + linux::configure(sandbox, home, instructions, skills).await +} diff --git a/agentctl/src/harness/codex/hooks.rs b/agentctl/src/harness/codex/hooks.rs new file mode 100644 index 0000000..a81c341 --- /dev/null +++ b/agentctl/src/harness/codex/hooks.rs @@ -0,0 +1,80 @@ +//! Codex's hook events and what each one tells the platform. + +use crate::{harness::hook_script::HookScript, sessions::ActivityEvent}; + +/// Every Codex hook event the platform folds. Codex 0.153 has no +/// `Notification`; Stop and Interrupt report a turn ending, not task success. +const EVENTS: &[(&str, ActivityEvent)] = &[ + ("SessionStart", ActivityEvent::SessionStart), + ("UserPromptSubmit", ActivityEvent::TurnStarted), + ("Stop", ActivityEvent::TurnCompleted), + ("Interrupt", ActivityEvent::TurnCompleted), + ("PermissionRequest", ActivityEvent::WaitingForInput), +]; + +/// Seconds Codex waits for the hook before continuing; a slow report must not +/// stall a turn. +const HOOK_TIMEOUT_SECONDS: u32 = 3; + +const SCRIPT: HookScript<'static> = HookScript { + events: EVENTS, + waiting_notifications: &[], +}; + +/// Renders Codex's activity hook script. +/// +/// # Errors +/// +/// Returns an error when the event table cannot be encoded. +pub(super) fn script() -> Result { + SCRIPT.render() +} + +/// The contents of Codex's `hooks.json`, registering the script for exactly +/// the events in the table. +pub(super) fn configuration(hook_path: &str) -> serde_json::Value { + let command = serde_json::json!({ + "type": "command", + "command": format!("node {hook_path}"), + "timeout": HOOK_TIMEOUT_SECONDS, + }); + let hooks = SCRIPT + .event_names() + .map(|event| { + let entry = serde_json::json!({ "hooks": [command] }); + (event.to_owned(), serde_json::Value::Array(vec![entry])) + }) + .collect::>(); + serde_json::json!({ "hooks": hooks }) +} + +#[cfg(test)] +mod tests { + use super::{EVENTS, configuration, script}; + use crate::harness::hook_script::embedded_events; + + #[test] + fn the_script_embeds_the_table_and_the_configuration_registers_it() { + let script = script().expect("script renders"); + let embedded = embedded_events(&script); + assert_eq!(embedded.len(), EVENTS.len()); + for (name, event) in EVENTS { + assert!(embedded.iter().any(|(n, e)| n == name && e == event), "{name}"); + } + assert!(script.contains("const WAITING_NOTIFICATIONS = [];")); + + let configuration = configuration("/home/agent/.codex/hooks/activity-hook.mjs"); + let registered = configuration["hooks"].as_object().expect("hooks object"); + assert_eq!(registered.len(), EVENTS.len()); + assert!(registered.get("Notification").is_none()); + for (name, _) in EVENTS { + let entry = ®istered[*name][0]; + assert_eq!( + entry["hooks"][0]["command"], + "node /home/agent/.codex/hooks/activity-hook.mjs" + ); + assert_eq!(entry["hooks"][0]["timeout"], 3); + assert!(entry.get("matcher").is_none()); + } + } +} diff --git a/agentctl/src/harness/codex/mod.rs b/agentctl/src/harness/codex/mod.rs new file mode 100644 index 0000000..df089b8 --- /dev/null +++ b/agentctl/src/harness/codex/mod.rs @@ -0,0 +1,400 @@ +//! `OpenAI` Codex CLI harness adapter. + +use std::{fmt::Write as _, io::Read as _}; + +use sandbox::secret_store::SecretReference; + +use crate::{ + Error, + harness::{LaunchRequest, MediatedSecret, ProcessLaunch, shell_single_quoted}, + persistence, +}; + +pub(super) mod authentication; +mod bootstrap; +mod hooks; +pub(super) mod transcript; + +const PROVIDER: &str = "codex"; +const ACCESS_SECRET: &str = "codex-access-token"; +const REFRESH_SECRET: &str = "codex-refresh-token"; +const ACCOUNT_SECRET: &str = "codex-account-id"; +pub(super) const ACCESS_ENVIRONMENT: &str = "AGENT_CODEX_ACCESS_TOKEN"; +const ACCOUNT_ENVIRONMENT: &str = "AGENT_CODEX_ACCOUNT_ID"; +const ACCESS_PLACEHOLDER: &str = concat!( + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.", + "eyJleHAiOjQxMDI0NDQ4MDAsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X2FjY291bnRfaWQiOiJhZ2VudC1tZWRpYXRlZC1jb2RleC1hY2NvdW50LXBsYWNlaG9sZGVyIn19.", + "agent" +); +const ACCOUNT_PLACEHOLDER: &str = "agent-mediated-codex-account-placeholder"; +const REFRESH_PLACEHOLDER: &str = "agent-mediated-codex-refresh-placeholder-not-a-real-token"; +const CHATGPT_HOST: &str = "chatgpt.com"; + +pub(super) fn owns_secret(reference: &SecretReference) -> bool { + reference.as_str() == ACCESS_SECRET +} + +pub(super) async fn authentication_ready(database: &persistence::Database) -> Result { + authentication::is_ready(database).await +} + +pub(super) async fn prepare(database: &persistence::Database) -> Result, Error> { + if !authentication::is_ready(database).await? { + return Err(Error::Invalid( + "Codex authentication is not ready; run `agentctl codex login`".into(), + )); + } + Ok(vec![ + MediatedSecret { + environment: ACCESS_ENVIRONMENT, + placeholder: ACCESS_PLACEHOLDER.into(), + reference: SecretReference::from_opaque(ACCESS_SECRET), + allowed_hosts: vec![CHATGPT_HOST.into()], + }, + MediatedSecret { + environment: ACCOUNT_ENVIRONMENT, + // The account ID is visible in authenticated workspace discovery. Codex 0.156 + // needs the selected ID locally to match that response before it can start. + placeholder: authentication::selected_account_id(database).await?, + reference: SecretReference::from_opaque(ACCOUNT_SECRET), + allowed_hosts: vec![CHATGPT_HOST.into()], + }, + ]) +} + +pub(super) fn conflicts_with_managed_secret(name: &str, placeholder: Option<&str>) -> bool { + matches!(name, ACCESS_ENVIRONMENT | ACCOUNT_ENVIRONMENT) + || matches!(placeholder, Some(ACCESS_PLACEHOLDER | ACCOUNT_PLACEHOLDER)) +} + +pub(super) fn manages_environment(name: &str) -> bool { + matches!( + name, + ACCESS_ENVIRONMENT | ACCOUNT_ENVIRONMENT | "CODEX_HOME" | "CODEX_CA_CERTIFICATE" + ) +} + +/// Creates a separate `ChatGPT` login grant without reading the user's Codex home. +pub(super) fn acquire_host_credential( + control_plane_home: &std::path::Path, +) -> Result, Error> { + let temporary_root = control_plane_home.join("tmp"); + std::fs::create_dir_all(&temporary_root)?; + crate::local::home::secure_directory(&temporary_root)?; + remove_stale_login_homes(&temporary_root); + let home = tempfile::Builder::new() + .prefix("codex-login-") + .tempdir_in(temporary_root)?; + let status = std::process::Command::new("codex") + .env("CODEX_HOME", home.path()) + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .args([ + "-c", + "cli_auth_credentials_store=\"file\"", + "-c", + "forced_login_method=\"chatgpt\"", + "login", + ]) + .status() + .map_err(|error| Error::Invalid(format!("could not start `codex login`: {error}")))?; + if !status.success() { + return Err(Error::Invalid(format!("`codex login` exited with {status}"))); + } + + let path = home.path().join("auth.json"); + if !std::fs::symlink_metadata(&path)?.file_type().is_file() { + return Err(Error::Invalid( + "`codex login` did not create a regular auth.json file".into(), + )); + } + let mut credential = zeroize::Zeroizing::new(String::new()); + std::fs::File::open(path)?.read_to_string(&mut credential)?; + if credential.trim().is_empty() { + return Err(Error::Invalid("`codex login` created an empty auth.json file".into())); + } + Ok(credential) +} + +fn remove_stale_login_homes(temporary_root: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(temporary_root) else { + return; + }; + for entry in entries.flatten() { + if entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("codex-login-")) + && entry.file_type().is_ok_and(|kind| kind.is_dir()) + { + let _ignored = std::fs::remove_dir_all(entry.path()); + } + } +} + +pub(super) async fn bootstrap_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + bootstrap::configure_linux(sandbox, home, instructions, skills).await +} + +pub(super) async fn verify_linux( + sandbox: &sandbox::SandboxHandle, + expected_version: Option<&str>, +) -> Result<(), Error> { + let output = super::version_output(sandbox, "codex").await?; + if !output.status.success() { + let message = format!("`codex --version` exited with code {}", output.status.code); + // 126/127 mean the image does not provide the harness; retrying cannot change that. + // Any other failure this early in the guest's life may be transient. + return Err(if matches!(output.status.code, 126 | 127) { + Error::Invalid(format!("Codex is missing: {message}")) + } else { + Error::SandboxSetup(message) + }); + } + let stdout = std::str::from_utf8(&output.stdout) + .map_err(|_| Error::SandboxSetup("`codex --version` returned non-UTF-8 output".into()))?; + let installed = stdout + .split_whitespace() + .nth(1) + .ok_or_else(|| Error::SandboxSetup("`codex --version` returned no version".into()))?; + if let Some(expected) = expected_version.filter(|expected| *expected != installed) { + return Err(Error::Invalid(format!( + "declared Codex version {expected:?} does not match installed version {installed:?}" + ))); + } + Ok(()) +} + +/// Codex's provisional composer accepts pastes but drops submission keys. The +/// launch-owned session-ID title appears only after `SessionConfigured`, and +/// survives the header scrolling offscreen during resume. The pinned TUI +/// truncates UUIDs to 29 ASCII characters plus three dots. +pub(super) fn input_ready_without_report(cursor_line: &str, title: &str) -> bool { + cursor_line.trim_start().starts_with("› ") + && title.strip_suffix("...").is_some_and(|prefix| { + prefix.len() == 29 + && prefix.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) + }) +} + +pub(super) fn launch_linux(request: &LaunchRequest<'_>) -> ProcessLaunch { + let config = format!("{}/.codex", request.home); + // Run Codex inside the Session's pane rather than on the shared background server + // Codex starts by default; launch overrides would otherwise fall back to embedded + // mode with a startup warning. + let flags = "--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --no-daemon"; + // Launch-only overrides keep adapter-owned authentication and the fixed + // Session root non-interactive without overwriting builder config.toml. + // Inline rendering lets tmux retain conversation output in pane history. + // https://developers.openai.com/codex/config-reference + let mut configuration = format!( + "-c 'cli_auth_credentials_store=\"file\"' -c 'tui.alternate_screen=\"never\"' -c 'check_for_update_on_startup=false' -c 'tui.terminal_title=[\"session-id\"]' \ + -c 'tui.status_line=[\"model-with-reasoning\",\"current-dir\",\"git-branch\",\"context-used\",\"weekly-limit\",\"codex-version\",\"fast-mode\"]' \ + -c 'projects.{}.trust_level=\"trusted\"'", + crate::sandbox::platform::WORKING_DIRECTORY + ); + // Codex takes the model as `-m`; effort has no flag of its own and travels as the + // `model_reasoning_effort` config override. Validated selections need no TOML escaping. + if let Some(model) = &request.model_selection.model { + let _infallible = write!(configuration, " -m {}", shell_single_quoted(model.as_str())); + } + if let Some(effort) = &request.model_selection.effort { + let _infallible = write!(configuration, " -c 'model_reasoning_effort=\"{}\"'", effort.as_str()); + } + let base = format!("codex {flags} {configuration}"); + // A fresh conversation may start on a positional prompt; `--` keeps a prompt + // that begins with `-` or names a subcommand (`resume`) positional. + let fresh = request.initial_prompt.map_or_else( + || base.clone(), + |message| format!("{base} -- {}", shell_single_quoted(message)), + ); + let resume = request.resume.and_then(|native| native.parse::().ok()); + let command = match resume { + Some(native) => format!( + "if /usr/bin/find {config}/sessions -type f \\( \ + -name 'rollout-*-{native}.jsonl' -o -name 'rollout-*-{native}.jsonl.zst' \\) \ + -print -quit 2>/dev/null | /usr/bin/grep -q .; \ + then exec codex resume {flags} {configuration} {native}; else exec {fresh}; fi" + ), + None => fresh, + }; + ProcessLaunch { + command, + environment: vec![ + ("CODEX_HOME".into(), config), + ( + "CODEX_CA_CERTIFICATE".into(), + "/etc/ssl/certs/ca-certificates.crt".into(), + ), + ], + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use crate::harness::{Effort, LaunchRequest, Model, ModelSelection}; + + const UNSELECTED: ModelSelection = ModelSelection { + model: None, + effort: None, + }; + + fn request<'a>(resume: Option<&'a str>, initial_prompt: Option<&'a str>) -> LaunchRequest<'a> { + LaunchRequest { + home: "/home/agent", + resume, + initial_prompt, + model_selection: &UNSELECTED, + } + } + + #[test] + fn input_readiness_waits_for_the_initialized_composer() { + let title = "01234567-1234-1234-1234-12345..."; + assert!(super::input_ready_without_report("› Ask Codex to do anything", title)); + assert!(super::input_ready_without_report(" › Find a bug", title)); + for title in ["", "agent-dev", "model: loading", "01234567-1234-1234-1234-1234g..."] { + assert!(!super::input_ready_without_report("› Ask Codex to do anything", title)); + } + for cursor in ["Starting Codex...", "Select a model", ""] { + assert!(!super::input_ready_without_report(cursor, title)); + } + } + + #[test] + fn stale_private_login_homes_are_removed_without_touching_other_files() { + let root = TempDir::new().expect("temporary directory"); + let stale = root.path().join("codex-login-stale"); + std::fs::create_dir(&stale).expect("stale login home"); + std::fs::write(stale.join("auth.json"), "refresh-canary").expect("stale credential"); + let unrelated = root.path().join("other-state"); + std::fs::create_dir(&unrelated).expect("unrelated state"); + + super::remove_stale_login_homes(root.path()); + + assert!(!stale.exists()); + assert!(unrelated.exists()); + } + + #[test] + fn every_executed_launch_uses_inline_scrollback_once() { + for resume in [None, Some("160cdb4b-5997-464c-9d22-602786eb45d4")] { + let launch = super::launch_linux(&request(resume, None)); + // Resume has two mutually exclusive commands: resume and fresh fallback. + let commands = launch.command.split("codex ").skip(1).collect::>(); + assert_eq!(commands.len(), if resume.is_some() { 2 } else { 1 }); + for command in commands { + assert_eq!(command.matches("tui.alternate_screen=\"never\"").count(), 1); + assert!(!command.contains("raw_output_mode")); + } + } + } + + #[test] + fn every_executed_launch_runs_without_the_shared_server() { + for resume in [None, Some("160cdb4b-5997-464c-9d22-602786eb45d4")] { + let launch = super::launch_linux(&request(resume, None)); + let commands = launch.command.split("codex ").skip(1).collect::>(); + assert_eq!(commands.len(), if resume.is_some() { 2 } else { 1 }); + for command in commands { + assert_eq!(command.matches("--no-daemon").count(), 1); + } + } + } + + #[test] + fn resume_launch_requires_a_native_rollout() { + let native = "160cdb4b-5997-464c-9d22-602786eb45d4"; + let launch = super::launch_linux(&request(Some(native), None)); + + assert!(launch.command.contains("/home/agent/.codex/sessions")); + assert!( + launch + .command + .contains("rollout-*-160cdb4b-5997-464c-9d22-602786eb45d4.jsonl'") + ); + assert!( + launch + .command + .contains("rollout-*-160cdb4b-5997-464c-9d22-602786eb45d4.jsonl.zst'") + ); + assert!(!launch.command.contains("_*.jsonl")); + assert!(!launch.command.contains(".jsonl*")); + assert!( + launch + .command + .contains("codex resume --dangerously-bypass-approvals-and-sandbox") + ); + assert!(launch.command.contains("cli_auth_credentials_store=\"file\"")); + assert!( + launch + .command + .contains("projects./home/agent/code.trust_level=\"trusted\"") + ); + assert!(launch.command.contains(native)); + assert!(launch.command.contains("else exec codex")); + } + + #[test] + fn non_uuid_native_id_is_not_a_codex_resume_target() { + let launch = super::launch_linux(&request(Some("opaque-harness-id"), None)); + + assert!(!launch.command.contains("codex resume")); + } + + #[test] + fn a_fresh_launch_passes_the_first_prompt_as_one_quoted_argument() { + let launch = super::launch_linux(&request(None, Some("fix it's\nbroken"))); + + assert!( + // `--` keeps a prompt that starts with `-` or names a subcommand positional. + launch.command.ends_with(" -- 'fix it'\\''s\nbroken'"), + "{}", + launch.command + ); + assert!(!launch.command.contains("codex resume")); + } + + #[test] + fn launches_select_no_model_or_effort_unless_the_session_carries_them() { + let launch = super::launch_linux(&request(None, None)); + + assert!(!launch.command.contains(" -m ")); + assert!(!launch.command.contains("model_reasoning_effort")); + } + + #[test] + fn model_and_effort_apply_to_fresh_and_resumed_conversations() { + let selection = ModelSelection { + model: Some(Model::new("gpt-5.4-codex").expect("model")), + effort: Some(Effort::new("high").expect("effort")), + }; + let launch = super::launch_linux(&LaunchRequest { + model_selection: &selection, + ..request(Some("160cdb4b-5997-464c-9d22-602786eb45d4"), Some("go")) + }); + + let selection = "-m 'gpt-5.4-codex' -c 'model_reasoning_effort=\"high\"'"; + assert_eq!(launch.command.matches(selection).count(), 2, "{}", launch.command); + assert!( + launch + .command + .contains(&format!("{selection} 160cdb4b-5997-464c-9d22-602786eb45d4;")) + ); + assert!(launch.command.contains(&format!("{selection} -- 'go'"))); + } +} diff --git a/agentctl/src/harness/codex/transcript.rs b/agentctl/src/harness/codex/transcript.rs new file mode 100644 index 0000000..8db7a72 --- /dev/null +++ b/agentctl/src/harness/codex/transcript.rs @@ -0,0 +1,533 @@ +//! Parsing the Codex rollout JSONL into runtime-neutral turns. +//! +//! A rollout records `event_msg` lines that delimit turns (`task_started`, +//! `task_complete`, `turn_aborted`) and `response_item` lines with the model +//! conversation. Codex injects context as `user`-role messages ahead of the +//! operator's prompt (AGENTS.md, environment details), so a user message is +//! never a turn boundary here: the turn markers are, and within a turn the last +//! user message before the model's first output is the operator's prompt. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::{ + Error, + sessions::{Message, Part, Role, Turn}, +}; + +/// Codex prefixes a bundled context-and-request user message with this marker. +const REQUEST_MARKER: &str = "## My request for Codex:"; + +// Codex 0.153 keeps success as internal metadata. Command results persist their +// exit code in the tool's output header instead (core/src/tools/{mod,context}.rs). +// Inspect only recognized command headers, never arbitrary command stdout. +fn command_failed(name: &str, output: Option<&Value>) -> bool { + let Some(output) = output.and_then(|output| { + output + .as_str() + .or_else(|| output.as_array()?.first()?.get("text")?.as_str()) + }) else { + return false; + }; + let prefix = match name { + "shell" | "shell_command" => "Exit code: ", + "exec_command" | "write_stdin" => "Process exited with code ", + _ => return false, + }; + output + .lines() + .take_while(|line| *line != "Output:") + .filter_map(|line| line.strip_prefix(prefix)) + .filter_map(|code| code.parse::().ok()) + .any(|code| code != 0) +} + +fn is_command_tool(name: &str) -> bool { + matches!( + name, + "exec" | "exec_command" | "shell" | "shell_command" | "write_stdin" + ) +} + +/// Parses Codex rollout JSONL into ordered turns. +/// +/// # Errors +/// +/// Returns an error when the transcript is not UTF-8. Lines that are not JSON +/// objects are skipped, so a partially written trailing line never fails a read. +pub(crate) fn parse(bytes: &[u8]) -> Result, Error> { + let text = + std::str::from_utf8(bytes).map_err(|error| Error::Session(format!("transcript is not UTF-8: {error}")))?; + let mut builder = Builder::default(); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + if let Ok(entry) = serde_json::from_str::(line) { + builder.push(&entry); + } + } + Ok(builder.finish()) +} + +/// Drops records before the first turn boundary in a bounded rollout suffix. +pub(crate) fn trim_partial(bytes: &[u8]) -> &[u8] { + let mut offset = 0; + for line in bytes.split_inclusive(|byte| *byte == b'\n') { + if serde_json::from_slice::(line).is_ok_and(|entry| { + entry.get("type").and_then(Value::as_str) == Some("event_msg") + && matches!( + entry.pointer("/payload/type").and_then(Value::as_str), + Some("task_started" | "turn_started") + ) + }) { + return &bytes[offset..]; + } + offset += line.len(); + } + &bytes[bytes.len()..] +} + +#[derive(Default)] +struct Builder { + turns: Vec, + /// Candidate prompt: the latest user message seen before the model's first + /// output in the current turn. Earlier candidates were injected context. + pending_prompt: Option, + /// Whether the current turn has recorded model output yet. + answered: bool, + /// Location of each recorded tool call by `call_id`. + tool_calls: HashMap, + /// Failure observed in nested code-mode `CommandExecution` records since + /// the last outer `exec` output. Codex assigns nested calls an `exec-*` ID, + /// distinct from the outer custom tool call's `call_id`. + pending_nested_command_failed: Option, +} + +impl Builder { + fn push(&mut self, entry: &Value) { + let Some(payload) = entry.get("payload") else { + return; + }; + match entry.get("type").and_then(Value::as_str) { + Some("event_msg") => match payload.get("type").and_then(Value::as_str) { + Some("task_started" | "turn_started") => self.start_turn(), + Some("task_complete" | "turn_complete" | "turn_aborted") => self.flush_prompt(), + Some("patch_apply_end") => { + if payload.get("success").and_then(Value::as_bool) == Some(false) { + self.mark_tool_failed(payload); + } + } + Some("mcp_tool_call_end") + if payload.pointer("/result/Err").is_some() + || payload.pointer("/result/Ok/isError").and_then(Value::as_bool) == Some(true) => + { + self.mark_tool_failed(payload); + } + Some("item_completed") + if payload.pointer("/item/type").and_then(Value::as_str) == Some("CommandExecution") => + { + self.record_command_result(&payload["item"]); + } + _ => {} + }, + Some("response_item") => self.push_item(payload), + _ => {} + } + } + + fn start_turn(&mut self) { + self.flush_prompt(); + if self.turns.last().is_none_or(|turn| !turn.messages.is_empty()) { + self.turns.push(Turn::default()); + } + self.answered = false; + self.pending_nested_command_failed = None; + } + + fn push_item(&mut self, payload: &Value) { + match payload.get("type").and_then(Value::as_str) { + Some("message") => { + let text = payload + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .unwrap_or_default(); + match payload.get("role").and_then(Value::as_str) { + Some("user") => self.push_user(text), + Some("assistant") if !text.is_empty() => { + self.push_output(Message { + role: Role::Assistant, + parts: vec![Part::Text { text }], + }); + } + // Developer and system messages are harness context, not conversation. + _ => {} + } + } + Some("function_call" | "custom_tool_call") => { + let Some(name) = payload.get("name").and_then(Value::as_str) else { + return; + }; + let location = self.push_output(Message { + role: Role::Assistant, + parts: vec![Part::ToolCall { + name: name.to_owned(), + failed: false, + }], + }); + if let Some(call_id) = payload.get("call_id").and_then(Value::as_str) { + self.tool_calls.insert(call_id.to_owned(), location); + } + } + Some("function_call_output" | "custom_tool_call_output") => { + let nested_failed = self.pending_nested_command_failed.unwrap_or(false); + let mut consumed_nested_result = false; + if let Some(Part::ToolCall { name, failed }) = self.tool_part(payload) { + *failed |= command_failed(name, payload.get("output")); + if name == "exec" { + *failed |= nested_failed; + consumed_nested_result = true; + } + } + if consumed_nested_result { + self.pending_nested_command_failed = None; + } + } + _ => {} + } + } + + fn tool_part(&mut self, payload: &Value) -> Option<&mut Part> { + let call_id = payload.get("call_id")?.as_str()?; + self.tool_part_by_call_id(call_id) + } + + fn tool_part_by_call_id(&mut self, call_id: &str) -> Option<&mut Part> { + let &(turn, message, part) = self.tool_calls.get(call_id)?; + self.turns.get_mut(turn)?.messages.get_mut(message)?.parts.get_mut(part) + } + + fn record_command_result(&mut self, item: &Value) { + let Some(id) = item.get("id").and_then(Value::as_str) else { + return; + }; + let failed = matches!(item.get("status").and_then(Value::as_str), Some("failed" | "declined")); + if let Some(Part::ToolCall { + name, + failed: tool_failed, + }) = self.tool_part_by_call_id(id) + { + if is_command_tool(name) { + *tool_failed |= failed; + } + } else if id.starts_with("exec-") { + self.pending_nested_command_failed = Some(self.pending_nested_command_failed.unwrap_or(false) || failed); + } + } + + fn mark_tool_failed(&mut self, payload: &Value) { + if let Some(Part::ToolCall { failed, .. }) = self.tool_part(payload) { + *failed = true; + } + } + + fn push_user(&mut self, text: String) { + let request = text + .strip_prefix("") + .and_then(|context| context.split_once("")) + .and_then(|(_, suffix)| suffix.trim_start().strip_prefix(REQUEST_MARKER)) + .map(|request| request.trim().to_owned()); + let text = request.unwrap_or(text); + if self.answered { + // Operator input injected mid-turn (steering) is conversation. + self.current_turn().messages.push(Message { + role: Role::User, + parts: vec![Part::Text { text }], + }); + } else { + self.pending_prompt = Some(text); + } + } + + fn push_output(&mut self, message: Message) -> (usize, usize, usize) { + self.flush_prompt(); + self.answered = true; + let turn_index = self.turns.len().saturating_sub(1); + let turn = self.current_turn(); + turn.messages.push(message); + let message_index = turn.messages.len() - 1; + (turn_index, message_index, turn.messages[message_index].parts.len() - 1) + } + + /// Commits the surviving prompt candidate as the turn's operator message. + fn flush_prompt(&mut self) { + if let Some(text) = self.pending_prompt.take() { + self.current_turn().messages.push(Message { + role: Role::User, + parts: vec![Part::Text { text }], + }); + } + } + + fn current_turn(&mut self) -> &mut Turn { + if self.turns.is_empty() { + self.turns.push(Turn::default()); + } + let last = self.turns.len() - 1; + &mut self.turns[last] + } + + fn finish(mut self) -> Vec { + self.flush_prompt(); + self.turns.retain(|turn| !turn.messages.is_empty()); + self.turns + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = include_str!("../../../tests/fixtures/codex-rollout.jsonl"); + + #[test] + fn a_bounded_suffix_discards_a_partial_turn() { + let suffix = concat!( + r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"text":"partial"}]}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"complete"}]}}"#, + "\n", + ); + + let turns = parse(trim_partial(suffix.as_bytes())).expect("suffix"); + + assert!(matches!(turns[0].messages[0].parts[0], Part::Text { ref text } if text == "complete")); + } + + fn prompts(turns: &[Turn]) -> Vec<&str> { + turns + .iter() + .map(|turn| match &turn.messages[0].parts[0] { + Part::Text { text } => text.as_str(), + Part::ToolCall { .. } => panic!("a turn starts with the prompt"), + }) + .collect() + } + + #[test] + fn recorded_rollout_yields_operator_turns_and_drops_injected_context() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + assert_eq!( + prompts(&turns), + ["Measure the desk and draw it.", "Make the top bar slimmer."] + ); + assert!( + turns + .iter() + .flat_map(|turn| &turn.messages) + .flat_map(|message| &message.parts) + .all(|part| !matches!(part, Part::Text { text } if text.contains("AGENTS.md"))), + "AGENTS.md context is not conversation" + ); + } + + #[test] + fn tool_calls_failures_and_final_message_are_recorded() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let first = &turns[0]; + let tools = first + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + assert_eq!(tools, [("exec_command", false), ("shell", true)]); + assert!( + matches!(first.messages.last().and_then(|message| message.parts.last()), Some(Part::Text { text }) if text == "Drawn to scale.") + ); + } + + #[test] + fn failures_use_command_headers_and_native_patch_and_mcp_results() { + for (name, output, event, expected) in [ + ( + "exec_command", + "Chunk ID: abc\nWall time: 0.1 seconds\nProcess exited with code 2\nOutput:\nmissing file", + serde_json::Value::Null, + true, + ), + ( + "exec_command", + "Chunk ID: abc\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\nProcess exited with code 2", + serde_json::Value::Null, + false, + ), + ( + "exec_command", + "Chunk ID: abc\nWall time: 0.1 seconds\nProcess running with session ID 123\nOutput:\n", + serde_json::Value::Null, + false, + ), + ( + "apply_patch", + "", + serde_json::json!({"type":"patch_apply_end", "call_id":"c", "success":false, "status":"failed"}), + true, + ), + ( + "mcp__example__read", + "", + serde_json::json!({"type":"mcp_tool_call_end", "call_id":"c", "result":{"Err":"connection closed"}}), + true, + ), + ( + "mcp__example__read", + "", + serde_json::json!({"type":"mcp_tool_call_end", "call_id":"c", "result":{"Ok":{"content":[], "isError":true}}}), + true, + ), + ] { + let lines = [ + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"c", "name":name}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"c", "output":output}}), + serde_json::json!({"type":"event_msg", "payload":event}), + ]; + let transcript = lines.iter().map(ToString::to_string).collect::>().join("\n"); + let turns = parse(transcript.as_bytes()).expect("parse"); + assert!( + matches!(&turns[0].messages[0].parts[0], Part::ToolCall { failed, .. } if *failed == expected), + "{name}: {transcript}" + ); + } + } + + #[test] + fn direct_command_results_match_call_ids_out_of_order() { + let lines = [ + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"a", "name":"exec_command"}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"b", "name":"write_stdin"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"b", "status":"failed", "exit_code":1}}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"a", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"a", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"b", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"c", "name":"exec_command"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"c", "status":"declined", "exit_code":-1}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"c", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"d", "name":"exec_command"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"missing", "status":"failed", "exit_code":1}}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"d", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"d", "output":[]}}), + ]; + let transcript = lines.iter().map(ToString::to_string).collect::>().join("\n"); + + let turns = parse(transcript.as_bytes()).expect("parse"); + let tools = turns[0] + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + + assert_eq!( + tools, + [ + ("exec_command", false), + ("write_stdin", true), + ("exec_command", true), + ("exec_command", false), + ] + ); + } + + #[test] + fn nested_code_mode_results_are_aggregated_for_the_outer_exec() { + let lines = [ + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call", "call_id":"outer-a", "name":"exec"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"exec-child-a1", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"exec-child-a2", "status":"failed", "exit_code":7}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"direct", "name":"exec_command"}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"direct", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call_output", "call_id":"outer-a", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call", "call_id":"outer-b", "name":"exec"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"exec-child-b", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call_output", "call_id":"outer-b", "output":[]}}), + ]; + let transcript = lines.iter().map(ToString::to_string).collect::>().join("\n"); + + let turns = parse(transcript.as_bytes()).expect("parse"); + let tools = turns[0] + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + + assert_eq!(tools, [("exec", true), ("exec_command", false), ("exec", false)]); + } + + #[test] + fn a_bundled_request_keeps_only_the_operator_text() { + let jsonl = concat!( + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"x\n\n## My request for Codex:\nhello"}]}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"task_complete"}}"#, + "\n", + ); + let turns = parse(jsonl.as_bytes()).expect("parse"); + assert_eq!(prompts(&turns), ["hello"]); + assert!( + matches!(turns[0].messages.last().and_then(|message| message.parts.last()), Some(Part::Text { text }) if text == "hi") + ); + } + + #[test] + fn an_aborted_turn_closes_and_the_next_prompt_starts_a_new_one() { + let jsonl = concat!( + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first"}]}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"turn_aborted"}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"second"}]}}"#, + "\n", + ); + let turns = parse(jsonl.as_bytes()).expect("parse"); + assert_eq!(prompts(&turns), ["first", "second"]); + } +} + +#[cfg(test)] +mod input_preservation_tests { + #[test] + fn a_literal_request_heading_does_not_truncate_operator_input() { + let prompt = "Explain this heading: ## My request for Codex: keep all of this"; + let record = serde_json::json!({"type":"response_item", "payload":{"type":"message", "role":"user", "content":[{"type":"input_text", "text":prompt}]}}); + let turns = super::parse(record.to_string().as_bytes()).expect("parse"); + assert_eq!( + turns[0].messages[0].parts[0], + crate::sessions::Part::Text { text: prompt.into() } + ); + } +} diff --git a/agentctl/src/harness/hook_script.rs b/agentctl/src/harness/hook_script.rs new file mode 100644 index 0000000..ed9c02b --- /dev/null +++ b/agentctl/src/harness/hook_script.rs @@ -0,0 +1,259 @@ +//! Renderer for the harness activity report hook. +//! +//! Both supported harnesses run hook commands with a JSON description of the +//! event on stdin, so one script shape serves both. Everything harness-specific +//! (which `hook_event_name`s exist, what each one means to the platform, which +//! notification types matter) is a table the adapter owns and hands to +//! [`HookScript::render`]; this module knows only the wire contract with the +//! Platform API. + +use crate::sessions::ActivityEvent; + +/// The harness-specific table an activity hook script is rendered from. +pub(super) struct HookScript<'a> { + /// Harness hook event names and the platform signal each one carries. + pub(super) events: &'a [(&'a str, ActivityEvent)], + /// `Notification` types that mean the harness is blocked on the operator; + /// other notifications carry no signal. Empty when the harness has no + /// notification hook. + pub(super) waiting_notifications: &'a [&'a str], +} + +const TEMPLATE: &str = r#"import { randomUUID } from "node:crypto"; + +const url = process.env.AGENT_SESSION_HOOK_URL; +const token = process.env.AGENT_SESSION_TOKEN; +const sessionId = process.env.AGENT_SESSION_ID; + +// Harness hook event -> platform activity signal. Rendered from the adapter's +// table; events not listed carry no signal and are ignored. +const EVENTS = __EVENTS__; +const WAITING_NOTIFICATIONS = __WAITING_NOTIFICATIONS__; + +function read(stream) { + // Codex 0.156 keeps stdin open while waiting for the hook to exit. + // Resolve on a complete object without waiting for EOF or stream cleanup. + return new Promise((resolve) => { + let data = ""; + const finish = (value) => { + stream.off("data", onData); + stream.off("end", onEnd); + stream.off("error", onEnd); + resolve(value); + }; + const parse = () => { + try { + const value = JSON.parse(data); + return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch { + return null; + } + }; + const onData = (chunk) => { + data += chunk; + if (data.length > 1048576) { + finish(null); + } else { + const value = parse(); + if (value !== null) finish(value); + } + }; + const onEnd = () => finish(parse()); + stream.setEncoding("utf8"); + stream.on("data", onData); + stream.on("end", onEnd); + stream.on("error", onEnd); + }); +} + +const input = await read(process.stdin); +if (!url || !token || !sessionId || input === null) process.exit(0); +const event = EVENTS[input.hook_event_name]; +if (!event) process.exit(0); +// A nested Agent's own reports carry an agent_id; never forward those. +if (input.agent_id) process.exit(0); +// Only notifications that block on the operator are activity. +if ( + input.hook_event_name === "Notification" && + typeof input.notification_type === "string" && + !WAITING_NOTIFICATIONS.includes(input.notification_type) +) { + process.exit(0); +} + +const body = { sessionId, eventId: randomUUID(), event, source: typeof input.source === "string" ? input.source : "" }; +if (event === "sessionStart") { + if (typeof input.session_id !== "string" || input.session_id === "") process.exit(0); + body.nativeSessionId = input.session_id; + if (typeof input.transcript_path === "string" && input.transcript_path !== "") { + body.transcriptPath = input.transcript_path; + } +} +const payload = JSON.stringify(body); + +// Start and terminal reports unblock callers, so they retry within a strict budget. +// The payload keeps the same event ID across retries, including a lost response. +// Other activity uses one short best-effort attempt. +const retryable = ["sessionStart", "turnCompleted", "waitingForInput"].includes(event); +const attempts = retryable ? 3 : 1; +const budget = retryable ? 1500 : 300; +const perAttempt = retryable ? 450 : 250; +const deadline = Date.now() + budget; +for (let attempt = 0; attempt < attempts; attempt += 1) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + try { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: payload, + signal: AbortSignal.timeout(Math.min(perAttempt, remaining)), + }); + if (response.ok) break; + } catch {} + if (attempt < attempts - 1) { + const pause = Math.min(75, deadline - Date.now()); + if (pause > 0) await new Promise((resolve) => setTimeout(resolve, pause)); + } +} +process.exit(0); +"#; + +impl HookScript<'_> { + /// Renders the hook script with the adapter's tables embedded. + /// + /// # Errors + /// + /// Returns an error when the tables cannot be encoded as JSON. + pub(super) fn render(&self) -> Result { + let events = self + .events + .iter() + .map(|(name, event)| Ok(((*name).to_owned(), serde_json::to_value(event)?))) + .collect::, serde_json::Error>>()?; + let events = serde_json::to_string(&serde_json::Value::Object(events))?; + let waiting = serde_json::to_string(self.waiting_notifications)?; + Ok(TEMPLATE + .replace("__EVENTS__", &events) + .replace("__WAITING_NOTIFICATIONS__", &waiting)) + } + + /// Hook event names the adapter registers: exactly the table's keys. + pub(super) fn event_names(&self) -> impl Iterator { + self.events.iter().map(|(name, _)| *name) + } +} + +/// Parses the event table back out of a rendered script, in the wire format +/// the Platform API reads; adapters use it to prove their table round-trips. +#[cfg(test)] +pub(super) fn embedded_events(script: &str) -> Vec<(String, ActivityEvent)> { + let start = script.find("const EVENTS = ").expect("table") + "const EVENTS = ".len(); + let end = script[start..].find(";\n").expect("terminator") + start; + let table: serde_json::Map = + serde_json::from_str(&script[start..end]).expect("embedded JSON"); + table + .into_iter() + .map(|(name, value)| (name, serde_json::from_value(value).expect("wire value parses"))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{HookScript, embedded_events}; + use crate::sessions::ActivityEvent; + + #[tokio::test] + async fn hook_exits_after_complete_json_even_when_stdin_remains_open() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let script = HookScript { + events: &[("SessionStart", ActivityEvent::SessionStart)], + waiting_notifications: &[], + } + .render() + .expect("script"); + let script = script.replacen( + "const input = await read(process.stdin);", + "process.stdout.write('ready\\n');\nconst input = await read(process.stdin);", + 1, + ); + let Ok(mut child) = tokio::process::Command::new("node") + .arg("--input-type=module") + .arg("-e") + .arg(script) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .env_remove("AGENT_SESSION_HOOK_URL") + .env_remove("AGENT_SESSION_TOKEN") + .env_remove("AGENT_SESSION_ID") + .kill_on_drop(true) + .spawn() + else { + // Node is optional for Rust-only development environments. + return; + }; + let mut ready = [0; 6]; + tokio::time::timeout( + std::time::Duration::from_secs(10), + child.stdout.as_mut().expect("stdout").read_exact(&mut ready), + ) + .await + .expect("Node started") + .expect("Node reported readiness"); + assert_eq!(&ready, b"ready\n"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(br#"{"hook_event_name":"SessionStart"}"#) + .await + .expect("write hook payload"); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if let Some(status) = child.try_wait().expect("hook status") { + assert!(status.success()); + break; + } + assert!(tokio::time::Instant::now() < deadline, "hook waited for stdin EOF"); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + #[test] + fn renders_the_given_tables_verbatim() { + let script = HookScript { + events: &[ + ("Begin", ActivityEvent::TurnStarted), + ("End", ActivityEvent::TurnCompleted), + ], + waiting_notifications: &["ask"], + } + .render() + .expect("script renders"); + assert_eq!( + embedded_events(&script), + [ + ("Begin".to_owned(), ActivityEvent::TurnStarted), + ("End".to_owned(), ActivityEvent::TurnCompleted), + ] + ); + assert!(script.contains(r#"const WAITING_NOTIFICATIONS = ["ask"];"#)); + assert!(!script.contains("__EVENTS__")); + } + + #[test] + fn session_start_carries_identity_and_retries_within_one_budget() { + let script = HookScript { + events: &[("SessionStart", ActivityEvent::SessionStart)], + waiting_notifications: &[], + } + .render() + .expect("script renders"); + assert!(script.contains("body.nativeSessionId = input.session_id;")); + assert!(script.contains("body.transcriptPath = input.transcript_path;")); + assert!(script.contains("retryable ? 3 : 1")); + assert!(script.contains("retryable ? 1500 : 300")); + } +} diff --git a/agentctl/src/harness/mod.rs b/agentctl/src/harness/mod.rs new file mode 100644 index 0000000..c4f56d8 --- /dev/null +++ b/agentctl/src/harness/mod.rs @@ -0,0 +1,597 @@ +//! Harness-specific adapters behind the closed Agent manifest harness selection. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{Error, persistence}; + +mod claude_code; +mod codex; +mod hook_script; +mod skills; + +const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const MAX_INITIAL_PROMPT_ARGUMENT_BYTES: usize = 64 * 1024; +const MAX_SELECTION_CHARACTERS: usize = 128; + +pub(crate) use skills::{Skill, SkillFile}; + +/// Supported harnesses. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Harness { + /// Anthropic Claude Code. + ClaudeCode, + /// `OpenAI` Codex CLI. + Codex, +} + +impl Harness { + /// Returns the manifest and CLI spelling of this harness family. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ClaudeCode => "claudeCode", + Self::Codex => "codex", + } + } +} + +impl std::fmt::Display for Harness { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Harness { + type Err = Error; + + fn from_str(value: &str) -> Result { + match value { + "claudeCode" => Ok(Self::ClaudeCode), + "codex" => Ok(Self::Codex), + _ => Err(Error::Invalid(format!("unsupported harness {value:?}"))), + } + } +} + +/// Supported harness authentication modes. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum HarnessAuthMode { + /// Credentials remain on the host and are injected into authorized requests. + Mediated, +} + +/// Declares a validated, provider-owned launch selection carried as an opaque string. +/// +/// Model names and effort levels belong to the harness vendor: they differ between +/// harnesses and gain new values without a platform release, so the platform only +/// checks that a value can travel safely to the harness command line. +macro_rules! launch_selection { + ($(#[$doc:meta])* $name:ident, $label:literal) => { + $(#[$doc])* + #[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] + #[serde(try_from = "String", into = "String")] + pub struct $name(String); + + impl $name { + /// Creates a validated selection. + /// + /// # Errors + /// + /// Returns an error unless the value is 1–128 ASCII letters, digits or + /// `-`, `_`, `.`, `:`, `/`, `@`, `+`. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_selection($label, &value)?; + Ok(Self(value)) + } + + /// Returns the selection as the text handed to the harness. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl TryFrom for $name { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } + } + + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + + impl std::str::FromStr for $name { + type Err = Error; + + fn from_str(value: &str) -> Result { + Self::new(value) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } + } + }; +} + +launch_selection! { + /// Harness-owned model selection, such as a Claude Code alias or a Codex model name. + Model, "model" +} + +launch_selection! { + /// Harness-owned effort or reasoning level, such as `high`. + Effort, "effort" +} + +/// A harness's model and effort level, each optional and provider-owned. +/// +/// Declared on a harness installation as the defaults for its new Sessions, +/// requested when a Session is created, and recorded with the Session as the +/// selection every launch of its harness applies. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ModelSelection { + /// Model name in the harness's own spelling; `None` leaves the harness default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Effort level in the harness's own spelling; `None` leaves the harness default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, +} + +impl ModelSelection { + /// Whether neither a model nor an effort level is selected. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.model.is_none() && self.effort.is_none() + } + + /// Fills each unselected field from `defaults`, field by field. + #[must_use] + pub fn or(self, defaults: &Self) -> Self { + Self { + model: self.model.or_else(|| defaults.model.clone()), + effort: self.effort.or_else(|| defaults.effort.clone()), + } + } + + /// Returns the model as text, when selected. + #[must_use] + pub fn model_str(&self) -> Option<&str> { + self.model.as_ref().map(Model::as_str) + } + + /// Returns the effort level as text, when selected. + #[must_use] + pub fn effort_str(&self) -> Option<&str> { + self.effort.as_ref().map(Effort::as_str) + } + + /// Describes the first field `requested` selects differently from this + /// recorded selection, as a clause following the Session's name; `None` + /// when every requested field matches or is unselected. + #[must_use] + pub fn conflict_with(&self, requested: &Self) -> Option { + [ + ("model", self.model_str(), requested.model_str()), + ("effort", self.effort_str(), requested.effort_str()), + ] + .into_iter() + .find_map(|(field, recorded, requested)| { + let requested = requested.filter(|requested| Some(*requested) != recorded)?; + Some(recorded.map_or_else( + || format!("leaves the {field} to the harness default, not {requested:?}"), + |recorded| format!("already uses {field} {recorded:?}, not {requested:?}"), + )) + }) + } +} + +fn validate_selection(label: &str, value: &str) -> Result<(), Error> { + if value.is_empty() + || value.chars().count() > MAX_SELECTION_CHARACTERS + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/' | b'@' | b'+')) + { + return Err(Error::Invalid(format!( + "{label} must be 1-{MAX_SELECTION_CHARACTERS} ASCII letters, digits or '-', '_', '.', ':', '/', '@', '+'" + ))); + } + Ok(()) +} + +/// One harness installation declared for an Agent. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HarnessSpec { + /// Closed harness family identifier. + #[serde(rename = "type")] + pub kind: Harness, + /// Exact version installed by the Agent image; omitted when the image owns the version, so image bumps need no manifest change. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Authentication delivery mode. + pub auth: HarnessAuthMode, + /// Whether a host login this harness cannot find omits the installation instead of blocking + /// Agent provisioning. Re-evaluated on every pass, so a later host login installs it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub optional: bool, + /// Whether new Sessions select this installation when no harness is specified. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub default: bool, + /// Model and effort level for new Sessions of this installation that do not + /// select their own. Omitted, the harness picks its own defaults. + #[serde(default, skip_serializing_if = "ModelSelection::is_empty")] + pub defaults: ModelSelection, +} + +/// Non-secret result of importing a host harness login. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ImportedAuthentication { + /// Authentication provider identifier. + pub provider: String, + /// Whether usable credentials were imported. + pub ready: bool, +} + +/// Dispatches host-side authentication to the selected harness adapter. +pub struct AuthenticationManager { + database: persistence::Database, + claude_code: claude_code::authentication::Authentication, + codex: codex::authentication::Authentication, +} + +impl AuthenticationManager { + /// Creates the harness authentication manager over the shared database owner. + #[must_use] + pub fn new(database: persistence::Database) -> Self { + Self { + database: database.clone(), + claude_code: claude_code::authentication::Authentication::new(database.clone()), + codex: codex::authentication::Authentication::new(database), + } + } + + /// Stores a credential for the selected harness. + /// + /// `imported` marks a credential supplied by the caller rather than minted by the host login + /// flow; adapters whose host grant must stay isolated only accept mediated placeholders that way. + /// + /// # Errors + /// + /// Returns an error when the credential is invalid or cannot be persisted. + pub async fn login( + &self, + harness: Harness, + credential: Zeroizing, + imported: bool, + ) -> Result { + match harness { + Harness::ClaudeCode => self.claude_code.login(credential).await, + Harness::Codex => self.codex.login(credential, imported).await, + } + } +} + +impl sandbox::secret_store::SecretStore for AuthenticationManager { + fn set<'a>( + &'a self, + name: &'a str, + value: &'a [u8], + ) -> sandbox::LocalFuture<'a, Result> { + sandbox::secret_store::SecretStore::set(&self.database, name, value) + } + + fn resolve<'a>( + &'a self, + reference: &'a sandbox::secret_store::SecretReference, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + if codex::owns_secret(reference) { + self.codex.resolve_access().await + } else { + sandbox::secret_store::SecretStore::resolve(&self.database, reference).await + } + }) + } +} + +/// Acquires a host credential for the selected harness, interactively. +/// +/// Runs on the client host, where a terminal and browser are available; the +/// harness-specific login mechanism lives behind the closed harness enum. +/// +/// # Errors +/// +/// Returns an error when the harness login tool is missing, fails, or yields no credential. +pub fn acquire_host_credential( + harness: Harness, + control_plane_home: &std::path::Path, +) -> Result, Error> { + match harness { + Harness::ClaudeCode => claude_code::acquire_host_token(), + Harness::Codex => codex::acquire_host_credential(control_plane_home), + } +} + +/// Reports whether the host login this harness mediates is present and usable. +/// +/// An optional installation is omitted rather than failing provisioning when this is false, so the +/// check must distinguish "no login here" from a genuine fault, which stays an error. +pub(crate) async fn authentication_ready(harness: Harness, database: &persistence::Database) -> Result { + match harness { + Harness::ClaudeCode => claude_code::authentication_ready(database).await, + Harness::Codex => codex::authentication_ready(database).await, + } +} + +pub(crate) async fn prepare(harness: Harness, database: &persistence::Database) -> Result, Error> { + match harness { + Harness::ClaudeCode => claude_code::prepare(database).await, + Harness::Codex => codex::prepare(database).await, + } +} + +pub(crate) struct MediatedSecret { + pub(crate) environment: &'static str, + pub(crate) placeholder: String, + pub(crate) reference: sandbox::secret_store::SecretReference, + pub(crate) allowed_hosts: Vec, +} + +pub(crate) fn conflicts_with_managed_secret(harness: Harness, name: &str, placeholder: Option<&str>) -> bool { + match harness { + Harness::ClaudeCode => claude_code::conflicts_with_managed_secret(name, placeholder), + Harness::Codex => codex::conflicts_with_managed_secret(name, placeholder), + } +} + +pub(crate) fn manages_environment(harness: Harness, name: &str) -> bool { + match harness { + Harness::ClaudeCode => claude_code::manages_environment(name), + Harness::Codex => codex::manages_environment(name), + } +} + +pub(crate) async fn bootstrap_linux( + harness: Harness, + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[Skill], +) -> Result<(), Error> { + match harness { + Harness::ClaudeCode => claude_code::bootstrap_linux(sandbox, home, instructions, skills).await, + Harness::Codex => codex::bootstrap_linux(sandbox, home, instructions, skills).await, + } +} + +/// Verifies that the declared harness installation exists, at the exact version when one is declared. +pub(crate) async fn verify_linux( + harness: Harness, + sandbox: &sandbox::SandboxHandle, + expected_version: Option<&str>, +) -> Result<(), Error> { + match harness { + Harness::ClaudeCode => claude_code::verify_linux(sandbox, expected_version).await, + Harness::Codex => codex::verify_linux(sandbox, expected_version).await, + } +} + +async fn version_output( + sandbox: &sandbox::SandboxHandle, + executable: &str, +) -> Result { + use sandbox::{SandboxPath, execution::ExecutionSpec}; + + let started = sandbox + .start_execution(sandbox::execution::StartExecutionRequest::new(ExecutionSpec::command( + SandboxPath::new("/usr/bin/env"), + [executable.to_owned(), "--version".into()], + ))) + .await?; + let execution_id = started.id.clone(); + match tokio::time::timeout(VERSION_PROBE_TIMEOUT, started.collect()).await { + Ok(output) => output.map_err(Error::from), + Err(_elapsed) => { + let _ignored = sandbox.kill_execution(&execution_id).await; + Err(Error::SandboxSetup(format!( + "`{executable} --version` did not finish within {}s", + VERSION_PROBE_TIMEOUT.as_secs() + ))) + } + } +} + +/// Harness-specific process and environment used by the Session runtime. +pub struct ProcessLaunch { + /// Shell command used to launch the harness. + pub command: String, + /// Environment added to the generic Agent session environment. + pub environment: Vec<(String, String)>, +} + +/// Harness-neutral inputs of one Session launch. +#[derive(Clone, Copy, Debug)] +pub struct LaunchRequest<'a> { + /// Guest home directory holding the harness configuration. + pub home: &'a str, + /// Harness-native conversation to continue instead of starting a fresh one. + pub resume: Option<&'a str>, + /// First prompt of a fresh conversation, passed as the harness's positional + /// prompt argument so it starts working immediately; ignored when resuming. + pub initial_prompt: Option<&'a str>, + /// Model and effort level the Session was created with. + pub model_selection: &'a ModelSelection, +} + +/// Resolves the selected harness's terminal launch configuration. +/// +/// Each adapter spells the request's model and effort in its own launch +/// vocabulary; both apply to fresh and resumed conversations alike. +#[must_use] +pub fn launch_linux(harness: Harness, request: &LaunchRequest<'_>) -> ProcessLaunch { + match harness { + Harness::ClaudeCode => claude_code::launch_linux(request), + Harness::Codex => codex::launch_linux(request), + } +} + +/// Model every Session of `harness` launched with before Sessions recorded a +/// model, when the adapter hardcoded one. Persistence records it for existing +/// Sessions when it adopts the Session selection columns. +pub(crate) const fn model_launched_before_selection(harness: Harness) -> Option<&'static str> { + match harness { + Harness::ClaudeCode => Some(claude_code::MODEL_LAUNCHED_BEFORE_SELECTION), + Harness::Codex => None, + } +} + +/// Quotes `value` as one POSIX shell word, safe for any content. +pub(crate) fn shell_single_quoted(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Validates an initial prompt before it is persisted for an argv-based launch. +pub(crate) fn validate_initial_prompt(prompt: &str) -> Result<(), Error> { + if prompt.contains('\0') { + return Err(Error::Invalid("initial prompt must not contain NUL".into())); + } + let quoted_bytes = prompt + .len() + .saturating_add(prompt.bytes().filter(|byte| *byte == b'\'').count().saturating_mul(3)) + .saturating_add(2); + if quoted_bytes > MAX_INITIAL_PROMPT_ARGUMENT_BYTES { + return Err(Error::Invalid(format!( + "initial prompt is too large; its encoded launch argument must not exceed {} KiB", + MAX_INITIAL_PROMPT_ARGUMENT_BYTES / 1024 + ))); + } + Ok(()) +} + +/// Recognizes an initialized input line before a harness reports its conversation. +/// The runtime supplies the visible cursor line and pane title. +pub(crate) fn input_ready_without_report(harness: Harness, cursor_line: &str, title: &str) -> bool { + match harness { + Harness::ClaudeCode => false, + Harness::Codex => codex::input_ready_without_report(cursor_line, title), + } +} + +/// Parses harness transcript bytes into ordered, runtime-neutral turns. +/// +/// # Errors +/// +/// Returns an error when the transcript cannot be decoded. +pub(crate) fn parse_transcript(harness: Harness, bytes: &[u8]) -> Result, Error> { + match harness { + Harness::ClaudeCode => claude_code::transcript::parse(bytes), + Harness::Codex => codex::transcript::parse(bytes), + } +} + +/// Trims a transcript suffix to its first complete harness turn. +pub(crate) fn trim_partial_transcript(harness: Harness, bytes: &[u8]) -> &[u8] { + match harness { + Harness::ClaudeCode => claude_code::transcript::trim_partial(bytes), + Harness::Codex => codex::transcript::trim_partial(bytes), + } +} + +#[cfg(test)] +pub(crate) const fn test_harness() -> Harness { + Harness::ClaudeCode +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_selections_are_opaque_but_command_line_safe() { + for value in [ + "fable", + "claude-fable-5", + "gpt-5.4-codex", + "us.anthropic.claude-v1:0", + "org/model@2", + "xhigh", + ] { + assert_eq!(Model::new(value).expect("valid model").as_str(), value); + assert_eq!(Effort::new(value).expect("valid effort").as_str(), value); + } + for value in [ + "", + " ", + "fable ", + "a b", + "it's", + "quote\"d", + "back\\slash", + "tab\t", + "ø", + ] { + assert!(Model::new(value).is_err(), "{value:?}"); + assert!(Effort::new(value).is_err(), "{value:?}"); + } + assert!(Model::new("m".repeat(MAX_SELECTION_CHARACTERS)).is_ok()); + assert!(Model::new("m".repeat(MAX_SELECTION_CHARACTERS + 1)).is_err()); + let error = Effort::new("").expect_err("empty effort"); + assert!(error.to_string().contains("effort must be 1-128")); + assert!(serde_json::from_str::("\"\"").is_err()); + assert_eq!( + serde_json::to_string(&Model::new("fable").expect("model")).expect("JSON"), + "\"fable\"" + ); + } + + #[test] + fn conflicts_name_only_explicitly_requested_differences() { + let recorded = ModelSelection { + model: Some(Model::new("fable").expect("model")), + effort: None, + }; + assert_eq!(recorded.conflict_with(&ModelSelection::default()), None); + assert_eq!(recorded.conflict_with(&recorded), None); + let other_model = ModelSelection { + model: Some(Model::new("opus").expect("model")), + effort: None, + }; + assert_eq!( + recorded.conflict_with(&other_model).as_deref(), + Some("already uses model \"fable\", not \"opus\"") + ); + let effort_only = ModelSelection { + model: None, + effort: Some(Effort::new("max").expect("effort")), + }; + assert_eq!( + recorded.conflict_with(&effort_only).as_deref(), + Some("leaves the effort to the harness default, not \"max\"") + ); + } + + #[test] + fn initial_prompt_validation_measures_the_shell_quoted_argument() { + validate_initial_prompt(&"a".repeat(MAX_INITIAL_PROMPT_ARGUMENT_BYTES - 2)).expect("boundary prompt"); + + let expanded = "'".repeat(MAX_INITIAL_PROMPT_ARGUMENT_BYTES / 4); + assert!(validate_initial_prompt(&expanded).is_err()); + assert!(validate_initial_prompt("before\0after").is_err()); + } +} diff --git a/agentctl/src/harness/skills.rs b/agentctl/src/harness/skills.rs new file mode 100644 index 0000000..b8fe705 --- /dev/null +++ b/agentctl/src/harness/skills.rs @@ -0,0 +1,74 @@ +//! Skill installation shared by the Linux harness bootstraps. +//! +//! A skill is a directory holding `SKILL.md`, read on the host from `spec.skills`. Each harness +//! discovers skills in its own user-level directory, so the adapter chooses the root and this +//! module does the placement. + +use sandbox::{SandboxHandle, SandboxPath, execution::ExecutionSpec}; + +use crate::{Error, sandbox::platform::files::write_if_changed}; + +/// One skill directory read on the host, keyed by its directory name. +pub(crate) struct Skill { + pub(crate) name: String, + /// Regular files below the skill directory, as `/`-separated relative paths. + pub(crate) files: Vec, +} + +/// One regular file of a skill. +pub(crate) struct SkillFile { + pub(crate) relative_path: String, + pub(crate) contents: Vec, +} + +/// Places every skill below `root` as `root//`, owned by the image user. +/// +/// Harnesses watch their skill directories live, so a file whose contents are already in place +/// is left alone rather than rewritten. Directories are created as the image user. Runtime file +/// transfer writes as the Sandbox supervisor, so exactly the managed files are chowned +/// afterwards: no recursive ownership walk. +pub(super) async fn install_linux(sandbox: &SandboxHandle, root: &str, skills: &[Skill]) -> Result<(), Error> { + for skill in skills { + let target = format!("{root}/{}", skill.name); + run_checked(sandbox, "/usr/bin/mkdir", ["-p".to_owned(), target.clone()]).await?; + let mut managed = Vec::with_capacity(skill.files.len()); + for file in &skill.files { + let path = format!("{target}/{}", file.relative_path); + if let Some((parent, _)) = file.relative_path.rsplit_once('/') { + run_checked( + sandbox, + "/usr/bin/mkdir", + ["-p".to_owned(), format!("{target}/{parent}")], + ) + .await?; + } + write_if_changed(sandbox, &path, &file.contents).await?; + managed.push(path); + } + if managed.is_empty() { + continue; + } + let mut chown = vec!["/usr/bin/chown".to_owned(), "agent:agent".to_owned()]; + chown.extend(managed); + run_checked(sandbox, "/usr/bin/sudo", chown).await?; + } + Ok(()) +} + +async fn run_checked( + sandbox: &SandboxHandle, + executable: &str, + args: impl IntoIterator, +) -> Result<(), Error> { + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new(executable), args)) + .await?; + if output.status.success() { + Ok(()) + } else { + Err(Error::SandboxSetup(format!( + "command {executable:?} exited with code {}", + output.status.code + ))) + } +} diff --git a/agentctl/src/lib.rs b/agentctl/src/lib.rs new file mode 100644 index 0000000..9c6d22e --- /dev/null +++ b/agentctl/src/lib.rs @@ -0,0 +1,104 @@ +//! Agent automation and the local declarative control plane. +//! +//! This crate depends on the generic Sandbox crates. The reverse dependency is +//! deliberately impossible in the workspace graph. + +pub mod authorization; +pub mod control_api; +pub mod control_plane; +mod controller; +mod environment; +pub mod harness; +pub mod local; +pub mod manifest; +pub mod persistence; +pub mod platform_api; +pub mod progress; +pub mod resources; +pub mod sandbox; +pub mod sessions; +pub mod ssh; +pub mod upgrade; +pub mod vnc; + +pub use control_plane::AgentId; +pub use controller::{FailureKind, ReconcileFailure}; +pub use harness::{Effort, Harness, HarnessAuthMode, HarnessSpec, Model, ModelSelection}; +pub use manifest::{ + API_VERSION, AccessSpec, Agent, AgentVariant, AgentVariantName, Condition, ConditionStatus, EnvironmentSpec, + HomeSpec, InstructionsSpec, KIND, Metadata, MountSpec, NetworkAllow, NetworkMode, NetworkSpec, + PlatformManifestSpec, Provenance, ResolvedManifest, RunState, SandboxManifestSpec, SecretSpec, SkillSpec, Spec, + Status, VARIANT_KIND, +}; + +/// Version embedded in a matched `agentctl`/`agentd` build. +#[must_use] +pub const fn build_version() -> &'static str { + match release_version() { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), + } +} + +/// Release version embedded by packaging, absent from ordinary development builds. +#[must_use] +pub const fn release_version() -> Option<&'static str> { + option_env!("AGENT_VERSION") +} + +use thiserror::Error; + +/// Errors exposed by the Agent control plane. +#[derive(Debug, Error)] +pub enum Error { + /// The Agent resource is invalid: desired state must change before another + /// reconciliation pass can succeed, so waiters fail fast and nothing retries. + #[error("invalid Agent: {0}")] + Invalid(String), + /// The requested Agent does not exist. + #[error("Agent not found")] + NotFound, + /// An immutable desired-state field changed. + #[error("immutable Agent field changed: {0}")] + Immutable(&'static str), + /// A compare-and-swap operation observed a newer generation. + #[error("Agent resource changed concurrently")] + Conflict, + /// Persistent control-plane state could not be read or written. + #[error("control-plane database failed: {0}")] + Database(String), + /// Immutable Agent setup failed inside a running Sandbox. Treated as transient: + /// the background controller retries and waiters keep following. + #[error("Agent Sandbox setup failed: {0}")] + SandboxSetup(String), + /// The Sandbox's guest stopped making progress while its VM kept running. + /// Treated as transient: the guest may recover, and the background + /// controller keeps observing it. + #[error("Agent Sandbox is not responding: {0}")] + SandboxUnresponsive(String), + /// The named Agent is stopped, so nothing runs in its Sandbox until it is + /// started. Desired state must change, so waiters fail fast. + #[error("Agent {0:?} is stopped; run `agentctl start agent/{0}`")] + Stopped(String), + /// A generic Sandbox operation failed. + #[error("Sandbox operation failed: {0}")] + Sandbox(#[from] ::sandbox::Error), + /// Session lifecycle or attachment failed. + #[error("Session operation failed: {0}")] + Session(String), + /// Local persistence or transport failed. + #[error("I/O operation failed: {0}")] + Io(#[from] std::io::Error), + /// A required daemon subsystem stopped unexpectedly. + #[error("Agent daemon subsystem failed: {0}")] + Daemon(String), + /// A JSON protocol document was invalid. + #[error("invalid JSON: {0}")] + Json(#[from] serde_json::Error), + /// A YAML manifest was invalid. + #[error("invalid YAML: {0}")] + Yaml(#[from] serde_yaml_ng::Error), + /// The Agent Control API returned a protocol-level error. + #[error("Agent Control API error: {0}")] + Rpc(#[from] control_api::ResponseError), +} diff --git a/agentctl/src/local/home.rs b/agentctl/src/local/home.rs new file mode 100644 index 0000000..b0feaea --- /dev/null +++ b/agentctl/src/local/home.rs @@ -0,0 +1,229 @@ +//! Per-user storage paths, permissions, and single-daemon ownership. + +use std::{ + env, + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, +}; + +use crate::Error; + +const ENVIRONMENT_VARIABLE: &str = "AGENT_HOME"; + +/// Root of one local Agent Control Plane. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ControlPlaneHome(PathBuf); + +impl ControlPlaneHome { + /// Resolves an explicit home, the environment override, or the per-user default. + /// + /// # Errors + /// + /// Returns an error when an absolute path or the per-user configuration directory cannot be resolved. + pub fn resolve(configured: Option<&Path>) -> Result { + if let Some(path) = configured.filter(|path| !path.as_os_str().is_empty()) { + return absolute(path).map(Self); + } + if let Some(path) = env::var_os(ENVIRONMENT_VARIABLE).filter(|value| !value.is_empty()) { + return absolute(Path::new(&path)).map(Self); + } + default_home().map(Self) + } + + /// Returns the resolved home path. + #[must_use] + pub fn path(&self) -> &Path { + &self.0 + } + + /// Returns the fixed local API socket path. + /// + /// The socket lives in its own directory because Windows can leave a stale + /// `AF_UNIX` socket file that cannot be deleted or rebound until reboot; + /// recovery renames the directory aside, which works even then. + #[must_use] + pub fn socket_path(&self) -> PathBuf { + self.0.join("run").join("agentd.sock") + } + + /// Returns the daemon diagnostic log path used by automatic startup. + #[must_use] + pub fn daemon_log_path(&self) -> PathBuf { + self.0.join("agentd.log") + } + + /// Returns the directory holding SSH client configuration and key material. + #[must_use] + pub fn ssh_directory(&self) -> PathBuf { + self.0.join("ssh") + } + + /// Returns the durable marker requesting one post-upgrade Session relaunch pass. + #[must_use] + pub fn pending_session_relaunch_path(&self) -> PathBuf { + self.0.join("pending-session-relaunch.json") + } + + /// Opens the automatic-start diagnostic log with user-only access. + /// + /// # Errors + /// + /// Returns an error when the log cannot be opened or secured. + pub fn open_daemon_log(&self) -> Result { + let path = self.daemon_log_path(); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + secure_file(&path)?; + Ok(file) + } + + /// Creates the home and restricts it to the current user. + /// + /// # Errors + /// + /// Returns an error when the directory cannot be created or secured. + pub fn prepare(&self) -> Result<(), Error> { + fs::create_dir_all(&self.0)?; + secure_directory(&self.0)?; + Ok(()) + } + + /// Acquires exclusive ownership of this control-plane home. + /// + /// # Errors + /// + /// Returns an error when the home cannot be prepared or another daemon owns its lock. + pub fn acquire_lock(&self) -> Result { + self.prepare()?; + let path = self.0.join("agentd.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path)?; + secure_file(&path)?; + match file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "control-plane home is already locked", + ))); + } + Err(std::fs::TryLockError::Error(error)) => return Err(Error::Io(error)), + } + Ok(Lock { _file: file }) + } +} + +/// Returns the current user's home directory as the host reports it. +/// +/// This is the directory OpenSSH expands `~` to and where `~/.ssh/config` +/// lives; it is unrelated to the control-plane home, which may be relocated. +#[must_use] +pub fn user_home_directory() -> Option { + env::var_os(HOME_VARIABLE) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +/// The environment variable the host uses for the user's home directory. +const HOME_VARIABLE: &str = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + +/// Held exclusive process lock for one local control-plane home. +#[derive(Debug)] +pub struct Lock { + _file: File, +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(env::current_dir()?.join(path)) + } +} + +pub(crate) fn secure_directory(path: &Path) -> Result<(), Error> { + secure_directory_for_host(path) +} + +pub(crate) fn secure_file(path: &Path) -> Result<(), Error> { + secure_file_for_host(path) +} + +/// `~/.agent`. On Windows that is below `USERPROFILE`, not `LOCALAPPDATA`: +/// endpoint-protection filters commonly applied to the `AppData` tree can leave +/// `AF_UNIX` sockets there unconnectable and their files undeletable, which +/// breaks the local API socket and Microsandbox. +fn default_home() -> Result { + if !cfg!(any(unix, windows)) { + return Err(Error::Invalid("unsupported host operating system".into())); + } + user_home_directory() + .map(|home| home.join(".agent")) + .ok_or_else(|| Error::Invalid(format!("{HOME_VARIABLE} is not set"))) +} + +#[cfg(unix)] +fn secure_directory_for_host(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(unix)] +fn secure_file_for_host(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn secure_directory_for_host(path: &Path) -> Result<(), Error> { + secure_windows_path(path, true) +} + +#[cfg(target_os = "windows")] +fn secure_file_for_host(path: &Path) -> Result<(), Error> { + secure_windows_path(path, false) +} + +#[cfg(target_os = "windows")] +fn secure_windows_path(path: &Path, inherit: bool) -> Result<(), Error> { + let user = env::var("USERNAME").map_err(|error| Error::Invalid(error.to_string()))?; + let grant = if inherit { + format!("{user}:(OI)(CI)F") + } else { + format!("{user}:F") + }; + let mut command = std::process::Command::new("icacls"); + command + .arg(path) + .arg("/inheritance:r") + .arg("/grant:r") + .arg(grant) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + super::process::configure_hidden(&mut command); + let status = command.status()?; + if !status.success() { + return Err(Error::Io(std::io::Error::other(format!( + "icacls failed for {} with {status}", + path.display() + )))); + } + Ok(()) +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn secure_directory_for_host(_path: &Path) -> Result<(), Error> { + Err(Error::Invalid("unsupported host operating system".into())) +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn secure_file_for_host(_path: &Path) -> Result<(), Error> { + Err(Error::Invalid("unsupported host operating system".into())) +} diff --git a/agentctl/src/local/mod.rs b/agentctl/src/local/mod.rs new file mode 100644 index 0000000..034c779 --- /dev/null +++ b/agentctl/src/local/mod.rs @@ -0,0 +1,4 @@ +//! Local daemon infrastructure and host integration. + +pub mod home; +pub mod process; diff --git a/agentctl/src/local/process.rs b/agentctl/src/local/process.rs new file mode 100644 index 0000000..33e695d --- /dev/null +++ b/agentctl/src/local/process.rs @@ -0,0 +1,69 @@ +//! Host-specific process launch behavior. + +/// Default `RUST_LOG` filter for `agentd` and every runtime process it spawns. +/// +/// Runtime helpers inherit the daemon's stderr and install their own tracing +/// subscriber, so the filter travels through the environment rather than code. +#[must_use] +pub fn daemon_log_filter() -> String { + format!("info,{}", crate::sandbox::microsandbox::LOG_DIRECTIVES) +} + +/// Gives a child daemon the default log filter unless the caller set `RUST_LOG`. +pub fn configure_logging(command: &mut std::process::Command) { + if std::env::var_os("RUST_LOG").is_none() { + command.env("RUST_LOG", daemon_log_filter()); + } +} + +/// Configures a child daemon to run independently of the invoking terminal. +#[cfg(windows)] +pub fn configure_detached(command: &mut std::process::Command) { + use std::os::windows::process::CommandExt as _; + + command.creation_flags(windows::CREATE_NEW_PROCESS_GROUP | windows::CREATE_NO_WINDOW); +} + +#[cfg(unix)] +pub fn configure_detached(command: &mut std::process::Command) { + use std::os::unix::process::CommandExt as _; + + command.process_group(0); +} + +#[cfg(windows)] +pub(super) fn configure_hidden(command: &mut std::process::Command) { + use std::os::windows::process::CommandExt as _; + + command.creation_flags(windows::CREATE_NO_WINDOW); +} + +#[cfg(windows)] +mod windows { + pub(super) const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + pub(super) const CREATE_NO_WINDOW: u32 = 0x0800_0000; +} + +#[cfg(all(test, unix))] +mod tests { + #[test] + fn detached_child_owns_its_process_group() { + let mut command = std::process::Command::new("/bin/sh"); + command + .args(["-c", "ps -o pgid= -p $$"]) + .stdout(std::process::Stdio::piped()); + super::configure_detached(&mut command); + let child = command.spawn().expect("child"); + let pid = child.id(); + let output = child.wait_with_output().expect("child output"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout) + .expect("UTF-8") + .trim() + .parse::() + .expect("process group"), + pid + ); + } +} diff --git a/agentctl/src/manifest.rs b/agentctl/src/manifest.rs new file mode 100644 index 0000000..af9d326 --- /dev/null +++ b/agentctl/src/manifest.rs @@ -0,0 +1,1308 @@ +//! Declarative Agent manifest and observed resource status. + +use std::path::{Component, Path, PathBuf}; + +use time::OffsetDateTime; + +use ::sandbox::{ + ByteQuantity, Platform, RetentionPolicy, SandboxName, SandboxPath, SandboxResources, image::ImageSource, + init::InitSystem, mount::Mount, +}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, HarnessSpec, harness}; + +/// The first supported Agent manifest API version. +pub const API_VERSION: &str = "agents.platform/v1alpha1"; +/// The manifest resource kind. +pub const KIND: &str = "Agent"; +/// Manifest kind used for a partial Agent configuration. +pub const VARIANT_KIND: &str = "AgentVariant"; +/// Maximum number of manifests in one inheritance chain, including the complete Agent. +pub const MAX_VARIANT_CHAIN: usize = 16; + +/// Declarative resource accepted by the agent control plane. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Agent { + /// Manifest schema version. + pub api_version: String, + /// Resource kind. + pub kind: String, + /// Resource identity and API-managed metadata. + pub metadata: Metadata, + /// Desired agent and sandbox configuration. + pub spec: Spec, + /// Most recently observed state. + #[serde(default, skip_serializing_if = "Status::is_empty")] + pub status: Status, +} + +/// A partial Agent manifest that inherits from a sibling manifest. +/// +/// `metadata` and `spec` remain YAML values until they have been merged with a +/// complete Agent. The expanded document is then decoded through [`Agent`], so +/// nested unknown fields are rejected by the same strict contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AgentVariant { + /// Manifest schema version, which must match every manifest in the chain. + pub api_version: String, + /// Resource kind. Always [`VARIANT_KIND`]. + pub kind: String, + /// Sibling manifest filename inherited by this variant. + pub extends: String, + /// Partial resource metadata. `name` is required in every variant. + pub metadata: serde_yaml_ng::Value, + /// Partial desired Agent configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spec: Option, +} + +/// Validated selector identifying an `agent..yaml` leaf. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct AgentVariantName(String); + +impl AgentVariantName { + /// Creates a validated Agent variant name. + /// + /// # Errors + /// + /// Returns an error unless the name matches `[a-z0-9]+(?:-[a-z0-9]+)*`. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || !value.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) + { + return Err(Error::Invalid("variant must match [a-z0-9]+(?:-[a-z0-9]+)*".into())); + } + Ok(Self(value)) + } + + /// Returns the selector text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the conventional leaf filename for this variant. + #[must_use] + pub fn filename(&self) -> String { + format!("agent.{self}.yaml") + } +} + +impl TryFrom for AgentVariantName { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: AgentVariantName) -> Self { + value.0 + } +} + +impl std::str::FromStr for AgentVariantName { + type Err = Error; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl std::fmt::Display for AgentVariantName { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl AsRef for AgentVariantName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +/// A leaf manifest expanded to the complete Agent sent to the control plane. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedManifest { + /// Expanded and strictly validated Agent. + pub agent: Agent, + /// Manifest paths from the leaf through to the complete base Agent. + pub chain: Vec, +} + +impl Agent { + /// Validates fields required at every API boundary. + /// + /// # Errors + /// + /// Returns an error when the resource version, kind, name, or sandbox specification is invalid. + pub fn validate(&self) -> Result<(), Error> { + if self.api_version != API_VERSION { + return Err(Error::Invalid(format!("apiVersion must be {API_VERSION:?}"))); + } + if self.kind != KIND { + return Err(Error::Invalid(format!("kind must be {KIND:?}"))); + } + SandboxName::new(self.metadata.name.clone()) + .map_err(|error| Error::Invalid(format!("metadata.name: {error}")))?; + self.spec + .sandbox + .validate() + .map_err(|error| Error::Invalid(format!("spec.sandbox: {error}")))?; + self.spec.validate() + } + + pub(crate) fn clear_managed_fields(&mut self) { + self.metadata.generation = 0; + self.metadata.deletion_timestamp = None; + self.status = Status::default(); + } +} + +/// Agent resource identity and API-managed metadata. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Metadata { + /// Stable resource name. + pub name: String, + /// Desired-state revision managed by the control plane. + #[serde(default, skip_serializing_if = "is_zero")] + pub generation: u64, + /// Time at which asynchronous deletion was requested. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub deletion_timestamp: Option, +} + +/// Desired agent settings and exactly one generic sandbox specification. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Spec { + /// Whether the Agent's Sandbox runs. `agentctl stop` and `agentctl start` + /// change it. A manifest may set it; when it is omitted, `apply` keeps the + /// Agent's current run state, and a new Agent runs. Stored only when not + /// Running. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_state: Option, + /// Generic sandbox configuration mapped to the lower-layer SDK. + pub sandbox: SandboxManifestSpec, + /// Host directory synchronized into the sandbox user's home at bootstrap. + pub home: HomeSpec, + /// Agent-wide guidance installed through every declared Harness Adapter, concatenated in order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub instructions: Vec, + /// Skill directories installed through every declared Harness Adapter. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, + /// Harness installations available to Sessions in this Agent. + pub harnesses: Vec, + /// Deliberately selected non-secret values exposed in plaintext inside the Sandbox. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub environment: Vec, + /// Host-owned values made available only through mediated requests. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secrets: Vec, + /// Ways the Agent's user reaches into the Sandbox besides Sessions and `exec`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub access: Vec, + /// Sandbox egress mediation policy. + pub network: NetworkSpec, +} + +/// Whether an Agent's Sandbox runs. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum RunState { + /// The Sandbox runs and serves Sessions and Executions. + #[default] + Running, + /// The Sandbox's VM is stopped. Its root filesystem, Volumes and identity + /// are kept, so a start boots the same disk and Sessions resume. + Stopped, +} + +/// One access capability the platform provides to the Agent's user. +/// +/// Access is an Agent-level capability like `harnesses` and `secrets`: the +/// platform owns the guest user, the transport and the key material, so a +/// variant carries no tunables. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase", tag = "type")] +pub enum AccessSpec { + /// OpenSSH access as the platform-owned guest user, reached through `agentctl ssh`. + /// + /// A struct variant so that `deny_unknown_fields` rejects tunables; serde + /// does not enforce it for unit variants of an internally tagged enum. + Ssh {}, + /// VNC access to the desktop the image runs, reached through `agentctl vnc`. + Vnc {}, +} + +/// Sandbox settings as supplied by an Agent manifest. +/// +/// Unlike the lower-layer [`sandbox::SandboxSpec`], this representation retains +/// an omitted architecture until the Agent creates the concrete Sandbox request. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SandboxManifestSpec { + /// Source of the immutable Image. + pub image: ImageSource, + /// Desired platform constraints. + pub platform: PlatformManifestSpec, + /// Desired mutable compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + #[serde(default)] + pub init_system: InitSystem, + /// Whether the Agent retains the Sandbox when releasing it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention_policy: Option, + /// Host filesystem and in-memory attachments materialized with the Sandbox. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mounts: Vec, +} + +impl SandboxManifestSpec { + fn validate(&self) -> Result<(), sandbox::Error> { + self.image.validate()?; + self.platform.validate() + } + + /// Resolves manifest-relative sources and an omitted architecture for a concrete Provider request. + #[must_use] + pub fn resolve_from(&self, source_directory: &std::path::Path, default_architecture: &str) -> sandbox::SandboxSpec { + sandbox::SandboxSpec { + image: self.image.resolve_from(source_directory), + platform: self.platform.resolve(default_architecture), + resources: self.resources, + init_system: self.init_system, + retention_policy: self.resolved_retention_policy(), + } + } + + /// Returns the Agent-layer retention default used when the manifest omits it. + #[must_use] + pub fn resolved_retention_policy(&self) -> RetentionPolicy { + self.retention_policy.unwrap_or(RetentionPolicy::Delete) + } + + /// Converts validated, absolute Agent Mount inputs to the generic Sandbox SDK representation. + #[must_use] + pub fn resolved_mounts(&self) -> Vec { + self.mounts.iter().map(MountSpec::to_sandbox_mount).collect() + } +} + +/// One filesystem attachment declared by an Agent builder. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "type" +)] +pub enum MountSpec { + /// A caller-host directory mapped into the Sandbox. + Bind { + /// Host path, resolved relative to the manifest directory at apply time. + source: std::path::PathBuf, + /// Absolute path inside the Sandbox. + target: SandboxPath, + /// Whether the Sandbox may modify the host directory. + read_only: bool, + }, + /// Anonymous in-memory storage with an explicit capacity. + Tmpfs { + /// Absolute path inside the Sandbox. + target: SandboxPath, + /// Maximum storage capacity. + capacity: ByteQuantity, + }, +} + +impl MountSpec { + const fn target(&self) -> &SandboxPath { + match self { + Self::Bind { target, .. } | Self::Tmpfs { target, .. } => target, + } + } + + fn to_sandbox_mount(&self) -> Mount { + match self { + Self::Bind { + source, + target, + read_only, + } => Mount::Bind { + source: source.clone(), + target: target.clone(), + read_only: *read_only, + }, + Self::Tmpfs { target, capacity } => Mount::Tmpfs { + target: target.clone(), + capacity: *capacity, + }, + } + } +} + +/// Platform constraints retained exactly as supplied by an Agent manifest. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PlatformManifestSpec { + /// Operating system, such as `linux`. + pub os: String, + /// Optional CPU architecture; omission selects the provider's native architecture. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub architecture: Option, + /// Optional architecture variant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, + /// Optional operating-system version constraint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os_version: Option, + /// Required operating-system features. + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub os_features: std::collections::BTreeSet, +} + +impl PlatformManifestSpec { + fn validate(&self) -> Result<(), sandbox::Error> { + if self.os.is_empty() { + return Err(sandbox::Error::invalid("platform.os", "must not be empty")); + } + if self.architecture.as_ref().is_some_and(String::is_empty) { + return Err(sandbox::Error::invalid( + "platform.architecture", + "must not be empty when present", + )); + } + if self.variant.as_ref().is_some_and(String::is_empty) { + return Err(sandbox::Error::invalid( + "platform.variant", + "must not be empty when present", + )); + } + if self.os_version.as_ref().is_some_and(String::is_empty) { + return Err(sandbox::Error::invalid( + "platform.osVersion", + "must not be empty when present", + )); + } + if self.os_features.contains("") { + return Err(sandbox::Error::invalid( + "platform.osFeatures", + "must not contain an empty value", + )); + } + Ok(()) + } + + fn resolve(&self, default_architecture: &str) -> Platform { + Platform { + os: self.os.clone(), + architecture: self.architecture.clone().unwrap_or_else(|| default_architecture.into()), + variant: self.variant.clone(), + os_version: self.os_version.clone(), + os_features: self.os_features.clone(), + } + } +} + +impl Spec { + /// Returns the desired run state; an omitted one is Running. + #[must_use] + pub fn run_state(&self) -> RunState { + self.run_state.unwrap_or_default() + } + + /// Returns whether the Agent's Sandbox is meant to be stopped. + #[must_use] + pub fn is_stopped(&self) -> bool { + self.run_state() == RunState::Stopped + } + + /// Sets the desired run state in its stored form, which omits Running, so + /// specs that differ only in how they say Running compare equal. + pub(crate) fn set_run_state(&mut self, state: RunState) { + self.run_state = (state != RunState::Running).then_some(state); + } + + /// Returns the declared installation for `kind`. + #[must_use] + pub fn harness(&self, kind: crate::Harness) -> Option<&HarnessSpec> { + self.harnesses.iter().find(|harness| harness.kind == kind) + } + + /// Returns the installation selected for a new Session without an explicit harness. + #[must_use] + pub fn default_harness(&self) -> Option<&HarnessSpec> { + if self.harnesses.len() == 1 { + self.harnesses.first() + } else { + self.harnesses.iter().find(|harness| harness.default) + } + } + + /// Returns whether the Agent declares SSH access. + #[must_use] + pub fn ssh_access(&self) -> bool { + self.access.contains(&AccessSpec::Ssh {}) + } + + /// Returns whether the Agent declares VNC access. + #[must_use] + pub fn vnc_access(&self) -> bool { + self.access.contains(&AccessSpec::Vnc {}) + } + + fn validate(&self) -> Result<(), Error> { + let mut mount_targets = std::collections::BTreeSet::new(); + for (index, mount) in self.sandbox.mounts.iter().enumerate() { + if let MountSpec::Bind { source, .. } = mount + && source.as_os_str().is_empty() + { + return Err(Error::Invalid(format!( + "spec.sandbox.mounts[{index}].source must not be empty" + ))); + } + let target = mount.target().as_str(); + if !valid_sandbox_path(target) || !mount_targets.insert(target) { + return Err(Error::Invalid(format!( + "spec.sandbox.mounts[{index}].target must be a unique absolute normalized Sandbox path" + ))); + } + } + if self.home.source.as_os_str().is_empty() { + return Err(Error::Invalid("spec.home.source must not be empty".into())); + } + if let Some(index) = self + .instructions + .iter() + .position(|instructions| instructions.source.as_os_str().is_empty()) + { + return Err(Error::Invalid(format!( + "spec.instructions[{index}].source must not be empty" + ))); + } + self.validate_skills()?; + if self.harnesses.is_empty() { + return Err(Error::Invalid("spec.harnesses must not be empty".into())); + } + let mut harness_kinds = std::collections::BTreeSet::new(); + let mut duplicate_harness = None; + let mut default_count = 0; + for (index, harness) in self.harnesses.iter().enumerate() { + if harness.version.as_deref().is_some_and(str::is_empty) { + return Err(Error::Invalid(format!( + "spec.harnesses[{index}].version must not be empty" + ))); + } + if !harness_kinds.insert(harness.kind) { + duplicate_harness = Some(harness.kind); + } + default_count += usize::from(harness.default); + } + if default_count > 1 || (self.harnesses.len() > 1 && default_count != 1) { + return Err(Error::Invalid( + "spec.harnesses must declare exactly one default when multiple harnesses are installed".into(), + )); + } + if let Some(harness) = duplicate_harness { + return Err(Error::Invalid(format!( + "spec.harnesses contains duplicate harness kind {:?}", + harness.as_str() + ))); + } + self.validate_environment()?; + self.validate_secrets()?; + let mut access = std::collections::BTreeSet::new(); + if let Some(index) = self.access.iter().position(|capability| !access.insert(*capability)) { + return Err(Error::Invalid(format!( + "spec.access[{index}] duplicates an access capability" + ))); + } + if self.network.deny.iter().any(|host| !valid_host_pattern(host)) { + return Err(Error::Invalid( + "spec.network.deny contains an invalid host pattern".into(), + )); + } + Ok(()) + } +} + +impl Spec { + fn validate_secrets(&self) -> Result<(), Error> { + let mut environments = self + .environment + .iter() + .map(|variable| variable.name.as_str()) + .collect::>(); + let environment_sources = self + .environment + .iter() + .map(EnvironmentSpec::source) + .collect::>(); + let mut placeholders = std::collections::BTreeSet::new(); + for (index, secret) in self.secrets.iter().enumerate() { + let placeholder = secret.inert_value(); + if environments.contains(secret.environment.as_str()) || environment_sources.contains(secret.source()) { + return Err(Error::Invalid(format!( + "spec.environment collides with spec.secrets[{index}]" + ))); + } + if !valid_environment_variable(&secret.environment) + || secret + .source + .as_deref() + .is_some_and(|source| !valid_environment_variable(source)) + || secret.placeholder.as_ref().is_some_and(String::is_empty) + || self.harnesses.iter().any(|installation| { + harness::conflicts_with_managed_secret( + installation.kind, + &secret.environment, + secret.placeholder.as_deref(), + ) + }) + || secret.allowed_hosts.is_empty() + || !environments.insert(&secret.environment) + || !placeholders.insert(placeholder) + || secret.allowed_hosts.iter().any(|host| !valid_host_pattern(host)) + { + return Err(Error::Invalid(format!( + "spec.secrets[{index}] is invalid or duplicated" + ))); + } + } + Ok(()) + } + + fn validate_environment(&self) -> Result<(), Error> { + let mut names = std::collections::BTreeSet::new(); + for (index, variable) in self.environment.iter().enumerate() { + if !valid_environment_variable(&variable.name) + || variable + .source + .as_deref() + .is_some_and(|source| !valid_environment_variable(source)) + || self + .harnesses + .iter() + .any(|installation| harness::manages_environment(installation.kind, &variable.name)) + || !names.insert(variable.name.as_str()) + { + return Err(Error::Invalid(format!( + "spec.environment[{index}] is invalid, duplicated, or managed by a declared harness" + ))); + } + } + let has_git_name = names.contains("GIT_USER_NAME"); + let has_git_email = names.contains("GIT_USER_EMAIL"); + if has_git_name != has_git_email { + return Err(Error::Invalid( + "spec.environment must declare GIT_USER_NAME and GIT_USER_EMAIL together".into(), + )); + } + Ok(()) + } + + fn validate_skills(&self) -> Result<(), Error> { + let mut skill_names = std::collections::BTreeSet::new(); + for (index, skill) in self.skills.iter().enumerate() { + let Some(name) = skill.name() else { + return Err(Error::Invalid(format!( + "spec.skills[{index}] must declare a name or use a source ending in the skill's directory name" + ))); + }; + if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') { + return Err(Error::Invalid(format!("spec.skills[{index}].name is invalid"))); + } + if !skill_names.insert(name) { + return Err(Error::Invalid(format!( + "spec.skills[{index}] duplicates skill {name:?}" + ))); + } + } + Ok(()) + } +} + +/// One explicitly selected non-secret value copied from the Agent environment file. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct EnvironmentSpec { + /// Environment variable exposed inside the Sandbox. + pub name: String, + /// Optional variable name in the Agent environment file; defaults to `name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +impl EnvironmentSpec { + /// Returns the environment-file variable that supplies the plaintext value. + #[must_use] + pub fn source(&self) -> &str { + self.source.as_deref().unwrap_or(&self.name) + } +} + +fn valid_sandbox_path(path: &str) -> bool { + path.starts_with('/') + && path != "/" + && path + .split('/') + .skip(1) + .all(|component| !component.is_empty() && component != "." && component != "..") +} + +/// Host inputs synchronized into the sandbox user's home. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HomeSpec { + /// Host path, resolved relative to the manifest directory. + pub source: std::path::PathBuf, +} + +/// One harness-neutral instruction file; several are concatenated in manifest order. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct InstructionsSpec { + /// Host file, resolved relative to the manifest directory. + pub source: std::path::PathBuf, +} + +/// One skill directory installed for every declared harness. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SkillSpec { + /// Host directory holding `SKILL.md`, resolved relative to the manifest directory. + pub source: std::path::PathBuf, + /// Installed skill directory name; defaults to the source directory name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl SkillSpec { + /// Returns the explicit skill name or the final component of the source directory. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref().or_else(|| { + self.source + .file_name()? + .to_str() + .filter(|name| !name.is_empty() && *name != ".") + }) + } +} + +/// One host-owned value exposed to Sandbox processes only as an inert environment placeholder. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SecretSpec { + /// Guest environment variable and stable secret binding name. + pub environment: String, + /// Whether a missing or empty environment-file value omits this binding. + #[serde(default, skip_serializing_if = "is_false")] + pub optional: bool, + /// Optional inert value; the selected Network Backend generates one when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + /// Hosts at which this secret may be substituted. + pub allowed_hosts: Vec, + /// Optional variable name in the manifest directory's `.env`; defaults to `environment`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_false(value: &bool) -> bool { + !*value +} + +impl SecretSpec { + /// Returns the host `.env` variable that supplies the secret material. + #[must_use] + pub fn source(&self) -> &str { + self.source.as_deref().unwrap_or(&self.environment) + } + + /// Returns the explicit or provider-neutral generated value exposed inside the Sandbox. + #[must_use] + pub fn inert_value(&self) -> String { + self.placeholder + .clone() + .unwrap_or_else(|| format!("$AGENT_SECRET_{}", self.environment)) + } +} + +/// Required network mediation mode. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkMode { + /// Route sandbox traffic through the trusted mediation backend. + Mediated, +} + +/// Baseline egress policy. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkAllow { + /// Permit network operations except explicitly denied hosts. + All, +} + +/// Agent-layer network policy interpreted by the host Policy Engine. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NetworkSpec { + /// Required mediation mode. + pub mode: NetworkMode, + /// Baseline egress decision. + pub allow: NetworkAllow, + /// Host patterns denied before the baseline decision. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, +} + +fn valid_host_pattern(pattern: &str) -> bool { + !pattern.is_empty() + && !pattern.contains(['/', ':', '\\']) + && pattern + .strip_prefix("*.") + .unwrap_or(pattern) + .split('.') + .all(|label| !label.is_empty() && label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')) +} + +fn valid_environment_variable(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +/// Most recently observed Agent state. +/// +/// Unlike the rest of the manifest, unknown fields are tolerated so an older +/// client can read responses from a newer control plane; status is +/// API-managed and never authored by hand. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + /// Desired generation observed by the reconciler. + #[serde(default, skip_serializing_if = "is_zero")] + pub observed_generation: u64, + /// Sticky selected Provider and optional materialized Sandbox identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + /// Normalized readiness conditions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conditions: Vec, + /// Classification of the reconciliation pass that recorded these + /// conditions, when it failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, + /// Provisioning of the latest pass while it runs or after it failed. + /// Projected onto API responses from the daemon's in-memory state; stores + /// scrub it, so it is never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress: Option, + /// Local origin of the desired state. Projected onto API responses from + /// the stored Agent record; stores scrub it, so it is never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, +} + +impl Status { + /// Creates reconciler-observed state; progress and provenance stay API-projected. + #[must_use] + pub const fn observed( + observed_generation: u64, + sandbox: Option, + conditions: Vec, + ) -> Self { + Self { + observed_generation, + sandbox, + conditions, + failure: None, + progress: None, + provenance: None, + } + } + + const fn is_empty(&self) -> bool { + self.observed_generation == 0 + && self.sandbox.is_none() + && self.conditions.is_empty() + && self.failure.is_none() + && self.progress.is_none() + && self.provenance.is_none() + } + + /// Carries each condition's transition time forward from `previous`, the + /// stored status being replaced, and stamps `now` on conditions whose + /// status or reason changed. + /// + /// A message-only change is not a transition, so a retry that reports a + /// different error detail keeps the time the condition entered its state. + pub fn stamp_transitions(&mut self, previous: &Self, now: OffsetDateTime) { + for condition in &mut self.conditions { + let earlier = previous + .conditions + .iter() + .find(|earlier| earlier.kind == condition.kind); + condition.last_transition_time = match earlier { + Some(earlier) if earlier.status == condition.status && earlier.reason == condition.reason => { + earlier.last_transition_time + } + _ => Some(now), + }; + } + } + + /// Returns the `Ready` condition when the reconciler has reported one. + #[must_use] + pub fn ready_condition(&self) -> Option<&Condition> { + Condition::find_ready(&self.conditions) + } + + /// Returns whether the reconciler reported `Ready=True`. + #[must_use] + pub fn is_ready(&self) -> bool { + Condition::any_ready(&self.conditions) + } + + /// Returns whether a pass recorded the Agent's Sandbox as stopped. + #[must_use] + pub fn is_stopped(&self) -> bool { + self.ready_condition() + .is_some_and(|ready| ready.reason == Condition::REASON_STOPPED) + } + + /// Returns the `SandboxResponsive=False` condition when the Agent's guest + /// is recorded as unresponsive. + #[must_use] + pub fn unresponsive(&self) -> Option<&Condition> { + self.conditions.iter().find(|condition| { + condition.kind == Condition::SANDBOX_RESPONSIVE && condition.status == ConditionStatus::False + }) + } + + /// Returns the failure detail when desired state must change before + /// another pass can succeed. + #[must_use] + pub fn invalid(&self) -> Option { + if self.failure != Some(crate::FailureKind::Invalid) { + return None; + } + self.ready_condition() + .or_else(|| self.conditions.first()) + .map(Condition::detail) + } +} + +impl Condition { + /// Condition type summarizing whether the Agent can serve Sessions and Executions. + pub const READY: &'static str = "Ready"; + /// Condition type for the Sandbox lifecycle underneath `Ready`. + pub const SANDBOX_READY: &'static str = "SandboxReady"; + /// Condition type for whether the running Sandbox's guest still makes + /// progress, observed by the host without a round trip to the guest. + pub const SANDBOX_RESPONSIVE: &'static str = "SandboxResponsive"; + /// Condition type for declared SSH access underneath `Ready`. + pub const SSH_READY: &'static str = "SshReady"; + /// VNC access is reconciled and the bridge to the desktop is listening. + pub const VNC_READY: &'static str = "VncReady"; + + /// `Ready` reason while a pass stops the Agent's Sandbox, and after a stop failed. + pub const REASON_STOPPING: &'static str = "Stopping"; + /// `Ready` and `SandboxReady` reason once the Agent's Sandbox is stopped. + pub const REASON_STOPPED: &'static str = "Stopped"; + /// `Ready` reason while a pass starts a stopped Sandbox. + pub const REASON_STARTING: &'static str = "Starting"; + + /// Finds the `Ready` condition in a condition list. + #[must_use] + pub fn find_ready(conditions: &[Self]) -> Option<&Self> { + conditions.iter().find(|condition| condition.kind == Self::READY) + } + + /// Returns whether a condition list reports `Ready=True`. + #[must_use] + pub fn any_ready(conditions: &[Self]) -> bool { + Self::find_ready(conditions).is_some_and(|condition| condition.status == ConditionStatus::True) + } + + /// Returns `reason: message`, or whichever of the two is present, or `Unknown`. + #[must_use] + pub fn summary(&self) -> String { + match (self.reason.is_empty(), self.message.is_empty()) { + (false, false) => format!("{}: {}", self.reason, self.message), + (false, true) => self.reason.clone(), + (true, false) => self.message.clone(), + (true, true) => "Unknown".to_owned(), + } + } + + /// Returns the human-readable message, falling back to the reason. + #[must_use] + pub fn detail(&self) -> String { + if self.message.is_empty() { + self.reason.clone() + } else { + self.message.clone() + } + } +} + +/// Conventional Agent manifest filename, used when a record predates path recording. +pub const MANIFEST_FILE: &str = "agent.yaml"; + +/// Local origin of an Agent's desired state. +/// +/// Part of [`Status`], so unknown fields are tolerated for the same reason. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Provenance { + /// Absolute directory against which manifest-relative sources are resolved. + pub source_directory: std::path::PathBuf, + /// Absolute path of the manifest last applied, when the client reported it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_path: Option, + /// Absolute path of the secret file, when it is not `.env` beside the manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_file: Option, +} + +impl Provenance { + /// Returns the recorded manifest path, falling back to [`MANIFEST_FILE`] + /// in the source directory for records that predate path recording. + #[must_use] + pub fn manifest_or_default(&self) -> std::path::PathBuf { + self.manifest_path + .clone() + .unwrap_or_else(|| self.source_directory.join(MANIFEST_FILE)) + } +} + +/// One aspect of observed Agent state. +/// +/// Like [`Status`], unknown fields are tolerated, so a client or store reader +/// can read conditions written by a newer control plane. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + /// Stable condition type. + #[serde(rename = "type")] + pub kind: String, + /// Normalized truth value. + pub status: ConditionStatus, + /// Stable machine-readable reason. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, + /// Optional human-readable detail. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub message: String, + /// When `status` or `reason` last changed. Stamped by the store; absent on + /// conditions recorded before transition times were tracked, until their + /// next transition. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub last_transition_time: Option, +} + +/// Truth value of an Agent condition. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum ConditionStatus { + /// The condition is satisfied. + True, + /// The condition is not satisfied. + False, + /// The control plane cannot determine the value. + Unknown, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_zero(value: &u64) -> bool { + *value == 0 +} + +/// Decodes and validates a YAML or JSON Agent manifest. +/// +/// # Errors +/// +/// Returns an error when syntax, structure, or required values are invalid. +pub fn decode(bytes: &[u8]) -> Result { + let agent: Agent = serde_yaml_ng::from_slice(bytes)?; + agent.validate()?; + Ok(agent) +} + +/// Expands a complete Agent or chained Agent variant from `path`. +/// +/// Variant bases are restricted to conventional sibling filenames. Mappings +/// merge recursively, arrays and scalars replace inherited values, and `null` +/// removes inherited fields. +/// +/// # Errors +/// +/// Returns an error when a manifest cannot be read, a variant chain is invalid, +/// or the expanded Agent does not satisfy the strict Agent contract. +pub fn resolve(path: &Path) -> Result { + let leaf = absolute_lexical(path)?; + let mut chain = Vec::new(); + let mut versions = Vec::new(); + let value = resolve_value(&leaf, &mut chain, &mut versions).map_err(|error| with_chain(error, &chain))?; + let bytes = serde_yaml_ng::to_string(&value).map_err(Error::Yaml)?; + let agent = decode(bytes.as_bytes()).map_err(|error| manifest_error(&leaf, "expanded Agent", &error))?; + Ok(ResolvedManifest { agent, chain }) +} + +/// Extracts a variant selector from a conventional leaf filename. +#[must_use] +pub fn variant_from_filename(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + let variant = name.strip_prefix("agent.")?.strip_suffix(".yaml")?; + variant.parse().ok() +} + +/// Returns whether a filename is a conventional Agent or Agent variant manifest. +#[must_use] +pub fn is_manifest_filename(path: &Path) -> bool { + path.file_name().is_some_and(|name| name == MANIFEST_FILE) || variant_from_filename(path).is_some() +} + +fn resolve_value( + path: &Path, + chain: &mut Vec, + versions: &mut Vec<(PathBuf, String)>, +) -> Result { + if let Some(start) = chain.iter().position(|candidate| candidate == path) { + let cycle = chain[start..] + .iter() + .map(PathBuf::as_path) + .chain(std::iter::once(path)) + .map(display_leaf) + .collect::>() + .join(" -> "); + return Err(Error::Invalid(format!("variant inheritance cycle: {cycle}"))); + } + if chain.len() == MAX_VARIANT_CHAIN { + return Err(Error::Invalid(format!( + "{}: variant inheritance exceeds {MAX_VARIANT_CHAIN} manifests", + display_leaf(path) + ))); + } + + chain.push(path.to_path_buf()); + + let bytes = std::fs::read(path) + .map_err(|error| Error::Invalid(format!("{}: could not read manifest: {error}", display_leaf(path))))?; + let value: serde_yaml_ng::Value = serde_yaml_ng::from_slice(&bytes) + .map_err(|error| Error::Invalid(format!("{}: {error}", display_leaf(path))))?; + let mapping = value + .as_mapping() + .ok_or_else(|| Error::Invalid(format!("{}: manifest must be a mapping", display_leaf(path))))?; + let version = string_field(mapping, "apiVersion", path)?; + let kind = string_field(mapping, "kind", path)?; + if let Some((version_path, expected)) = versions.first() + && version != *expected + { + return Err(Error::Invalid(format!( + "{}: apiVersion {version:?} does not match {} ({expected:?})", + display_leaf(path), + display_leaf(version_path) + ))); + } + versions.push((path.to_path_buf(), version)); + match kind.as_str() { + KIND => { + decode(&bytes).map_err(|error| manifest_error(path, "complete Agent", &error))?; + Ok(value) + } + VARIANT_KIND => { + if variant_from_filename(path).is_none() { + return Err(Error::Invalid(format!( + "{}: AgentVariant filename must match agent..yaml", + display_leaf(path) + ))); + } + let variant: AgentVariant = serde_yaml_ng::from_slice(&bytes) + .map_err(|error| Error::Invalid(format!("{}: {error}", display_leaf(path))))?; + validate_variant(&variant, path)?; + let base = sibling_base(path, &variant.extends)?; + let mut inherited = resolve_value(&base, chain, versions)?; + let mut overlay = value; + let overlay_mapping = overlay + .as_mapping_mut() + .ok_or_else(|| Error::Invalid(format!("{}: manifest must be a mapping", display_leaf(path))))?; + overlay_mapping.remove(serde_yaml_ng::Value::String("extends".into())); + overlay_mapping.insert( + serde_yaml_ng::Value::String("kind".into()), + serde_yaml_ng::Value::String(KIND.into()), + ); + merge_value(&mut inherited, overlay); + let expanded = serde_yaml_ng::to_string(&inherited).map_err(Error::Yaml)?; + decode(expanded.as_bytes()).map_err(|error| manifest_error(path, "expanded variant", &error))?; + Ok(inherited) + } + _ => Err(Error::Invalid(format!( + "{}: kind must be {KIND:?} or {VARIANT_KIND:?}", + display_leaf(path) + ))), + } +} + +fn validate_variant(variant: &AgentVariant, path: &Path) -> Result<(), Error> { + if variant.api_version != API_VERSION { + return Err(Error::Invalid(format!( + "{}: apiVersion must be {API_VERSION:?}", + display_leaf(path) + ))); + } + if variant.kind != VARIANT_KIND { + return Err(Error::Invalid(format!( + "{}: kind must be {VARIANT_KIND:?}", + display_leaf(path) + ))); + } + let metadata = variant + .metadata + .as_mapping() + .ok_or_else(|| Error::Invalid(format!("{}: metadata must be a mapping", display_leaf(path))))?; + let name = string_field(metadata, "name", path).map_err(|_| { + Error::Invalid(format!( + "{}: metadata.name must be explicitly specified", + display_leaf(path) + )) + })?; + if name.is_empty() { + return Err(Error::Invalid(format!( + "{}: metadata.name must be explicitly specified", + display_leaf(path) + ))); + } + if let Some(spec) = &variant.spec + && !spec.is_mapping() + { + return Err(Error::Invalid(format!( + "{}: spec must be a mapping", + display_leaf(path) + ))); + } + Ok(()) +} + +fn sibling_base(path: &Path, extends: &str) -> Result { + let base = Path::new(extends); + let one_normal_component = { + let mut components = base.components(); + matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() + }; + if !one_normal_component || !is_manifest_filename(base) { + return Err(Error::Invalid(format!( + "{}: extends must name agent.yaml or a sibling agent..yaml", + display_leaf(path) + ))); + } + let parent = path + .parent() + .ok_or_else(|| Error::Invalid(format!("{}: manifest path has no parent directory", display_leaf(path))))?; + Ok(parent.join(base)) +} + +fn merge_value(base: &mut serde_yaml_ng::Value, patch: serde_yaml_ng::Value) { + if let (Some(base_mapping), Some(patch_mapping)) = (base.as_mapping(), patch.as_mapping()) { + let discriminator = serde_yaml_ng::Value::String("type".into()); + if let (Some(base_type), Some(patch_type)) = ( + base_mapping.get(&discriminator).and_then(serde_yaml_ng::Value::as_str), + patch_mapping.get(&discriminator).and_then(serde_yaml_ng::Value::as_str), + ) && base_type != patch_type + { + // Internally tagged union values are one logical scalar choice. + // Changing the discriminator replaces the whole mapping so fields + // belonging to the previous variant cannot leak into the new one. + *base = patch; + return; + } + } + match (base, patch) { + (serde_yaml_ng::Value::Mapping(base), serde_yaml_ng::Value::Mapping(patch)) => { + for (key, value) in patch { + if value.is_null() { + if base.remove(&key).is_none() { + // Preserve a null for fields absent from the concrete base. + // Known optional fields still decode successfully, while + // strict deserialization rejects unknown null-valued fields. + base.insert(key, value); + } + } else if let Some(inherited) = base.get_mut(&key) { + merge_value(inherited, value); + } else { + base.insert(key, value); + } + } + } + (base, patch) => *base = patch, + } +} + +fn string_field(mapping: &serde_yaml_ng::Mapping, field: &str, path: &Path) -> Result { + mapping + .get(serde_yaml_ng::Value::String(field.into())) + .and_then(serde_yaml_ng::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| Error::Invalid(format!("{}: {field} must be a string", display_leaf(path)))) +} + +fn absolute_lexical(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn display_leaf(path: &Path) -> String { + path.file_name().map_or_else( + || path.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ) +} + +fn manifest_error(path: &Path, context: &str, error: &Error) -> Error { + Error::Invalid(format!("{}: invalid {context}: {error}", display_leaf(path))) +} + +fn with_chain(error: Error, chain: &[PathBuf]) -> Error { + if chain.len() < 2 || error.to_string().contains("inheritance chain:") { + return error; + } + let mut lines = chain.iter().map(|path| display_leaf(path)); + let Some(first) = lines.next() else { + return error; + }; + let diagnostic = std::iter::once(first) + .chain(lines.map(|line| format!(" extends {line}"))) + .collect::>() + .join("\n"); + Error::Invalid(format!("{error}\ninheritance chain:\n{diagnostic}")) +} diff --git a/agentctl/src/persistence/agents.rs b/agentctl/src/persistence/agents.rs new file mode 100644 index 0000000..018234f --- /dev/null +++ b/agentctl/src/persistence/agents.rs @@ -0,0 +1,242 @@ +//! Agent resource persistence with one column group per write owner. + +use rusqlite::{Connection, OptionalExtension as _, params}; + +use crate::{Agent, AgentId, Error, Status, control_plane::AgentRecord}; + +use super::{database_error, secrets}; + +pub(super) fn get(connection: &Connection, id: AgentId) -> Result { + connection + .query_row( + "SELECT id, active_name, source_directory, desired_json, deletion_timestamp, status_json + FROM agents WHERE id = ?1 AND active_name IS NOT NULL", + [id.to_string()], + decode_row, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound) +} + +pub(super) fn get_by_name(connection: &Connection, name: &str) -> Result { + let record = connection + .query_row( + "SELECT id, active_name, source_directory, desired_json, deletion_timestamp, status_json + FROM agents WHERE active_name = ?1", + [name], + decode_row, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + if record.agent.metadata.name == name { + Ok(record) + } else { + Err(Error::Database( + "stored Agent name does not match its active-name index".into(), + )) + } +} + +pub(super) fn list(connection: &Connection) -> Result, Error> { + let mut statement = connection + .prepare( + "SELECT id, active_name, source_directory, desired_json, deletion_timestamp, status_json + FROM agents WHERE active_name IS NOT NULL ORDER BY active_name", + ) + .map_err(database_error)?; + statement + .query_map([], decode_row) + .map_err(database_error)? + .map(|row| row.map_err(database_error)) + .collect() +} + +pub(super) fn put(connection: &mut Connection, record: &AgentRecord, expected_generation: u64) -> Result<(), Error> { + let id = record.id; + let name = &record.agent.metadata.name; + let source = serde_json::to_string(&crate::Provenance { + source_directory: record.source_directory.clone(), + manifest_path: record.manifest_path.clone(), + env_file: record.env_file.clone(), + })?; + let desired = encode_desired(&record.agent)?; + let transaction = connection.transaction().map_err(database_error)?; + let changed = if expected_generation == 0 { + transaction + .execute( + "INSERT OR IGNORE INTO agents + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + id.to_string(), + name, + source, + desired, + deletion_timestamp(&record.agent), + encode_status(&record.agent.status)? + ], + ) + .map_err(database_error)? + } else { + let current = get(&transaction, id)?; + if current.agent.metadata.generation != expected_generation + || current.agent.metadata.name != *name + || current.agent.metadata.deletion_timestamp.is_some() + { + return Err(Error::Conflict); + } + transaction + .execute( + "UPDATE agents SET source_directory = ?1, desired_json = ?2 + WHERE id = ?3 AND active_name = ?4 AND deletion_timestamp IS NULL", + params![source, desired, id.to_string(), name], + ) + .map_err(database_error)? + }; + if changed != 1 { + return Err(Error::Conflict); + } + transaction.commit().map_err(database_error) +} + +pub(super) fn update_status( + connection: &mut Connection, + id: AgentId, + generation: u64, + mut status: Status, +) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let record = get(&transaction, id)?; + if record.agent.metadata.generation != generation { + return Err(Error::Conflict); + } + scrub(&mut status); + status.stamp_transitions(&record.agent.status, time::OffsetDateTime::now_utc()); + let changed = transaction + .execute( + "UPDATE agents SET status_json = ?1 WHERE id = ?2 AND active_name IS NOT NULL", + params![serde_json::to_string(&status)?, id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + transaction.commit().map_err(database_error)?; + Ok(status) +} + +pub(super) fn mark_deleting(connection: &mut Connection, name: &str) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let mut record = get_by_name(&transaction, name)?; + if record.agent.metadata.deletion_timestamp.is_none() { + let timestamp = time::OffsetDateTime::now_utc(); + let changed = transaction + .execute( + "UPDATE agents SET deletion_timestamp = ?1 WHERE id = ?2 AND active_name = ?3", + params![timestamp.unix_timestamp(), record.id.to_string(), name], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + record.agent.metadata.deletion_timestamp = Some(timestamp); + } + transaction.commit().map_err(database_error)?; + Ok(record) +} + +pub(super) fn finalize_deletion(connection: &mut Connection, id: AgentId, generation: u64) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + let record = get(&transaction, id)?; + if record.agent.metadata.generation != generation || record.agent.metadata.deletion_timestamp.is_none() { + return Err(Error::Conflict); + } + let changed = transaction + .execute( + "UPDATE agents SET active_name = NULL WHERE id = ?1 AND active_name IS NOT NULL", + [id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + secrets::delete_agent_secrets(&transaction, id)?; + secrets::delete_secret(&transaction, &super::ssh_host_key_name(id))?; + transaction.commit().map_err(database_error) +} + +fn encode_desired(agent: &Agent) -> Result { + let mut desired = agent.clone(); + desired.metadata.deletion_timestamp = None; + desired.status = Status::default(); + serde_json::to_string(&desired).map_err(Error::from) +} + +/// Serializes status for storage, scrubbing API-projected progress and provenance. +fn encode_status(status: &Status) -> Result { + let mut status = status.clone(); + scrub(&mut status); + serde_json::to_string(&status).map_err(Error::from) +} + +/// Removes what is projected onto responses and never stored. +fn scrub(status: &mut Status) { + status.progress = None; + status.provenance = None; +} + +/// Source-column payload: current writes store [`crate::Provenance`]; rows +/// written before the manifest path was recorded hold a bare directory string. +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum StoredSource { + Provenance(crate::Provenance), + Directory(std::path::PathBuf), +} + +fn deletion_timestamp(agent: &Agent) -> Option { + agent + .metadata + .deletion_timestamp + .map(time::OffsetDateTime::unix_timestamp) +} + +fn decode_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id = row.get::<_, String>(0)?; + let active_name = row.get::<_, String>(1)?; + let source = row.get::<_, String>(2)?; + let desired = row.get::<_, String>(3)?; + let deletion = row.get::<_, Option>(4)?; + let status = row.get::<_, String>(5)?; + let id = id.parse::().map_err(conversion_error)?; + let (source_directory, manifest_path, env_file) = match serde_json::from_str(&source).map_err(conversion_error)? { + StoredSource::Provenance(provenance) => ( + provenance.source_directory, + provenance.manifest_path, + provenance.env_file, + ), + StoredSource::Directory(directory) => (directory, None, None), + }; + let mut agent = serde_json::from_str::(&desired).map_err(conversion_error)?; + if agent.metadata.name != active_name { + return Err(rusqlite::Error::InvalidQuery); + } + agent.metadata.deletion_timestamp = deletion + .map(time::OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(conversion_error)?; + agent.status = serde_json::from_str(&status).map_err(conversion_error)?; + Ok(AgentRecord { + id, + source_directory, + manifest_path, + env_file, + agent, + }) +} + +fn conversion_error(error: impl std::error::Error + Send + Sync + 'static) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(error)) +} diff --git a/agentctl/src/persistence/mod.rs b/agentctl/src/persistence/mod.rs new file mode 100644 index 0000000..496a9b7 --- /dev/null +++ b/agentctl/src/persistence/mod.rs @@ -0,0 +1,1104 @@ +//! Persistent local control-plane state owned by one dedicated `SQLite` thread. + +use std::{path::Path, thread}; + +use rusqlite::Connection; +use tokio::sync::oneshot; +use zeroize::Zeroizing; + +use crate::{AgentId, Error, Status, control_plane::AgentRecord, local::home}; + +mod agents; +mod schema; +mod secrets; +mod sessions; + +/// Persistent Agent store backed by the shared control-plane database owner. +/// +/// Every successful write to Agents or Sessions advances [`Database::changes`]. +#[derive(Clone)] +pub struct Database { + sender: tokio::sync::mpsc::Sender, + changes: crate::resources::Changes, +} + +pub(crate) struct ProviderAccountWrite { + pub(crate) provider: String, + pub(crate) credentials: Vec, + pub(crate) metadata_json: String, +} + +pub(crate) struct StoredSecret { + pub(crate) name: String, + pub(crate) value: Zeroizing>, +} + +impl Database { + /// Opens a compatible database, then starts its dedicated owner thread. + /// + /// Call this during daemon startup, before entering the local async runtime. + /// + /// # Errors + /// + /// Returns an error when the database cannot be created, secured, or its + /// schema does not match this build. + pub fn open(path: &Path) -> Result { + let (sender, mut receiver) = tokio::sync::mpsc::channel(256); + let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(1); + let path = path.to_path_buf(); + thread::Builder::new() + .name("agent-database".into()) + .spawn(move || database_thread(&path, &mut receiver, &ready_sender)) + .map_err(Error::Io)?; + ready_receiver + .recv() + .map_err(|_| Error::Database("database thread stopped during startup".into()))??; + Ok(Self { + sender, + changes: crate::resources::Changes::new(), + }) + } + + /// Returns the change history advanced by every Agent and Session write. + #[must_use] + pub fn changes(&self) -> crate::resources::Changes { + self.changes.clone() + } + + /// Applies pending schema migrations without starting a database owner thread. + /// + /// This is used while the updater exclusively owns the control-plane home. + /// Opening the database also creates the same pre-migration backup as daemon startup. + /// + /// # Errors + /// + /// Returns an error when backup, schema validation, or migration fails. + pub fn migrate(path: &Path) -> Result<(), Error> { + drop(open(path)?); + Ok(()) + } + + async fn request(&self, build: impl FnOnce(oneshot::Sender>) -> Command) -> Result { + let (response, receiver) = oneshot::channel(); + let command = build(response); + let observable = command.changes_resources(); + self.sender + .send(command) + .await + .map_err(|_| Error::Database("database thread stopped".into()))?; + let result = receiver + .await + .map_err(|_| Error::Database("database thread dropped a response".into()))?; + if observable && result.is_ok() { + self.changes.bump(); + } + result + } + + pub(crate) async fn put_provider_account(&self, account: ProviderAccountWrite) -> Result<(), Error> { + self.request(|response| Command::PutProviderAccount { account, response }) + .await + } + + pub(crate) async fn replace_agent_secrets( + &self, + id: AgentId, + secrets: Vec, + ) -> Result, Error> { + let references = secrets + .iter() + .map(|secret| sandbox::secret_store::SecretReference::from_opaque(agent_secret_name(id, &secret.name))) + .collect(); + self.request(|response| Command::ReplaceAgentSecrets { id, secrets, response }) + .await?; + Ok(references) + } + + pub(crate) async fn provider_account_exists(&self, provider: &str) -> Result { + self.request(|response| Command::ProviderAccountExists { + provider: provider.into(), + response, + }) + .await + } + + pub(crate) async fn provider_account_metadata(&self, provider: &str) -> Result, Error> { + self.request(|response| Command::ProviderAccountMetadata { + provider: provider.into(), + response, + }) + .await + } +} + +impl crate::sessions::SessionReports for Database { + fn record_session_start_for_launch<'a>( + &'a self, + id: crate::sessions::SessionId, + token: &'a crate::sessions::LaunchToken, + event_id: uuid::Uuid, + native: &'a str, + transcript_path: Option<&'a str>, + at: time::OffsetDateTime, + ) -> sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::RecordSessionStartForLaunch { + id, + token: token.clone(), + event_id, + at, + native: native.into(), + transcript_path: transcript_path.map(str::to_owned), + response, + }) + .await + }) + } + + fn apply_session_activity_for_launch<'a>( + &'a self, + id: crate::sessions::SessionId, + token: &'a crate::sessions::LaunchToken, + event_id: uuid::Uuid, + event: crate::sessions::ActivityEvent, + at: time::OffsetDateTime, + ) -> sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::ApplySessionActivityForLaunch { + id, + token: token.clone(), + event_id, + event, + at, + response, + }) + .await + }) + } +} + +impl crate::sessions::SessionStore for Database { + fn ensure_session<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + new: crate::sessions::NewSession, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::EnsureSession { + agent: agent.into(), + name: name.clone(), + new, + response, + }) + .await + }) + } + + fn get_session( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::GetSession { id, response }).await }) + } + + fn get_agent_session<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::GetSessionByName { + agent: agent.into(), + name: name.clone(), + response, + }) + .await + }) + } + + fn list_all_sessions(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { self.request(|response| Command::ListAllSessions { response }).await }) + } + + fn list_agent_sessions<'a>( + &'a self, + agent: &'a str, + ) -> sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::ListSessions { + agent: agent.into(), + response, + }) + .await + }) + } + + fn update_session_lifecycle( + &self, + id: crate::sessions::SessionId, + lifecycle: crate::sessions::Lifecycle, + observed_activation_generation: u64, + ) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::UpdateSessionLifecycle { + id, + lifecycle, + observed_activation_generation, + response, + }) + .await + }) + } + + fn activate_session(&self, id: crate::sessions::SessionId) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::ActivateSession { id, response }).await }) + } + + fn mark_session_deleting<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::MarkSessionDeleting { + agent: agent.into(), + name: name.clone(), + response, + }) + .await + }) + } + + fn set_session_archived<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + archived: bool, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::SetSessionArchived { + agent: agent.into(), + name: name.clone(), + archived, + response, + }) + .await + }) + } + + fn finalize_session_deletion(&self, id: crate::sessions::SessionId) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::FinalizeSessionDeletion { id, response }) + .await + }) + } + + fn session_attach_target( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::GetAttachTarget { id, response }).await }) + } + + fn clear_session_report(&self, id: crate::sessions::SessionId) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::ClearSessionReport { id, response }) + .await + }) + } + + fn record_session_launch( + &self, + id: crate::sessions::SessionId, + launch: crate::sessions::LaunchRecord, + ) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::RecordSessionLaunch { + id, + token: launch.token, + sandbox: launch.sandbox, + launched_at: launch.launched_at, + attempts: launch.attempts, + response, + }) + .await + }) + } + + fn session_launch_state( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::GetSessionLaunchState { id, response }) + .await + }) + } + + fn reset_session_launch_attempts( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::ResetSessionLaunchAttempts { id, response }) + .await + }) + } +} + +impl crate::control_plane::AgentStore for Database { + fn get(&self, id: AgentId) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::Get { id, response }).await }) + } + + fn get_by_name<'a>(&'a self, name: &'a str) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::GetByName { + name: name.into(), + response, + }) + .await + }) + } + + fn list(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { self.request(|response| Command::List { response }).await }) + } + + fn put(&self, record: AgentRecord, expected_generation: u64) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::Put { + record: Box::new(record), + expected_generation, + response, + }) + .await + }) + } + + fn update_status( + &self, + id: AgentId, + generation: u64, + status: Status, + ) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { + self.request(|response| Command::UpdateStatus { + id, + generation, + status: Box::new(status), + response, + }) + .await + }) + } + + fn mark_deleting<'a>(&'a self, name: &'a str) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::MarkDeleting { + name: name.into(), + response, + }) + .await + }) + } + + fn finalize_deletion(&self, id: AgentId, generation: u64) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::FinalizeDeletion { + id, + generation, + response, + }) + .await + }) + } +} + +impl sandbox::secret_store::SecretStore for Database { + fn set<'a>( + &'a self, + name: &'a str, + value: &'a [u8], + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + if name.is_empty() { + return Err(sandbox::Error::invalid("secret.name", "must not be empty")); + } + let value = Zeroizing::new(value.to_vec()); + self.request(|response| Command::SetSecret { + name: name.into(), + value, + response, + }) + .await + .map_err(secret_store_error)?; + Ok(sandbox::secret_store::SecretReference::from_opaque(name)) + }) + } + + fn resolve<'a>( + &'a self, + reference: &'a sandbox::secret_store::SecretReference, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::ResolveSecret { + name: reference.as_str().into(), + response, + }) + .await + .map_err(secret_store_error) + }) + } +} + +impl crate::ssh::HostKeyStore for Database { + fn load_host_key(&self, id: AgentId) -> sandbox::LocalFuture<'_, Result>>, Error>> { + Box::pin(async move { + match self + .request(|response| Command::ResolveSecret { + name: ssh_host_key_name(id), + response, + }) + .await + { + Ok(material) => Ok(Some(Zeroizing::new(material.expose().to_vec()))), + Err(Error::NotFound) => Ok(None), + Err(error) => Err(error), + } + }) + } + + fn store_host_key(&self, id: AgentId, key: Zeroizing>) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::SetSecret { + name: ssh_host_key_name(id), + value: key, + response, + }) + .await + }) + } + + fn delete_host_key(&self, id: AgentId) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::DeleteSecret { + name: ssh_host_key_name(id), + response, + }) + .await + }) + } +} + +enum Command { + Get { + id: AgentId, + response: oneshot::Sender>, + }, + GetByName { + name: String, + response: oneshot::Sender>, + }, + List { + response: oneshot::Sender, Error>>, + }, + Put { + record: Box, + expected_generation: u64, + response: oneshot::Sender>, + }, + UpdateStatus { + id: AgentId, + generation: u64, + status: Box, + response: oneshot::Sender>, + }, + MarkDeleting { + name: String, + response: oneshot::Sender>, + }, + FinalizeDeletion { + id: AgentId, + generation: u64, + response: oneshot::Sender>, + }, + SetSecret { + name: String, + value: Zeroizing>, + response: oneshot::Sender>, + }, + DeleteSecret { + name: String, + response: oneshot::Sender>, + }, + ReplaceAgentSecrets { + id: AgentId, + secrets: Vec, + response: oneshot::Sender>, + }, + ResolveSecret { + name: String, + response: oneshot::Sender>, + }, + PutProviderAccount { + account: ProviderAccountWrite, + response: oneshot::Sender>, + }, + ProviderAccountExists { + provider: String, + response: oneshot::Sender>, + }, + ProviderAccountMetadata { + provider: String, + response: oneshot::Sender, Error>>, + }, + EnsureSession { + agent: String, + name: crate::sessions::SessionName, + new: crate::sessions::NewSession, + response: oneshot::Sender>, + }, + GetSession { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + GetSessionByName { + agent: String, + name: crate::sessions::SessionName, + response: oneshot::Sender>, + }, + ListAllSessions { + response: oneshot::Sender, Error>>, + }, + ListSessions { + agent: String, + response: oneshot::Sender, Error>>, + }, + UpdateSessionLifecycle { + id: crate::sessions::SessionId, + lifecycle: crate::sessions::Lifecycle, + observed_activation_generation: u64, + response: oneshot::Sender>, + }, + ActivateSession { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + MarkSessionDeleting { + agent: String, + name: crate::sessions::SessionName, + response: oneshot::Sender>, + }, + FinalizeSessionDeletion { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + SetSessionArchived { + agent: String, + name: crate::sessions::SessionName, + archived: bool, + response: oneshot::Sender>, + }, + GetAttachTarget { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + ClearSessionReport { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + RecordSessionStartForLaunch { + id: crate::sessions::SessionId, + token: crate::sessions::LaunchToken, + event_id: uuid::Uuid, + at: time::OffsetDateTime, + native: String, + transcript_path: Option, + response: oneshot::Sender, Error>>, + }, + ApplySessionActivityForLaunch { + id: crate::sessions::SessionId, + token: crate::sessions::LaunchToken, + event_id: uuid::Uuid, + event: crate::sessions::ActivityEvent, + at: time::OffsetDateTime, + response: oneshot::Sender, Error>>, + }, + RecordSessionLaunch { + id: crate::sessions::SessionId, + token: crate::sessions::LaunchToken, + sandbox: String, + launched_at: i64, + attempts: u32, + response: oneshot::Sender, Error>>, + }, + GetSessionLaunchState { + id: crate::sessions::SessionId, + response: oneshot::Sender, Error>>, + }, + ResetSessionLaunchAttempts { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, +} + +impl Command { + /// Returns whether a successful execution changes an Agent or Session resource. + const fn changes_resources(&self) -> bool { + match self { + Self::Put { .. } + | Self::UpdateStatus { .. } + | Self::MarkDeleting { .. } + | Self::FinalizeDeletion { .. } + | Self::EnsureSession { .. } + | Self::UpdateSessionLifecycle { .. } + | Self::ActivateSession { .. } + | Self::MarkSessionDeleting { .. } + | Self::FinalizeSessionDeletion { .. } + | Self::SetSessionArchived { .. } + | Self::ClearSessionReport { .. } + | Self::RecordSessionStartForLaunch { .. } + | Self::ApplySessionActivityForLaunch { .. } + | Self::RecordSessionLaunch { .. } => true, + Self::Get { .. } + | Self::GetByName { .. } + | Self::List { .. } + | Self::SetSecret { .. } + | Self::DeleteSecret { .. } + | Self::ReplaceAgentSecrets { .. } + | Self::ResolveSecret { .. } + | Self::PutProviderAccount { .. } + | Self::ProviderAccountExists { .. } + | Self::ProviderAccountMetadata { .. } + | Self::GetSession { .. } + | Self::GetSessionByName { .. } + | Self::ListAllSessions { .. } + | Self::ListSessions { .. } + | Self::GetAttachTarget { .. } + | Self::GetSessionLaunchState { .. } + | Self::ResetSessionLaunchAttempts { .. } => false, + } + } +} + +fn database_thread( + path: &Path, + receiver: &mut tokio::sync::mpsc::Receiver, + ready: &std::sync::mpsc::SyncSender>, +) { + let connection = open(path); + let Ok(mut connection) = connection else { + let _ = ready.send(connection.map(|_| ())); + return; + }; + if ready.send(Ok(())).is_err() { + return; + } + while let Some(command) = receiver.blocking_recv() { + execute(&mut connection, command); + } +} + +fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + home::secure_directory(parent)?; + } + let mut connection = Connection::open(path).map_err(database_error)?; + home::secure_file(path)?; + // Finish fallible connection setup before the transactional schema migration. + connection + .execute_batch("PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA journal_mode = WAL;") + .map_err(database_error)?; + if let Some(version) = schema::pending_version(&connection)? { + backup_database(path, version)?; + } + schema::initialize(&mut connection)?; + Ok(connection) +} + +fn backup_database(path: &Path, version: u32) -> Result<(), Error> { + let parent = path + .parent() + .ok_or_else(|| Error::Database("database path has no parent directory".into()))?; + let directory = parent.join("backups"); + std::fs::create_dir_all(&directory)?; + home::secure_directory(&directory)?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| Error::Database(format!("system clock precedes Unix epoch: {error}")))? + .as_nanos(); + let backup = directory.join(format!("agent-schema-{version}-{timestamp}.db")); + let backup_connection = Connection::open(path).map_err(database_error)?; + backup_connection + .execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()]) + .map_err(database_error)?; + drop(backup_connection); + #[cfg(unix)] + { + home::secure_file(&backup)?; + std::fs::File::open(&backup)?.sync_all()?; + sync_directory(&directory)?; + } + // On Windows the file inherits the owner-only ACL from `directory`. + // SQLite commits and flushes VACUUM INTO before the connection closes; + // reopening its output immediately for ACL or flush operations is denied. + + let mut backups = std::fs::read_dir(&directory)? + .collect::, _>>()? + .into_iter() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .filter_map(|entry| { + let name = entry.file_name(); + let timestamp = name + .to_str()? + .strip_prefix("agent-schema-")? + .strip_suffix(".db")? + .rsplit_once('-')? + .1 + .parse::() + .ok()?; + Some((timestamp, entry)) + }) + .collect::>(); + backups.sort_by_key(|(timestamp, _)| *timestamp); + let remove = backups.len().saturating_sub(3); + for (_, entry) in backups.into_iter().take(remove) { + std::fs::remove_file(entry.path())?; + } + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), Error> { + std::fs::File::open(path)?.sync_all()?; + Ok(()) +} + +fn execute(connection: &mut Connection, command: Command) { + match command { + Command::Get { id, response } => { + let _ = response.send(agents::get(connection, id)); + } + Command::GetByName { name, response } => { + let _ = response.send(agents::get_by_name(connection, &name)); + } + Command::List { response } => { + let _ = response.send(agents::list(connection)); + } + Command::Put { + record, + expected_generation, + response, + } => { + let _ = response.send(agents::put(connection, &record, expected_generation)); + } + Command::UpdateStatus { + id, + generation, + status, + response, + } => { + let _ = response.send(agents::update_status(connection, id, generation, *status)); + } + Command::MarkDeleting { name, response } => { + let _ = response.send(agents::mark_deleting(connection, &name)); + } + Command::FinalizeDeletion { + id, + generation, + response, + } => { + let _ = response.send(agents::finalize_deletion(connection, id, generation)); + } + Command::DeleteSecret { name, response } => { + let _ignored = response.send(secrets::delete_secret(connection, &name)); + } + Command::SetSecret { name, value, response } => { + let _ = response.send(secrets::set_secret(connection, &name, &value)); + } + Command::ReplaceAgentSecrets { id, secrets, response } => { + let _ = response.send(secrets::replace_agent_secrets(connection, id, &secrets)); + } + Command::ResolveSecret { name, response } => { + let _ = response.send(secrets::resolve_secret(connection, &name)); + } + Command::PutProviderAccount { account, response } => { + let _ = response.send(secrets::put_provider_account(connection, &account)); + } + Command::ProviderAccountExists { provider, response } => { + let _ = response.send(secrets::provider_account_exists(connection, &provider)); + } + Command::ProviderAccountMetadata { provider, response } => { + let _ = response.send(secrets::provider_account_metadata(connection, &provider)); + } + session_command => execute_session(connection, session_command), + } +} + +fn execute_session(connection: &mut Connection, command: Command) { + match command { + Command::EnsureSession { + agent, + name, + new, + response, + } => { + let _ = response.send(sessions::ensure(connection, &agent, &name, &new)); + } + Command::GetSession { id, response } => { + let _ = response.send(sessions::get(connection, id)); + } + Command::GetSessionByName { agent, name, response } => { + let _ = response.send(sessions::get_by_name(connection, &agent, &name)); + } + Command::ListAllSessions { response } => { + let _ = response.send(sessions::list_all(connection)); + } + Command::ListSessions { agent, response } => { + let _ = response.send(sessions::list_for_agent(connection, &agent)); + } + Command::UpdateSessionLifecycle { + id, + lifecycle, + observed_activation_generation, + response, + } => { + let _ = response.send(sessions::update_lifecycle( + connection, + id, + lifecycle, + observed_activation_generation, + )); + } + Command::ActivateSession { id, response } => { + let _ = response.send(sessions::activate(connection, id)); + } + Command::MarkSessionDeleting { agent, name, response } => { + let _ = response.send(sessions::mark_deleting(connection, &agent, &name)); + } + Command::FinalizeSessionDeletion { id, response } => { + let _ = response.send(sessions::finalize_deletion(connection, id)); + } + Command::SetSessionArchived { + agent, + name, + archived, + response, + } => { + let _ = response.send(sessions::set_archived(connection, &agent, &name, archived)); + } + Command::GetAttachTarget { id, response } => { + let _ = response.send(sessions::attach_target(connection, id)); + } + command @ (Command::ClearSessionReport { .. } + | Command::RecordSessionStartForLaunch { .. } + | Command::ApplySessionActivityForLaunch { .. }) => execute_session_report(connection, command), + Command::RecordSessionLaunch { + id, + token, + sandbox, + launched_at, + attempts, + response, + } => { + let _ = response.send(sessions::record_launch( + connection, + id, + &token, + &sandbox, + launched_at, + attempts, + )); + } + Command::GetSessionLaunchState { id, response } => { + let _ = response.send(sessions::launch_state(connection, id)); + } + Command::ResetSessionLaunchAttempts { id, response } => { + let _ = response.send(sessions::reset_launch_attempts(connection, id)); + } + // Every non-Session command is matched exhaustively by `execute`. + _ => unreachable!("non-Session command routed to the Session executor"), + } +} + +/// Executes the hook-route commands that write the reported half of a Session. +fn execute_session_report(connection: &mut Connection, command: Command) { + match command { + Command::ClearSessionReport { id, response } => { + let _ = response.send(sessions::clear_report(connection, id)); + } + Command::RecordSessionStartForLaunch { + id, + token, + event_id, + at, + native, + transcript_path, + response, + } => { + let _ = response.send(sessions::record_start_for_launch( + connection, + id, + &token, + event_id, + &native, + transcript_path.as_deref(), + at, + )); + } + Command::ApplySessionActivityForLaunch { + id, + token, + event_id, + event, + at, + response, + } => { + let _ = response.send(sessions::apply_activity_for_launch( + connection, id, &token, event_id, event, at, + )); + } + // Only report commands are routed here by `execute_session`. + _ => unreachable!("non-report command routed to the Session report executor"), + } +} + +pub(super) fn database_error(error: rusqlite::Error) -> Error { + let message = error.to_string(); + drop(error); + Error::Database(message) +} + +fn secret_store_error(error: Error) -> sandbox::Error { + match error { + Error::NotFound => sandbox::Error::not_found(sandbox::ResourceKind::Secret, "host secret"), + other => sandbox::Error::Backend(other.to_string()), + } +} + +fn agent_secret_prefix(id: AgentId) -> String { + format!("agent/{id}/") +} + +/// Secret row holding one incarnation's SSH host key. The name is outside the +/// `agent//` prefix so that replacing the manifest's secrets keeps it. +pub(crate) fn ssh_host_key_name(id: AgentId) -> String { + format!("agent-ssh/{id}/host-key") +} + +fn agent_secret_name(id: AgentId, name: &str) -> String { + format!("{}{name}", agent_secret_prefix(id)) +} + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + use sandbox::secret_store::{SecretReference, SecretStore as _}; + use tempfile::TempDir; + use zeroize::Zeroizing; + + use super::{Database, StoredSecret, open}; + + #[test] + fn database_owner_enables_secure_deletion() { + let directory = TempDir::new().expect("temporary directory"); + let connection = open(&directory.path().join("agent.db")).expect("database connection"); + + assert_eq!( + connection + .query_row("PRAGMA secure_delete", [], |row| row.get::<_, u8>(0)) + .expect("secure-delete setting"), + 1 + ); + } + + #[test] + fn pending_migration_creates_and_prunes_owner_only_backups() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("agent.db"); + drop(open(&path).expect("current database")); + for _ in 0..4 { + let connection = Connection::open(&path).expect("database"); + connection + .pragma_update(None, "user_version", super::schema::VERSION - 1) + .expect("old version"); + drop(connection); + Database::migrate(&path).expect("adopt the expanded previous version after backup"); + } + let backups = std::fs::read_dir(directory.path().join("backups")) + .expect("backups") + .collect::, _>>() + .expect("backup entries"); + assert_eq!(backups.len(), 3); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(directory.path().join("backups")) + .expect("directory metadata") + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert!( + backups + .iter() + .all(|entry| entry.metadata().expect("backup metadata").permissions().mode() & 0o777 == 0o600) + ); + } + } + + #[tokio::test(flavor = "local")] + async fn replacing_agent_secrets_prunes_stale_rows_without_touching_provider_credentials() { + let directory = TempDir::new().expect("temporary directory"); + let database = Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let github = SecretReference::from_opaque(format!("agent/{agent_id}/github-token")); + let studio = SecretReference::from_opaque(format!("agent/{agent_id}/studio-token")); + let provider = database + .set("provider-access-token", b"provider-secret") + .await + .expect("provider secret"); + + database + .replace_agent_secrets( + agent_id, + vec![ + StoredSecret { + name: "github-token".into(), + value: Zeroizing::new(b"github-secret".to_vec()), + }, + StoredSecret { + name: "studio-token".into(), + value: Zeroizing::new(b"studio-secret".to_vec()), + }, + ], + ) + .await + .expect("initial Agent secrets"); + database + .replace_agent_secrets( + agent_id, + vec![StoredSecret { + name: "studio-token".into(), + value: Zeroizing::new(b"rotated-studio-secret".to_vec()), + }], + ) + .await + .expect("replacement Agent secrets"); + + assert!(database.resolve(&github).await.is_err()); + assert_eq!( + database.resolve(&studio).await.expect("retained secret").expose(), + b"rotated-studio-secret" + ); + assert_eq!( + database.resolve(&provider).await.expect("provider secret").expose(), + b"provider-secret" + ); + } +} diff --git a/agentctl/src/persistence/schema.rs b/agentctl/src/persistence/schema.rs new file mode 100644 index 0000000..ff8a228 --- /dev/null +++ b/agentctl/src/persistence/schema.rs @@ -0,0 +1,575 @@ +//! Ordered, transactional `SQLite` schema migrations. + +use std::collections::{BTreeMap, BTreeSet}; + +use rusqlite::{Connection, Transaction}; + +use crate::Error; + +use super::database_error; + +pub(crate) const VERSION: u32 = 5; + +const PREVIEW_1_SQL: &str = " + CREATE TABLE agents ( + id TEXT PRIMARY KEY NOT NULL, + active_name TEXT UNIQUE, + source_directory TEXT NOT NULL, + desired_json TEXT NOT NULL, + deletion_timestamp INTEGER, + status_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE secrets ( + name TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ); + CREATE TABLE provider_accounts ( + provider TEXT PRIMARY KEY NOT NULL, + metadata_json TEXT NOT NULL + ); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id), + name TEXT NOT NULL, + harness TEXT NOT NULL, + created_at INTEGER NOT NULL, + activation_generation INTEGER NOT NULL DEFAULT 0, + lifecycle_json TEXT NOT NULL DEFAULT '{}', + harness_native_id TEXT, + launch_token TEXT UNIQUE, + launch_sandbox TEXT, + launched_at INTEGER, + launch_attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE (agent_id, name) + ); +"; + +const SESSION_COLUMNS_SQL: &str = " + ALTER TABLE sessions ADD COLUMN initial_prompt TEXT; + ALTER TABLE sessions ADD COLUMN harness_transcript_path TEXT; + ALTER TABLE sessions ADD COLUMN activity_json TEXT NOT NULL DEFAULT '{}'; +"; + +const SESSION_ACTIVITY_REPORTS_SQL: &str = " + CREATE TABLE IF NOT EXISTS session_activity_reports ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + launch_token TEXT NOT NULL, + event_id TEXT NOT NULL, + PRIMARY KEY (session_id, launch_token, event_id) + ); +"; + +const SESSION_SELECTION_COLUMNS_SQL: &str = " + ALTER TABLE sessions ADD COLUMN model TEXT; + ALTER TABLE sessions ADD COLUMN effort TEXT; +"; + +const SESSION_DELETION_COLUMN_SQL: &str = " + ALTER TABLE sessions ADD COLUMN deletion_timestamp INTEGER; +"; + +const SESSION_ARCHIVE_COLUMN_SQL: &str = " + ALTER TABLE sessions ADD COLUMN archived_at INTEGER; +"; + +struct Migration { + version: u32, + name: &'static str, + schema: &'static [&'static str], + apply: fn(&Transaction<'_>) -> Result<(), Error>, +} + +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "preview 1 baseline", + schema: &[PREVIEW_1_SQL], + apply: create_preview_1, + }, + Migration { + version: 2, + name: "session management", + schema: &[SESSION_COLUMNS_SQL, SESSION_ACTIVITY_REPORTS_SQL], + apply: add_session_management, + }, + Migration { + version: 3, + name: "session model and effort", + schema: &[SESSION_SELECTION_COLUMNS_SQL], + apply: add_session_selections, + }, + Migration { + version: 4, + name: "session deletion", + schema: &[SESSION_DELETION_COLUMN_SQL], + apply: add_session_deletion, + }, + Migration { + version: 5, + name: "session archive", + schema: &[SESSION_ARCHIVE_COLUMN_SQL], + apply: add_session_archive, + }, +]; + +pub(super) fn initialize(connection: &mut Connection) -> Result<(), Error> { + let current = schema_version(connection)?; + if current > VERSION { + return Err(Error::Database(format!( + "Agent database schema {current} is newer than the supported schema {VERSION}" + ))); + } + if current == 0 && !user_tables(connection)?.is_empty() { + return Err(unknown_schema(0, "the database contains unversioned tables")); + } + if current == VERSION { + return verify_schema(connection, VERSION); + } + + apply_pending_migrations(connection, current, MIGRATIONS, VERSION) +} + +fn apply_pending_migrations( + connection: &mut Connection, + current: u32, + migrations: &[Migration], + target: u32, +) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + for migration in migrations.iter().filter(|migration| migration.version > current) { + (migration.apply)(&transaction).map_err(|error| { + Error::Database(format!( + "failed to apply Agent database migration {} ({}): {error}", + migration.version, migration.name + )) + })?; + } + transaction + .pragma_update(None, "user_version", target) + .map_err(database_error)?; + verify_schema_with(&transaction, migrations, target)?; + transaction.commit().map_err(database_error) +} + +pub(super) fn pending_version(connection: &Connection) -> Result, Error> { + let version = schema_version(connection)?; + Ok((version > 0 && version < VERSION).then_some(version)) +} + +fn create_preview_1(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction.execute_batch(PREVIEW_1_SQL).map_err(database_error) +} + +fn add_session_management(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 2)?.is_none() { + return migrate_agent_instructions(transaction); + } + if schema_difference(transaction, 1)?.is_some() { + verify_intermediate_schema(transaction)?; + } + migrate_agent_instructions(transaction)?; + transaction.execute_batch(SESSION_COLUMNS_SQL).map_err(database_error)?; + transaction + .execute_batch(SESSION_ACTIVITY_REPORTS_SQL) + .map_err(database_error) +} + +/// Adds the model and effort a Session was created with. Sessions from earlier +/// schemas never chose either; an adapter that hardcoded a launch model until now +/// reports it, and that model is recorded for its existing Sessions so they keep +/// launching on one known model once the choice is a Session property. +fn add_session_selections(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 3)?.is_none() { + return Ok(()); + } + transaction + .execute_batch(SESSION_SELECTION_COLUMNS_SQL) + .map_err(database_error)?; + let harnesses = { + let mut statement = transaction + .prepare("SELECT DISTINCT harness FROM sessions WHERE model IS NULL") + .map_err(database_error)?; + statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + for value in harnesses { + let Some(model) = value + .parse::() + .ok() + .and_then(crate::harness::model_launched_before_selection) + else { + continue; + }; + transaction + .execute( + "UPDATE sessions SET model = ?1 WHERE harness = ?2 AND model IS NULL", + rusqlite::params![model, value], + ) + .map_err(database_error)?; + } + Ok(()) +} + +/// Adds the marker that requests a Session's release. Sessions from earlier +/// schemas were never deletable, so every existing row starts unmarked. +fn add_session_deletion(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 4)?.is_none() { + return Ok(()); + } + transaction + .execute_batch(SESSION_DELETION_COLUMN_SQL) + .map_err(database_error) +} + +/// Adds the request to archive a Session. Sessions from earlier schemas were +/// never archived, so every existing row starts active. +fn add_session_archive(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 5)?.is_none() { + return Ok(()); + } + transaction + .execute_batch(SESSION_ARCHIVE_COLUMN_SQL) + .map_err(database_error) +} + +fn migrate_agent_instructions(transaction: &Transaction<'_>) -> Result<(), Error> { + let rows = { + let mut statement = transaction + .prepare("SELECT id, desired_json FROM agents ORDER BY id") + .map_err(database_error)?; + statement + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + for (id, encoded) in rows { + let mut desired: serde_json::Value = serde_json::from_str(&encoded).map_err(|error| { + Error::Database(format!( + "Agent {id} has invalid desired state during migration: {error}" + )) + })?; + let spec = desired + .get_mut("spec") + .and_then(serde_json::Value::as_object_mut) + .ok_or_else(|| Error::Database(format!("Agent {id} desired state has no object-valued spec")))?; + let Some(instructions) = spec.get_mut("instructions") else { + continue; + }; + match instructions { + serde_json::Value::Array(_) => continue, + serde_json::Value::Null => *instructions = serde_json::Value::Array(Vec::new()), + serde_json::Value::Object(_) => { + *instructions = serde_json::Value::Array(vec![instructions.take()]); + } + _ => { + return Err(Error::Database(format!( + "Agent {id} desired state has an unexpected preview 1 instructions value" + ))); + } + } + transaction + .execute( + "UPDATE agents SET desired_json = ?1 WHERE id = ?2", + rusqlite::params![serde_json::to_string(&desired)?, id], + ) + .map_err(database_error)?; + } + Ok(()) +} + +fn schema_version(connection: &Connection) -> Result { + connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(database_error) +} + +fn verify_schema(connection: &Connection, version: u32) -> Result<(), Error> { + verify_schema_with(connection, MIGRATIONS, version) +} + +fn verify_schema_with(connection: &Connection, migrations: &[Migration], version: u32) -> Result<(), Error> { + schema_difference_with_migrations(connection, migrations, version)? + .map_or(Ok(()), |detail| Err(unknown_schema(version, &detail))) +} + +fn schema_difference(connection: &Connection, version: u32) -> Result, Error> { + schema_difference_with_migrations(connection, MIGRATIONS, version) +} + +fn schema_difference_with_migrations( + connection: &Connection, + migrations: &[Migration], + version: u32, +) -> Result, Error> { + let statements = migrations + .iter() + .filter(|migration| migration.version <= version) + .flat_map(|migration| migration.schema.iter().copied()) + .collect::>(); + schema_difference_with(connection, &statements) +} + +fn verify_intermediate_schema(connection: &Connection) -> Result<(), Error> { + schema_difference_with(connection, &[PREVIEW_1_SQL, SESSION_ACTIVITY_REPORTS_SQL])? + .map_or(Ok(()), |detail| Err(unknown_schema(1, &detail))) +} + +fn schema_difference_with(connection: &Connection, statements: &[&str]) -> Result, Error> { + let expected = Connection::open_in_memory().map_err(database_error)?; + for sql in statements { + expected.execute_batch(sql).map_err(database_error)?; + } + let actual_tables = user_tables(connection)?; + let expected_tables = user_tables(&expected)?; + if actual_tables != expected_tables { + return Ok(Some(format!( + "expected tables {expected_tables:?}, found {actual_tables:?}" + ))); + } + for table in expected_tables { + let actual = inspect_table(connection, &table)?; + if actual != inspect_table(&expected, &table)? { + return Ok(Some(format!("table {table:?} has an unexpected definition"))); + } + } + Ok(None) +} + +fn user_tables(connection: &Connection) -> Result, Error> { + let mut statement = connection + .prepare( + "SELECT name FROM sqlite_schema \ + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .map_err(database_error)?; + statement + .query_map([], |row| row.get(0)) + .map_err(database_error)? + .collect::>() + .map_err(database_error) +} + +#[derive(Debug, Eq, PartialEq)] +struct TableDefinition { + columns: BTreeMap, + unique_keys: Vec>, + foreign_keys: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +struct ColumnDefinition { + declared_type: String, + not_null: bool, + default: Option, + primary_key_position: u32, +} + +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ForeignKeyDefinition { + table: String, + from: String, + to: String, + on_update: String, + on_delete: String, +} + +fn inspect_table(connection: &Connection, table: &str) -> Result { + let columns = { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(database_error)?; + statement + .query_map([], |row| { + Ok(( + row.get(1)?, + ColumnDefinition { + declared_type: row.get(2)?, + not_null: row.get::<_, u32>(3)? != 0, + default: row.get(4)?, + primary_key_position: row.get(5)?, + }, + )) + }) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + let unique_keys = inspect_unique_keys(connection, table)?; + let foreign_keys = { + let mut statement = connection + .prepare(&format!("PRAGMA foreign_key_list({table})")) + .map_err(database_error)?; + let mut keys = statement + .query_map([], |row| { + Ok(ForeignKeyDefinition { + table: row.get(2)?, + from: row.get(3)?, + to: row.get(4)?, + on_update: row.get(5)?, + on_delete: row.get(6)?, + }) + }) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + keys.sort(); + keys + }; + Ok(TableDefinition { + columns, + unique_keys, + foreign_keys, + }) +} + +fn inspect_unique_keys(connection: &Connection, table: &str) -> Result>, Error> { + let indices = { + let mut statement = connection + .prepare(&format!("PRAGMA index_list({table})")) + .map_err(database_error)?; + statement + .query_map([], |row| Ok((row.get::<_, String>(1)?, row.get::<_, u32>(2)? != 0))) + .map_err(database_error)? + .filter_map(|row| match row { + Ok((name, true)) => Some(Ok(name)), + Ok((_, false)) => None, + Err(error) => Some(Err(error)), + }) + .collect::, _>>() + .map_err(database_error)? + }; + let mut keys = Vec::with_capacity(indices.len()); + for index in indices { + let escaped = index.replace('"', "\"\""); + let mut statement = connection + .prepare(&format!("PRAGMA index_info(\"{escaped}\")")) + .map_err(database_error)?; + keys.push( + statement + .query_map([], |row| row.get(2)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?, + ); + } + keys.sort(); + Ok(keys) +} + +fn unknown_schema(version: u32, detail: &str) -> Error { + Error::Database(format!( + "Agent database schema {version} is not a recognized released schema: {detail}; select a new AGENT_HOME or restore a supported backup" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_first(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction + .execute_batch("CREATE TABLE first (id INTEGER PRIMARY KEY);") + .map_err(database_error) + } + + fn fail_second(_transaction: &Transaction<'_>) -> Result<(), Error> { + Err(Error::Database("injected second migration failure".into())) + } + + fn create_third(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction + .execute_batch("CREATE TABLE third (id INTEGER PRIMARY KEY);") + .map_err(database_error) + } + + fn create_unexpected(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction + .execute_batch("CREATE TABLE unexpected (id INTEGER PRIMARY KEY);") + .map_err(database_error) + } + + #[test] + fn all_pending_migrations_roll_back_together() { + let mut connection = Connection::open_in_memory().expect("database"); + let migrations = [ + Migration { + version: 1, + name: "first", + schema: &[], + apply: create_first, + }, + Migration { + version: 2, + name: "failure", + schema: &[], + apply: fail_second, + }, + ]; + + let error = apply_pending_migrations(&mut connection, 0, &migrations, 2).expect_err("second migration fails"); + + assert!(error.to_string().contains("injected second migration failure")); + assert_eq!(schema_version(&connection).expect("schema version"), 0); + assert!(user_tables(&connection).expect("tables").is_empty()); + } + + #[test] + fn expanded_version_1_continues_through_later_migrations() { + let mut connection = Connection::open_in_memory().expect("database"); + connection.execute_batch(PREVIEW_1_SQL).expect("preview 1 schema"); + connection + .execute_batch(SESSION_COLUMNS_SQL) + .expect("expanded Session columns"); + connection + .execute_batch(SESSION_ACTIVITY_REPORTS_SQL) + .expect("expanded reports table"); + connection.pragma_update(None, "user_version", 1).expect("version 1"); + let migrations = [ + Migration { + version: 1, + name: "preview 1 baseline", + schema: &[PREVIEW_1_SQL], + apply: create_preview_1, + }, + Migration { + version: 2, + name: "session management", + schema: &[SESSION_COLUMNS_SQL, SESSION_ACTIVITY_REPORTS_SQL], + apply: add_session_management, + }, + Migration { + version: 3, + name: "third", + schema: &["CREATE TABLE third (id INTEGER PRIMARY KEY);"], + apply: create_third, + }, + ]; + + apply_pending_migrations(&mut connection, 1, &migrations, 3).expect("migrations"); + + assert_eq!(schema_version(&connection).expect("schema version"), 3); + assert!(user_tables(&connection).expect("tables").contains("third")); + } + + #[test] + fn final_schema_validation_rolls_back_the_migration() { + let mut connection = Connection::open_in_memory().expect("database"); + let migrations = [Migration { + version: 1, + name: "unexpected schema", + schema: &[], + apply: create_unexpected, + }]; + + let error = + apply_pending_migrations(&mut connection, 0, &migrations, 1).expect_err("final schema validation fails"); + + assert!(error.to_string().contains("not a recognized released schema")); + assert_eq!(schema_version(&connection).expect("schema version"), 0); + assert!(user_tables(&connection).expect("tables").is_empty()); + } +} diff --git a/agentctl/src/persistence/secrets.rs b/agentctl/src/persistence/secrets.rs new file mode 100644 index 0000000..15aa77d --- /dev/null +++ b/agentctl/src/persistence/secrets.rs @@ -0,0 +1,123 @@ +//! Host-owned secret and provider-account persistence. + +use std::collections::BTreeSet; + +use rusqlite::{Connection, OptionalExtension as _, params}; + +use crate::{AgentId, Error}; + +use super::{ProviderAccountWrite, StoredSecret, agent_secret_name, agent_secret_prefix, database_error}; + +pub(super) fn set_secret(connection: &Connection, name: &str, value: &[u8]) -> Result<(), Error> { + connection + .execute( + "INSERT INTO secrets (name, value) VALUES (?1, ?2) + ON CONFLICT(name) DO UPDATE SET value = excluded.value", + params![name, value], + ) + .map(|_| ()) + .map_err(database_error) +} + +pub(super) fn resolve_secret( + connection: &Connection, + name: &str, +) -> Result { + connection + .query_row("SELECT value FROM secrets WHERE name = ?1", [name], |row| { + row.get::<_, Vec>(0) + }) + .optional() + .map_err(database_error)? + .map(sandbox::secret_store::SecretMaterial::new) + .ok_or(Error::NotFound) +} + +pub(super) fn replace_agent_secrets( + connection: &mut Connection, + id: AgentId, + secrets: &[StoredSecret], +) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + let mut desired = BTreeSet::new(); + for secret in secrets { + let name = agent_secret_name(id, &secret.name); + if !desired.insert(name.clone()) { + return Err(Error::Invalid(format!("duplicate Agent secret {:?}", secret.name))); + } + set_secret(&transaction, &name, &secret.value)?; + } + + let prefix = agent_secret_prefix(id); + let existing = { + let mut statement = transaction + .prepare("SELECT name FROM secrets WHERE substr(name, 1, length(?1)) = ?1") + .map_err(database_error)?; + statement + .query_map([&prefix], |row| row.get::<_, String>(0)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + for name in existing { + if !desired.contains(&name) { + transaction + .execute("DELETE FROM secrets WHERE name = ?1", [&name]) + .map_err(database_error)?; + } + } + transaction.commit().map_err(database_error) +} + +pub(super) fn delete_secret(connection: &Connection, name: &str) -> Result<(), Error> { + connection + .execute("DELETE FROM secrets WHERE name = ?1", [name]) + .map(|_| ()) + .map_err(database_error) +} + +pub(super) fn delete_agent_secrets(connection: &Connection, id: AgentId) -> Result<(), Error> { + let prefix = agent_secret_prefix(id); + connection + .execute("DELETE FROM secrets WHERE substr(name, 1, length(?1)) = ?1", [&prefix]) + .map(|_| ()) + .map_err(database_error) +} + +pub(super) fn put_provider_account(connection: &mut Connection, account: &ProviderAccountWrite) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + for credential in &account.credentials { + set_secret(&transaction, &credential.name, &credential.value)?; + } + transaction + .execute( + "INSERT INTO provider_accounts (provider, metadata_json) VALUES (?1, ?2) + ON CONFLICT(provider) DO UPDATE SET metadata_json = excluded.metadata_json", + params![account.provider, account.metadata_json], + ) + .map_err(database_error)?; + transaction.commit().map_err(database_error) +} + +pub(super) fn provider_account_exists(connection: &Connection, provider: &str) -> Result { + connection + .query_row( + "SELECT 1 FROM provider_accounts WHERE provider = ?1", + [provider], + |_| Ok(()), + ) + .optional() + .map(|row| row.is_some()) + .map_err(database_error) +} + +pub(super) fn provider_account_metadata(connection: &Connection, provider: &str) -> Result, Error> { + connection + .query_row( + "SELECT metadata_json FROM provider_accounts WHERE provider = ?1", + [provider], + |row| row.get(0), + ) + .optional() + .map_err(database_error) +} diff --git a/agentctl/src/persistence/sessions.rs b/agentctl/src/persistence/sessions.rs new file mode 100644 index 0000000..43b2a84 --- /dev/null +++ b/agentctl/src/persistence/sessions.rs @@ -0,0 +1,555 @@ +//! Session persistence with one column group per write owner. + +use rusqlite::{Connection, OptionalExtension as _, params}; +use serde::{Deserialize, Serialize}; + +use crate::{ + AgentId, Error, + sandbox::Assignment, + sessions::{ + Activity, ActivityEvent, AttachTarget, LaunchState, LaunchToken, Lifecycle, LifecycleState, NewSession, + Reported, Session, SessionId, SessionName, Status, + }, +}; + +use super::{agents, database_error}; + +const SESSION_COLUMNS: &str = "sessions.id, sessions.agent_id, agents.active_name, sessions.name, \ + sessions.harness, sessions.created_at, sessions.activation_generation, sessions.lifecycle_json, \ + sessions.harness_native_id, sessions.harness_transcript_path, sessions.activity_json, \ + sessions.model, sessions.effort, sessions.deletion_timestamp, sessions.archived_at"; + +/// Reconciler-owned column: the lifecycle half of the status plus the +/// activation revision it was observed at. +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LifecycleRow { + #[serde(default)] + state: LifecycleState, + #[serde(default, skip_serializing_if = "Option::is_none")] + failure: Option, + #[serde(default)] + observed_activation_generation: u64, + /// When `state` last changed. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + since: Option, +} + +pub(super) fn ensure( + connection: &mut Connection, + agent: &str, + name: &SessionName, + new: &NewSession, +) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let owner = agents::get_by_name(&transaction, agent)?; + if owner.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + let agent_id = owner.id; + if let Some(session) = query_named(&transaction, agent_id, name)? { + // The name is free again only once the reconciler has released the + // harness and removed the row, so recreating it now would revive a + // Session that is already going away. + if session.is_deleting() { + return Err(Error::Invalid(format!( + "Session \"{name}\" is being deleted; its name is free once its harness has stopped" + ))); + } + // Two callers may both find no Session and both resolve one; the first + // recorded selections bind, so a loser that explicitly chose differently + // learns about it, while one that chose nothing gets the Session as is. + if session.harness != new.harness { + return Err(Error::Invalid(format!( + "Session \"{name}\" already uses harness {:?}, not {:?}", + session.harness.as_str(), + new.harness.as_str() + ))); + } + if let Some(conflict) = session.model_selection.conflict_with(&new.requested) { + return Err(Error::Invalid(format!("Session \"{name}\" {conflict}"))); + } + transaction.commit().map_err(database_error)?; + return Ok(session); + } + let id = SessionId::generate(); + let created_at = time::OffsetDateTime::now_utc().unix_timestamp(); + transaction + .execute( + "INSERT INTO sessions (id, agent_id, name, harness, created_at, initial_prompt, model, effort) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id.to_string(), + agent_id.to_string(), + name.as_str(), + new.harness.as_str(), + created_at, + new.initial_prompt.as_deref(), + new.model_selection.model_str(), + new.model_selection.effort_str(), + ], + ) + .map_err(database_error)?; + let session = query_named(&transaction, agent_id, name)?.ok_or(Error::NotFound)?; + transaction.commit().map_err(database_error)?; + Ok(session) +} + +pub(super) fn get(connection: &Connection, id: SessionId) -> Result { + connection + .query_row( + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE sessions.id = ?1 AND agents.active_name IS NOT NULL" + ), + [id.to_string()], + decode_row, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound) +} + +pub(super) fn get_by_name(connection: &Connection, agent: &str, name: &SessionName) -> Result { + let owner = agents::get_by_name(connection, agent)?; + query_named(connection, owner.id, name)?.ok_or(Error::NotFound) +} + +pub(super) fn list_all(connection: &Connection) -> Result, Error> { + query_many( + connection, + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE agents.active_name IS NOT NULL ORDER BY agents.active_name, sessions.name" + ), + [], + ) +} + +pub(super) fn list_for_agent(connection: &Connection, agent: &str) -> Result, Error> { + query_many( + connection, + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE agents.active_name = ?1 ORDER BY sessions.name" + ), + [agent], + ) +} + +pub(super) fn activate(connection: &Connection, id: SessionId) -> Result { + let changed = connection + .execute( + "UPDATE sessions SET activation_generation = activation_generation + 1 WHERE id = ?1", + [id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::NotFound); + } + connection + .query_row( + "SELECT activation_generation FROM sessions WHERE id = ?1", + [id.to_string()], + |row| { + let generation = row.get::<_, i64>(0)?; + u64::try_from(generation).map_err(conversion_error) + }, + ) + .map_err(database_error) +} + +/// Records the first release request for one named Session; repeating it +/// returns the Session already marked. +pub(super) fn mark_deleting(connection: &mut Connection, agent: &str, name: &SessionName) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let owner = agents::get_by_name(&transaction, agent)?; + let mut session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + if !session.is_deleting() { + let changed = transaction + .execute( + "UPDATE sessions SET deletion_timestamp = ?1 WHERE id = ?2 AND deletion_timestamp IS NULL", + params![time::OffsetDateTime::now_utc().unix_timestamp(), session.id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + // Read the marker back so callers see the stored second, not a + // higher-precision value this Session would never report again. + session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + } + transaction.commit().map_err(database_error)?; + Ok(session) +} + +/// Records whether one named Session is archived. Archiving an archived +/// Session keeps its original time; unarchiving clears it. +pub(super) fn set_archived( + connection: &mut Connection, + agent: &str, + name: &SessionName, + archived: bool, +) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let owner = agents::get_by_name(&transaction, agent)?; + let session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + if session.is_deleting() { + return Err(Error::NotFound); + } + if session.is_archived() != archived { + let archived_at = archived.then(|| time::OffsetDateTime::now_utc().unix_timestamp()); + transaction + .execute( + "UPDATE sessions SET archived_at = ?1 WHERE id = ?2", + params![archived_at, session.id.to_string()], + ) + .map_err(database_error)?; + } + let session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + transaction.commit().map_err(database_error)?; + Ok(session) +} + +/// Removes a released Session. Rows keyed to it, such as its activity reports, +/// cascade with it. +pub(super) fn finalize_deletion(connection: &Connection, id: SessionId) -> Result<(), Error> { + let changed = connection + .execute( + "DELETE FROM sessions WHERE id = ?1 AND deletion_timestamp IS NOT NULL", + [id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::Conflict) } +} + +pub(super) fn update_lifecycle( + connection: &Connection, + id: SessionId, + lifecycle: Lifecycle, + observed_activation_generation: u64, +) -> Result<(), Error> { + let current = connection + .query_row( + "SELECT lifecycle_json FROM sessions WHERE id = ?1", + params![id.to_string()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + let current = serde_json::from_str::(¤t)?; + let since = if current.state == lifecycle.state { + current.since + } else { + Some(time::OffsetDateTime::now_utc()) + }; + let row = LifecycleRow { + state: lifecycle.state, + failure: lifecycle.failure, + observed_activation_generation, + since, + }; + let changed = connection + .execute( + "UPDATE sessions SET lifecycle_json = ?1 WHERE id = ?2", + params![serde_json::to_string(&row)?, id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::NotFound) } +} + +/// Clears every reported column: the previous harness incarnation's +/// conversation no longer exists, so neither do its ID, transcript or activity. +pub(super) fn clear_report(connection: &Connection, id: SessionId) -> Result<(), Error> { + let changed = connection + .execute( + "UPDATE sessions SET harness_native_id = NULL, harness_transcript_path = NULL, activity_json = '{}' \ + WHERE id = ?1", + params![id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::NotFound) } +} + +/// Folds one activity event into the Session, guarded by the current launch token. +/// +/// A stale token (an earlier launch, or a deleted Agent) matches no row and is a +/// silent no-op returning `None`, so the harness hook does not retry a report it +/// can never land. +pub(super) fn apply_activity_for_launch( + connection: &mut Connection, + id: SessionId, + token: &LaunchToken, + event_id: uuid::Uuid, + event: ActivityEvent, + at: time::OffsetDateTime, +) -> Result, Error> { + let (transaction, activity) = match begin_report(connection, id, token, event_id) { + Ok(Some(report)) => report, + Ok(None) | Err(Error::NotFound) => return Ok(None), + Err(error) => return Err(error), + }; + commit_report(transaction, id, activity.folded(event, at)).map(Some) +} + +/// Records start metadata and activity as one deduplicated report. +pub(super) fn record_start_for_launch( + connection: &mut Connection, + id: SessionId, + token: &LaunchToken, + event_id: uuid::Uuid, + native: &str, + transcript_path: Option<&str>, + at: time::OffsetDateTime, +) -> Result, Error> { + let Some((transaction, activity)) = begin_report(connection, id, token, event_id)? else { + return Ok(None); + }; + transaction + .execute( + "UPDATE sessions SET harness_native_id = ?1, harness_transcript_path = ?2 WHERE id = ?3", + params![native, transcript_path, id.to_string()], + ) + .map_err(database_error)?; + commit_report(transaction, id, activity.folded(ActivityEvent::SessionStart, at)).map(Some) +} + +/// Authenticates the launch and claims the event ID inside its write transaction. +/// Duplicate reports return `None`; stale launches return `Error::NotFound`. +fn begin_report<'a>( + connection: &'a mut Connection, + id: SessionId, + token: &LaunchToken, + event_id: uuid::Uuid, +) -> Result, Activity)>, Error> { + let transaction = connection.transaction().map_err(database_error)?; + let current = transaction + .query_row( + "SELECT activity_json FROM sessions \ + WHERE id = ?1 AND launch_token = ?2 \ + AND EXISTS ( \ + SELECT 1 FROM agents \ + WHERE agents.id = sessions.agent_id AND agents.active_name IS NOT NULL \ + )", + params![id.to_string(), token.expose()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + let inserted = transaction + .execute( + "INSERT INTO session_activity_reports (session_id, launch_token, event_id) VALUES (?1, ?2, ?3) \ + ON CONFLICT (session_id, launch_token, event_id) DO NOTHING", + params![id.to_string(), token.expose(), event_id.to_string()], + ) + .map_err(database_error)?; + if inserted == 0 { + return Ok(None); + } + Ok(Some((transaction, serde_json::from_str(¤t)?))) +} + +fn commit_report(transaction: rusqlite::Transaction<'_>, id: SessionId, activity: Activity) -> Result { + transaction + .execute( + "UPDATE sessions SET activity_json = ?1 WHERE id = ?2", + params![serde_json::to_string(&activity)?, id.to_string()], + ) + .map_err(database_error)?; + transaction.commit().map_err(database_error)?; + Ok(activity) +} + +pub(super) fn record_launch( + connection: &mut Connection, + id: SessionId, + token: &LaunchToken, + sandbox: &str, + launched_at: i64, + attempts: u32, +) -> Result, Error> { + let transaction = connection.transaction().map_err(database_error)?; + let prompt: Option = transaction + .query_row( + "SELECT initial_prompt FROM sessions WHERE id = ?1", + [id.to_string()], + |row| row.get(0), + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + transaction + .execute( + "UPDATE sessions SET launch_token = ?1, launch_sandbox = ?2, launched_at = ?3, launch_attempts = ?4, \ + activity_json = '{}', initial_prompt = NULL WHERE id = ?5", + params![token.expose(), sandbox, launched_at, attempts, id.to_string()], + ) + .map_err(database_error)?; + // Reports from previous launches can no longer authenticate, so their IDs can be discarded. + transaction + .execute( + "DELETE FROM session_activity_reports WHERE session_id = ?1", + [id.to_string()], + ) + .map_err(database_error)?; + transaction.commit().map_err(database_error)?; + Ok(prompt) +} + +pub(super) fn launch_state(connection: &Connection, id: SessionId) -> Result, Error> { + connection + .query_row( + "SELECT launch_token, launch_sandbox, launched_at, launch_attempts + FROM sessions WHERE id = ?1", + [id.to_string()], + |row| { + let token = row + .get::<_, Option>(0)? + .map(|token| token.parse().map_err(conversion_error)) + .transpose()?; + let sandbox = row.get::<_, Option>(1)?; + let launched_at = row.get::<_, Option>(2)?; + let attempts = row.get::<_, u32>(3)?; + let (Some(token), Some(sandbox), Some(launched_at)) = (token, sandbox, launched_at) else { + return Ok(None); + }; + Ok(Some(LaunchState { + token, + sandbox, + launched_at, + attempts, + })) + }, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound) +} + +pub(super) fn reset_launch_attempts(connection: &Connection, id: SessionId) -> Result<(), Error> { + let changed = connection + .execute( + "UPDATE sessions SET launch_attempts = 0 WHERE id = ?1", + [id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::NotFound) } +} + +pub(super) fn attach_target(connection: &Connection, id: SessionId) -> Result { + let session = get(connection, id)?; + if session.status.lifecycle.state != LifecycleState::Running { + return Err(session.not_running_error()); + } + let agent = agents::get(connection, session.agent_id)?; + let ready = agent.agent.status.is_ready(); + let Some(sandbox @ Assignment::Materialized { .. }) = agent.agent.status.sandbox else { + return Err(Error::Invalid(format!( + "Agent {:?} is not ready", + agent.agent.metadata.name + ))); + }; + if !ready || agent.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Invalid(format!( + "Agent {:?} is not ready", + agent.agent.metadata.name + ))); + } + Ok(AttachTarget { session, sandbox }) +} + +fn query_named(connection: &Connection, agent: AgentId, name: &SessionName) -> Result, Error> { + connection + .query_row( + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE sessions.agent_id = ?1 AND sessions.name = ?2 AND agents.active_name IS NOT NULL" + ), + params![agent.to_string(), name.as_str()], + decode_row, + ) + .optional() + .map_err(database_error) +} + +fn query_many

(connection: &Connection, sql: &str, params: P) -> Result, Error> +where + P: rusqlite::Params, +{ + let mut statement = connection.prepare(sql).map_err(database_error)?; + statement + .query_map(params, decode_row) + .map_err(database_error)? + .map(|row| row.map_err(database_error)) + .collect() +} + +fn decode_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id = row.get::<_, String>(0)?.parse().map_err(conversion_error)?; + let agent_id = row.get::<_, String>(1)?.parse().map_err(conversion_error)?; + let agent = row.get::<_, String>(2)?; + let name = SessionName::new(row.get::<_, String>(3)?).map_err(conversion_error)?; + let harness = row.get::<_, String>(4)?.parse().map_err(conversion_error)?; + let created_at = time::OffsetDateTime::from_unix_timestamp(row.get::<_, i64>(5)?).map_err(conversion_error)?; + let activation_generation = u64::try_from(row.get::<_, i64>(6)?).map_err(conversion_error)?; + let lifecycle = serde_json::from_str::(&row.get::<_, String>(7)?).map_err(conversion_error)?; + let harness_session_id = row.get::<_, Option>(8)?; + let harness_transcript_path = row.get::<_, Option>(9)?; + let activity = serde_json::from_str::(&row.get::<_, String>(10)?).map_err(conversion_error)?; + let model = row + .get::<_, Option>(11)? + .map(crate::Model::new) + .transpose() + .map_err(conversion_error)?; + let effort = row + .get::<_, Option>(12)? + .map(crate::Effort::new) + .transpose() + .map_err(conversion_error)?; + let deletion_timestamp = row + .get::<_, Option>(13)? + .map(time::OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(conversion_error)?; + let archived_at = row + .get::<_, Option>(14)? + .map(time::OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(conversion_error)?; + Ok(Session { + id, + agent_id, + agent, + name, + harness, + model_selection: crate::ModelSelection { model, effort }, + created_at, + deletion_timestamp, + archived_at, + status: Status::observed( + Lifecycle { + state: lifecycle.state, + failure: lifecycle.failure, + }, + Reported { + harness_session_id, + harness_transcript_path, + activity, + }, + lifecycle.since, + archived_at, + ), + activation_generation, + observed_activation_generation: lifecycle.observed_activation_generation, + }) +} + +fn conversion_error(error: impl std::error::Error + Send + Sync + 'static) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(error)) +} diff --git a/agentctl/src/platform_api/mod.rs b/agentctl/src/platform_api/mod.rs new file mode 100644 index 0000000..e5d7006 --- /dev/null +++ b/agentctl/src/platform_api/mod.rs @@ -0,0 +1,349 @@ +//! Sandbox-facing Platform API endpoint. +//! +//! Harness processes inside a Sandbox reach the host through the mediated +//! Network Backend's host alias, which rewrites to host loopback. This module +//! owns the loopback listener and its session-report route: per-launch reports +//! carrying the harness-native conversation ID and transcript location (on +//! start) and activity signals folded into Session status. The same listener +//! is the growth point for later platform tools (MCP), so nothing here assumes +//! the report route is the only one. +//! +//! Requests originate inside Sandboxes and are untrusted: parsing is bounded, +//! authentication is a per-launch bearer token, and failures return nothing +//! but a status code. + +use std::{io, path::Path, rc::Rc}; + +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::{TcpListener, TcpStream}, +}; + +use crate::{Error, sessions}; + +/// Upper bound for the request line and headers. +const MAX_HEAD_BYTES: usize = 8_192; + +/// Upper bound for a request body. +const MAX_BODY_BYTES: usize = 4_096; + +/// Time budget for one connection, request and response included. +const CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const MAX_CONCURRENT_CONNECTIONS: usize = 64; +const MAX_NATIVE_SESSION_ID_BYTES: usize = 1_024; +const MAX_TRANSCRIPT_PATH_BYTES: usize = 4_096; +type ConnectionFuture = futures_util::future::LocalBoxFuture<'static, ()>; + +/// Binds the Platform API listener on loopback, reusing the previously bound port. +/// +/// The port is persisted at `port_path` so Sandbox environments composed at +/// earlier launches keep pointing at a live endpoint across daemon restarts. +/// +/// # Errors +/// +/// Returns an error when the persisted port is invalid or unavailable, no +/// loopback port can be bound on first use, or that first port cannot be persisted. +pub async fn bind_persistent(port_path: &Path) -> Result { + let preferred = match tokio::fs::read_to_string(port_path).await { + Ok(content) => { + let port = content.trim().parse::().map_err(|error| { + Error::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid persisted Platform API port: {error}"), + )) + })?; + if port == 0 { + return Err(Error::Io(io::Error::new( + io::ErrorKind::InvalidData, + "persisted Platform API port must not be zero", + ))); + } + Some(port) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(Error::Io(error)), + }; + if let Some(port) = preferred { + return TcpListener::bind(("127.0.0.1", port)).await.map_err(|error| { + Error::Io(io::Error::new( + error.kind(), + format!("persisted Platform API port {port} is unavailable: {error}"), + )) + }); + } + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + tokio::fs::write(port_path, format!("{}\n", listener.local_addr()?.port())).await?; + Ok(listener) +} + +/// Serves Platform API requests from Sandboxes until the listener fails. +pub struct Server { + sessions: Rc, + on_error: Rc, +} + +impl Server { + /// Creates a Platform API server that records what harnesses report; it + /// holds no other Session capability. + #[must_use] + pub fn new(sessions: Rc, on_error: Rc) -> Self { + Self { sessions, on_error } + } + + /// Accepts and handles connections until the listener fails. + /// + /// # Errors + /// + /// Returns an error when accepting connections fails permanently. + pub async fn serve(self: Rc, listener: TcpListener) -> Result<(), Error> { + let mut connections = FuturesUnordered::::new(); + loop { + tokio::select! { + accepted = listener.accept(), if connections.len() < MAX_CONCURRENT_CONNECTIONS => { + let (stream, _) = accepted?; + let server = self.clone(); + connections.push(async move { + let outcome = tokio::time::timeout(CONNECTION_TIMEOUT, server.handle(stream)).await; + match outcome { + Ok(Ok(())) => {} + Ok(Err(error)) => (server.on_error)(&error), + Err(_) => { + (server.on_error)(&Error::Session("Platform API connection timed out".into())); + } + } + }.boxed_local()); + } + Some(()) = connections.next(), if !connections.is_empty() => {} + } + } + } + + async fn handle(&self, mut stream: TcpStream) -> Result<(), Error> { + let request = match read_request(&mut stream).await { + Ok(request) => request, + Err(status) => return respond(&mut stream, status).await, + }; + let status = self.dispatch(&request).await; + respond(&mut stream, status).await + } + + async fn dispatch(&self, request: &Request) -> u16 { + if request.method != "POST" { + return 405; + } + if request.target != "/v1/session/hooks" { + return 404; + } + let Some(token) = request.bearer_token() else { + return 401; + }; + let Ok(token) = token.parse::() else { + return 401; + }; + let Ok(report) = serde_json::from_slice::(&request.body) else { + return 400; + }; + self.accept_report(&token, &report).await + } + + /// Applies one authenticated session report: it records the native session + /// ID and transcript location on start and folds the reported activity event. + /// + /// The per-launch token rejects reports from earlier harness incarnations. + /// Sessions in one Agent share a Unix identity and are not mutually + /// isolated security principals. A stale token on an activity-only report is + /// a silent no-op; a stale token on a start report is rejected so the ID is + /// never attributed to the wrong launch. + async fn accept_report(&self, token: &sessions::LaunchToken, report: &SessionReport) -> u16 { + let at = time::OffsetDateTime::now_utc(); + let applied = if report.event == sessions::ActivityEvent::SessionStart { + if report.native_session_id.is_empty() || report.native_session_id.len() > MAX_NATIVE_SESSION_ID_BYTES { + return 400; + } + // A transcript is read inside the reporting Sandbox and must be an absolute path. + let transcript_path = report.transcript_path.as_deref().filter(|path| !path.is_empty()); + if transcript_path.is_some_and(|path| !path.starts_with('/') || path.len() > MAX_TRANSCRIPT_PATH_BYTES) { + return 400; + } + self.sessions + .record_session_start_for_launch( + report.session_id, + token, + report.event_id, + &report.native_session_id, + transcript_path, + at, + ) + .await + } else { + self.sessions + .apply_session_activity_for_launch(report.session_id, token, report.event_id, report.event, at) + .await + }; + match applied { + Ok(_) => 204, + Err(Error::NotFound) => 401, + Err(error) => { + (self.on_error)(&error); + 500 + } + } + } +} + +/// One per-launch session report from the harness hook. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SessionReport { + session_id: sessions::SessionId, + event_id: uuid::Uuid, + /// The reported activity signal; `sessionStart` also carries the native ID + /// and, when the harness exposes one, its transcript location. + event: sessions::ActivityEvent, + #[serde(default)] + native_session_id: String, + #[serde(default)] + transcript_path: Option, + #[serde(default)] + #[allow(dead_code, reason = "accepted for diagnostics; not used for authorization")] + source: String, +} + +struct Request { + method: String, + target: String, + authorization: Option, + body: Vec, +} + +impl Request { + fn bearer_token(&self) -> Option<&str> { + let value = self.authorization.as_deref()?; + let token = value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer "))?; + (!token.is_empty() && token.len() <= 128).then_some(token) + } +} + +/// Reads one bounded HTTP/1.x request; the error is the response status code. +async fn read_request(stream: &mut TcpStream) -> Result { + let mut buffer = Vec::with_capacity(1_024); + let head_end = loop { + if let Some(position) = find_head_end(&buffer) { + break position; + } + if buffer.len() >= MAX_HEAD_BYTES { + return Err(431); + } + let mut chunk = [0_u8; 1_024]; + let read = stream.read(&mut chunk).await.map_err(|_| 400_u16)?; + if read == 0 { + return Err(400); + } + buffer.extend_from_slice(&chunk[..read]); + }; + + let head = std::str::from_utf8(&buffer[..head_end]).map_err(|_| 400_u16)?; + let mut lines = head.split("\r\n"); + let request_line = lines.next().ok_or(400_u16)?; + let mut parts = request_line.split(' '); + let method = parts.next().ok_or(400_u16)?.to_owned(); + let target = parts.next().ok_or(400_u16)?.to_owned(); + + let mut authorization = None; + let mut content_length = 0_usize; + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + if name.eq_ignore_ascii_case("authorization") { + authorization = Some(value.to_owned()); + } else if name.eq_ignore_ascii_case("content-length") { + content_length = value.parse().map_err(|_| 400_u16)?; + } + } + if content_length > MAX_BODY_BYTES { + return Err(413); + } + + let mut body = buffer[head_end + 4..].to_vec(); + if body.len() > content_length { + return Err(400); + } + while body.len() < content_length { + let mut chunk = vec![0_u8; content_length - body.len()]; + let read = stream.read(&mut chunk).await.map_err(|_| 400_u16)?; + if read == 0 { + return Err(400); + } + body.extend_from_slice(&chunk[..read]); + } + Ok(Request { + method, + target, + authorization, + body, + }) +} + +fn find_head_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +async fn respond(stream: &mut TcpStream, status: u16) -> Result<(), Error> { + let reason = match status { + 204 => "No Content", + 400 => "Bad Request", + 401 => "Unauthorized", + 404 => "Not Found", + 405 => "Method Not Allowed", + 413 => "Content Too Large", + 431 => "Request Header Fields Too Large", + _ => "Internal Server Error", + }; + let response = format!("HTTP/1.1 {status} {reason}\r\nconnection: close\r\ncontent-length: 0\r\n\r\n"); + stream.write_all(response.as_bytes()).await?; + stream.shutdown().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn an_unavailable_persisted_port_is_not_replaced() { + let directory = tempfile::TempDir::new().expect("temporary directory"); + let path = directory.path().join("platform-api-port"); + let occupied = TcpListener::bind(("127.0.0.1", 0)).await.expect("occupied port"); + let port = occupied.local_addr().expect("address").port(); + tokio::fs::write(&path, format!("{port}\n")).await.expect("port file"); + + let error = bind_persistent(&path).await.expect_err("occupied persisted port"); + assert!(error.to_string().contains(&format!("port {port} is unavailable"))); + assert_eq!( + tokio::fs::read_to_string(&path).await.expect("port file"), + format!("{port}\n") + ); + + drop(occupied); + let rebound = bind_persistent(&path).await.expect("same port after release"); + assert_eq!(rebound.local_addr().expect("address").port(), port); + } + + #[tokio::test] + async fn the_first_bound_port_is_persisted() { + let directory = tempfile::TempDir::new().expect("temporary directory"); + let path = directory.path().join("platform-api-port"); + + let listener = bind_persistent(&path).await.expect("first bind"); + + assert_eq!( + tokio::fs::read_to_string(path).await.expect("port file"), + format!("{}\n", listener.local_addr().expect("address").port()) + ); + } +} diff --git a/agentctl/src/progress/mod.rs b/agentctl/src/progress/mod.rs new file mode 100644 index 0000000..f327754 --- /dev/null +++ b/agentctl/src/progress/mod.rs @@ -0,0 +1,47 @@ +//! Observable Agent provisioning progress. +//! +//! The Sandbox SDK reports progress as events and defines what they mean as a +//! folded [`::sandbox::progress::Progress`]. The reconciler folds every event of +//! a pass into the Agent's [`Provisioning`] in [`ProvisioningState`]; readers +//! see its current value, and a daemon-wide revision tells them when it +//! changed. A resync of a Ready Agent is published only if it fails. Durable +//! readiness and failure are the Agent's stored conditions. + +mod observer; +mod state; + +pub use observer::SandboxObserver; +pub use state::{Provisioning, ProvisioningState}; + +/// Platform setup inside the Sandbox: harnesses, home, instructions and Skills. +pub const SETUP: ::sandbox::Phase = ::sandbox::Phase::new("agentSetup", "Set up Agent"); +/// Declared SSH access to the Sandbox. +pub const SSH_ACCESS: ::sandbox::Phase = ::sandbox::Phase::new("sshAccess", "Configure SSH access"); +/// Stopping the Sandbox of an Agent whose run state is Stopped. +pub const SANDBOX_STOP: ::sandbox::Phase = ::sandbox::Phase::new("sandboxStop", "Stop Sandbox"); +/// Declared VNC access to the Agent's desktop. +pub const VNC_ACCESS: ::sandbox::Phase = ::sandbox::Phase::new("vncAccess", "Configure VNC access"); + +/// One Agent's stored status and the progress of its latest pass, as of one +/// revision, returned by `agents.v1.progress`. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AgentProgress { + /// Revision to follow from next. + pub revision: crate::resources::Revision, + /// Stored status: conditions and failure class. + pub status: crate::Status, + /// Progress of the latest pass, with the output the caller has not seen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provisioning: Option, +} + +/// Where a follower is in one pass's output. +#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OutputPosition { + /// Pass the position belongs to. + pub pass: crate::resources::Revision, + /// First output line the follower has not seen. + pub sequence: u64, +} diff --git a/agentctl/src/progress/observer.rs b/agentctl/src/progress/observer.rs new file mode 100644 index 0000000..5e6b168 --- /dev/null +++ b/agentctl/src/progress/observer.rs @@ -0,0 +1,110 @@ +//! Records one reconciliation pass's progress. + +use std::cell::Cell; + +use ::sandbox::ProgressReporter; + +use crate::{AgentId, ReconcileFailure}; + +use super::ProvisioningState; + +/// One reconciliation pass of an Agent, folded into the Agent's provisioning. +/// +/// The pass ends as succeeded or failed. A pass dropped without either, when +/// reconciliation returns an error first, ends as failed. +pub struct SandboxObserver { + state: ProvisioningState, + id: AgentId, + ended: Cell, +} + +impl SandboxObserver { + /// Starts recording a new pass for an Agent. + #[must_use] + pub fn new(id: AgentId, state: ProvisioningState) -> Self { + state.begin(id); + Self { + state, + id, + ended: Cell::new(false), + } + } + + /// Starts recording a resync of a Ready Agent, which replaces the Agent's + /// latest pass only if it fails. + #[must_use] + pub fn resync(id: AgentId, state: ProvisioningState) -> Self { + state.begin_resync(id); + Self { + state, + id, + ended: Cell::new(false), + } + } + + /// Returns the reporter for the pass's progress. + #[must_use] + pub fn reporter(&self) -> ProgressReporter { + let state = self.state.clone(); + let id = self.id; + ProgressReporter::from_callback(move |event| state.apply(id, &event)) + } + + /// Records that the pass succeeded. + pub fn succeeded(&self) { + self.ended.set(true); + self.state.succeed(self.id); + } + + /// Records that the pass failed. + pub fn failed(&self, failure: &ReconcileFailure) { + self.ended.set(true); + self.state.fail(self.id, &failure.message); + } +} + +impl Drop for SandboxObserver { + fn drop(&mut self) { + if !self.ended.get() { + self.state.fail( + self.id, + "reconciliation stopped before the pass finished; agentd retries it", + ); + } + } +} + +#[cfg(test)] +mod tests { + use ::sandbox::progress::OperationStatus; + + use super::*; + + #[test] + fn a_pass_dropped_without_an_outcome_ends_as_failed() { + let state = ProvisioningState::default(); + let id = AgentId::generate(); + drop(SandboxObserver::new(id, state.clone())); + assert!(matches!( + state.get(id).expect("pass").progress.status(), + OperationStatus::Failed { .. } + )); + + let observer = SandboxObserver::new(id, state.clone()); + observer.succeeded(); + drop(observer); + assert_eq!( + state.get(id).expect("pass").progress.status(), + &OperationStatus::Succeeded + ); + + drop(SandboxObserver::resync(id, state.clone())); + assert!( + matches!( + state.get(id).expect("pass").progress.status(), + OperationStatus::Failed { .. } + ), + "a resync that stops early is published as failed" + ); + } +} diff --git a/agentctl/src/progress/state.rs b/agentctl/src/progress/state.rs new file mode 100644 index 0000000..c76c24b --- /dev/null +++ b/agentctl/src/progress/state.rs @@ -0,0 +1,247 @@ +//! Provisioning progress of each Agent's latest provisioning pass. +//! +//! The reconciler folds every Sandbox and Agent progress event into the +//! Agent's [`Provisioning`]. It lives in memory only: after a daemon restart no +//! pass is running, and the durable outcome of the last pass is the Agent's +//! conditions and failure class. +//! +//! A resync, a pass that only reconfirms a Ready Agent at its current +//! generation, is folded out of sight and published only if it fails. The +//! periodic resync would otherwise replace the pass that provisioned the Agent +//! within seconds, and show a healthy Agent as provisioning while it runs. + +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use ::sandbox::progress::{OperationStatus, Progress}; + +use crate::{ + AgentId, + resources::{Changes, Revision}, +}; + +/// Output lines kept in the summary projected onto an Agent whose pass failed. +const FAILURE_OUTPUT_LINES: usize = 40; + +/// Progress of one Agent's latest pass that created, changed or retried its +/// Sandbox, or of a resync that failed. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Provisioning { + /// Identity of the pass: the revision at which it was published, unique + /// across daemon processes. A new identity means a new pass, so observers + /// start their cursor over. + pub pass: Revision, + /// The pass's progress. + pub progress: Progress, +} + +/// Latest provisioning of every Agent, shared by the reconciler and readers. +#[derive(Clone, Default)] +pub struct ProvisioningState { + agents: Rc>>, + resyncs: Rc>>, + changes: Changes, +} + +impl ProvisioningState { + /// Creates empty state whose every change advances `changes`. + #[must_use] + pub fn new(changes: Changes) -> Self { + Self { + changes, + ..Self::default() + } + } + + /// Returns the complete progress of the Agent's latest pass. + #[must_use] + pub fn get(&self, id: AgentId) -> Option { + self.agents.borrow().get(&id).cloned() + } + + /// Returns a summary of a pass that is running or failed, for listings. + /// + /// A succeeded pass is omitted: the Agent's conditions report it. + #[must_use] + pub fn summary(&self, id: AgentId) -> Option { + let agents = self.agents.borrow(); + let provisioning = agents.get(&id)?; + let output = match provisioning.progress.status() { + OperationStatus::Succeeded => return None, + OperationStatus::Failed { .. } => FAILURE_OUTPUT_LINES, + OperationStatus::Running => 0, + }; + Some(Provisioning { + progress: provisioning.progress.summary(output), + ..provisioning.clone() + }) + } + + /// Starts a new pass, replacing what the previous one left behind. + pub(crate) fn begin(&self, id: AgentId) { + self.resyncs.borrow_mut().remove(&id); + self.publish(id, Progress::new()); + } + + /// Starts a resync, which leaves the latest pass in place unless it fails. + pub(crate) fn begin_resync(&self, id: AgentId) { + self.resyncs.borrow_mut().insert(id, Progress::new()); + } + + pub(crate) fn apply(&self, id: AgentId, event: &::sandbox::ProgressEvent) { + if let Some(progress) = self.resyncs.borrow_mut().get_mut(&id) { + progress.apply(event); + return; + } + self.update(id, |provisioning| provisioning.progress.apply(event)); + } + + pub(crate) fn succeed(&self, id: AgentId) { + if self.resyncs.borrow_mut().remove(&id).is_some() { + return; + } + self.update(id, |provisioning| provisioning.progress.succeed()); + } + + pub(crate) fn fail(&self, id: AgentId, detail: &str) { + let resync = self.resyncs.borrow_mut().remove(&id); + if let Some(mut progress) = resync { + progress.fail(detail); + self.publish(id, progress); + return; + } + self.update(id, |provisioning| provisioning.progress.fail(detail)); + } + + /// Drops a deleted Agent's state. + pub(crate) fn forget(&self, id: AgentId) { + self.resyncs.borrow_mut().remove(&id); + if self.agents.borrow_mut().remove(&id).is_some() { + self.changes.bump(); + } + } + + fn publish(&self, id: AgentId, progress: Progress) { + self.changes.bump(); + let pass = self.changes.revision(); + self.agents.borrow_mut().insert(id, Provisioning { pass, progress }); + } + + fn update(&self, id: AgentId, change: impl FnOnce(&mut Provisioning)) { + let updated = self.agents.borrow_mut().get_mut(&id).map(change).is_some(); + if updated { + self.changes.bump(); + } + } +} + +#[cfg(test)] +mod tests { + use ::sandbox::{ProgressEvent, SandboxPhase}; + + use super::*; + + fn started() -> ProgressEvent { + ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + } + } + + #[test] + fn each_pass_gets_a_new_number_and_every_change_advances_the_revision() { + let changes = Changes::new(); + let state = ProvisioningState::new(changes.clone()); + let id = AgentId::generate(); + let before = changes.revision(); + state.apply(id, &started()); + assert_eq!(changes.revision(), before, "no pass has begun"); + + state.begin(id); + state.apply(id, &started()); + let first = state.get(id).expect("first pass"); + assert!(first.progress.current().is_some()); + assert_ne!(changes.revision(), before); + + state.begin(id); + let second = state.get(id).expect("second pass"); + assert_ne!(second.pass, first.pass); + assert!(second.progress.current().is_none(), "a new pass starts empty"); + + let other = ProvisioningState::new(Changes::new()); + other.begin(id); + assert_ne!( + other.get(id).expect("pass of another daemon").pass, + first.pass, + "passes of another daemon process never compare as the same pass" + ); + } + + #[test] + fn a_resync_stays_out_of_sight_unless_it_fails() { + let changes = Changes::new(); + let state = ProvisioningState::new(changes.clone()); + let id = AgentId::generate(); + state.begin(id); + state.apply(id, &started()); + state.succeed(id); + let provisioned = state.get(id).expect("provisioning pass"); + + let before = changes.revision(); + state.begin_resync(id); + state.apply(id, &started()); + state.succeed(id); + assert_eq!( + changes.revision(), + before, + "a resync that succeeds changes nothing observable" + ); + assert_eq!(state.get(id), Some(provisioned.clone()), "the provisioning pass stays"); + assert!(state.summary(id).is_none()); + + state.begin_resync(id); + state.apply(id, &started()); + state.fail(id, "Sandbox stopped"); + let failed = state.get(id).expect("failed resync"); + assert_ne!( + failed.pass, provisioned.pass, + "a failed resync is published as a new pass" + ); + assert!( + failed + .progress + .finished() + .iter() + .any(|phase| phase.phase.id == SandboxPhase::ImageResolve.phase().id) + ); + assert!( + matches!(state.summary(id), Some(summary) if matches!(summary.progress.status(), OperationStatus::Failed { .. })) + ); + assert_ne!(changes.revision(), before); + } + + #[test] + fn listings_show_running_and_failed_passes_but_not_succeeded_ones() { + let state = ProvisioningState::default(); + let id = AgentId::generate(); + state.begin(id); + state.apply(id, &started()); + assert!(state.summary(id).is_some()); + + state.fail(id, "pull failed"); + let failed = state.summary(id).expect("failed pass"); + assert_eq!( + failed.progress.status(), + &OperationStatus::Failed { + detail: "pull failed".into() + } + ); + + state.begin(id); + state.succeed(id); + assert!(state.summary(id).is_none()); + assert!(state.get(id).is_some(), "followers still read the finished pass"); + + state.forget(id); + assert!(state.get(id).is_none()); + } +} diff --git a/agentctl/src/resources.rs b/agentctl/src/resources.rs new file mode 100644 index 0000000..a4a6185 --- /dev/null +++ b/agentctl/src/resources.rs @@ -0,0 +1,212 @@ +//! Level-triggered change notification for the Agent and Session resources. +//! +//! Every durable Agent or Session write and every provisioning update advances +//! one daemon-wide revision. Watchers never receive the changes themselves: +//! they wait for the revision to move past the one they last saw and then read +//! the current state, so a slow watcher can skip intermediate states but never miss the +//! latest one. + +use std::{fmt, str::FromStr, time::Duration}; + +use tokio::sync::watch; + +/// Daemon-wide change history of the Agent and Session resources. +#[derive(Clone)] +pub struct Changes { + epoch: uuid::Uuid, + sequence: watch::Sender, +} + +/// Position in one daemon's change history. +/// +/// The epoch is chosen when the daemon starts, so a revision from an earlier +/// daemon process never compares as current. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Revision { + epoch: uuid::Uuid, + sequence: u64, +} + +impl Changes { + /// Starts a new change history. + #[must_use] + pub fn new() -> Self { + Self { + epoch: uuid::Uuid::new_v4(), + sequence: watch::Sender::new(0), + } + } + + /// Records that observable state changed. + pub fn bump(&self) { + self.sequence + .send_modify(|sequence| *sequence = sequence.wrapping_add(1)); + } + + /// Returns the current position. + #[must_use] + pub fn revision(&self) -> Revision { + Revision { + epoch: self.epoch, + sequence: *self.sequence.borrow(), + } + } + + /// Waits until state has changed since `after`, then for `settle` more so + /// that a burst of changes produces one wake-up. + /// + /// Returns immediately when `after` is absent, belongs to another daemon + /// process, or is already behind. Returns `false` when `timeout` passes + /// without a change. + pub async fn changed_since(&self, after: Option, settle: Duration, timeout: Duration) -> bool { + let Some(after) = after.filter(|after| after.epoch == self.epoch) else { + return true; + }; + let mut receiver = self.sequence.subscribe(); + if *receiver.borrow_and_update() != after.sequence { + return true; + } + // `wait_for` returns a read guard on the sequence. Drop it before + // settling: a change made while it is held would block its writer, and + // with it the single-threaded runtime this guard is waiting on. + let changed = tokio::time::timeout(timeout, receiver.wait_for(|sequence| *sequence != after.sequence)) + .await + .is_ok_and(|changed| changed.is_ok()); + if changed { + tokio::time::sleep(settle).await; + } + changed + } +} + +impl Default for Changes { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for Revision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}:{}", self.epoch, self.sequence) + } +} + +impl FromStr for Revision { + type Err = crate::Error; + + fn from_str(value: &str) -> Result { + let invalid = || crate::Error::Invalid(format!("invalid resource revision {value:?}")); + let (epoch, sequence) = value.split_once(':').ok_or_else(invalid)?; + Ok(Self { + epoch: epoch.parse().map_err(|_| invalid())?, + sequence: sequence.parse().map_err(|_| invalid())?, + }) + } +} + +impl serde::Serialize for Revision { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> serde::Deserialize<'de> for Revision { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +/// Every Agent and Session as of one revision. +/// +/// The revision is taken before the state is read, so a change made while it +/// is read is also reported by the next watch from this revision. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Resources { + /// Revision to watch from next. + pub revision: Revision, + /// Active Agents ordered by name, with provisioning progress projected. + pub agents: Vec, + /// Durable Sessions of those Agents. + pub sessions: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + const SETTLE: Duration = Duration::from_millis(150); + const TIMEOUT: Duration = Duration::from_secs(30); + + #[test] + fn revision_round_trips_through_its_wire_form() { + let changes = Changes::new(); + changes.bump(); + let revision = changes.revision(); + assert_eq!(revision.to_string().parse::().expect("revision"), revision); + let json = serde_json::to_value(revision).expect("revision JSON"); + assert_eq!( + serde_json::from_value::(json).expect("revision from JSON"), + revision + ); + assert!("not-a-revision".parse::().is_err()); + } + + #[tokio::test(start_paused = true)] + async fn missing_foreign_or_stale_revisions_return_immediately() { + let changes = Changes::new(); + assert!(changes.changed_since(None, SETTLE, TIMEOUT).await); + assert!( + changes + .changed_since(Some(Changes::new().revision()), SETTLE, TIMEOUT) + .await + ); + let stale = changes.revision(); + changes.bump(); + let started = tokio::time::Instant::now(); + assert!(changes.changed_since(Some(stale), SETTLE, TIMEOUT).await); + assert_eq!(started.elapsed(), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] + async fn current_revision_waits_for_a_change_or_times_out() { + let changes = Changes::new(); + let current = changes.revision(); + assert!(!changes.changed_since(Some(current), SETTLE, TIMEOUT).await); + + let bumper = changes.clone(); + let waiting = changes.changed_since(Some(current), SETTLE, TIMEOUT); + let bump = async { + tokio::time::sleep(Duration::from_secs(1)).await; + bumper.bump(); + bumper.bump(); + }; + let started = tokio::time::Instant::now(); + let (woke, ()) = tokio::join!(waiting, bump); + assert!(woke); + assert_eq!( + started.elapsed(), + Duration::from_secs(1) + SETTLE, + "the watch settles after the first change so a burst wakes it once" + ); + assert_eq!(changes.revision().sequence, current.sequence + 2); + } + + #[tokio::test(flavor = "local", start_paused = true)] + async fn changes_during_the_settle_window_do_not_block_the_writer() { + let changes = Changes::new(); + let current = changes.revision(); + let bumper = changes.clone(); + let waiting = changes.changed_since(Some(current), SETTLE, TIMEOUT); + let bump = async { + tokio::time::sleep(Duration::from_secs(1)).await; + bumper.bump(); + tokio::time::sleep(SETTLE / 2).await; + bumper.bump(); + }; + let (woke, ()) = tokio::join!(waiting, bump); + assert!(woke); + assert_eq!(changes.revision().sequence, current.sequence + 2); + } +} diff --git a/agentctl/src/sandbox/execution.rs b/agentctl/src/sandbox/execution.rs new file mode 100644 index 0000000..9c28f3b --- /dev/null +++ b/agentctl/src/sandbox/execution.rs @@ -0,0 +1,96 @@ +//! Transient command execution against an Agent-owned Sandbox. + +use std::{path::Path, rc::Rc}; + +use ::sandbox::execution; +use serde::{Deserialize, Serialize}; + +use crate::{Error, control_plane, control_plane::WaitPolicy}; + +use super::Assignment; + +/// Exact materialized Sandbox selected after Agent convergence. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ExecutionTarget { + /// Sticky Provider and immutable Sandbox identity. + pub sandbox: Assignment, + /// Sandbox operating system used to construct the Execution. + pub operating_system: String, +} + +/// Resolves transient executions without taking ownership of Sandbox lifecycle effects. +pub struct ExecutionService { + agents: Rc, + convergence: control_plane::Convergence, +} + +impl ExecutionService { + /// Creates an execution-target resolver over the Agent controller. + #[must_use] + pub const fn new(agents: Rc, convergence: control_plane::Convergence) -> Self { + Self { agents, convergence } + } + + /// Wakes Agent convergence and returns its exact ready Sandbox assignment. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing, deleting, or invalid; with + /// [`WaitPolicy::FirstPass`] also when the single pass fails or leaves the + /// Agent without a ready materialized Sandbox. + pub async fn ensure(&self, name: &str, wait: WaitPolicy) -> Result { + // A stopped Agent runs nothing, so it is refused before anything is woken. + let record = self.agents.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + record.reject_stopped()?; + let record = self.convergence.converge(name, wait).await?; + record.reject_stopped()?; + target(record, name) + } +} + +fn target(record: control_plane::AgentRecord, name: &str) -> Result { + let ready = record.agent.status.ready_condition(); + if !record.agent.status.is_ready() { + let detail = ready.map_or_else( + || "no Ready condition was reported".to_owned(), + crate::Condition::summary, + ); + return Err(Error::Invalid(format!("Agent {name:?} is not Ready: {detail}"))); + } + let sandbox = record + .agent + .status + .sandbox + .filter(|assignment| assignment.id().is_some()) + .ok_or_else(|| Error::Invalid(format!("Agent {name:?} has no materialized Sandbox")))?; + Ok(ExecutionTarget { + sandbox, + operating_system: record.agent.spec.sandbox.platform.os, + }) +} + +/// Starts a non-interactive Execution through the recorded Sandbox Provider. +/// +/// The returned stream belongs to the exact Sandbox lifecycle ID in `target`; +/// this function does not create, start, or otherwise reconcile a Sandbox. +/// +/// # Errors +/// +/// Returns an error when the Provider is unsupported by this client or the +/// exact Sandbox cannot start the Execution. +pub async fn start_execution( + home: &Path, + target: &ExecutionTarget, + spec: execution::ExecutionSpec, +) -> Result { + match target.sandbox.provider().as_str() { + super::microsandbox::PROVIDER_ID => super::microsandbox::start_execution(home, &target.sandbox, spec).await, + provider => Err(Error::Invalid(format!( + "command execution is not supported through Sandbox Provider {provider:?}" + ))), + } +} diff --git a/agentctl/src/sandbox/forward.rs b/agentctl/src/sandbox/forward.rs new file mode 100644 index 0000000..6237b01 --- /dev/null +++ b/agentctl/src/sandbox/forward.rs @@ -0,0 +1,267 @@ +//! Client-owned connections into Agent Sandboxes. +//! +//! Port forwards follow the k9s model: they live in the client process, accept +//! connections on a local listener, and dial the guest through the Sandbox +//! agent relay. They end when the process exits or the forward is stopped. +//! [`relay_guest_port`] is the single-connection form behind +//! `agentctl ssh-proxy`: one fresh dial, relayed over an arbitrary byte +//! stream pair such as the process's standard input and output. + +use std::{cell::RefCell, net::IpAddr, path::PathBuf, rc::Rc}; + +use tokio::net::TcpListener; + +use crate::Error; + +use super::{Assignment, GuestDialer}; + +/// One requested local-to-guest port mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ForwardSpec { + /// Local interface address accepting connections. + pub address: IpAddr, + /// Local port to bind; zero selects an ephemeral port. + pub local_port: u16, + /// Guest port that receives forwarded connections. + pub guest_port: u16, +} + +impl ForwardSpec { + /// Parses `GUEST`, `LOCAL:GUEST`, or `ADDRESS:LOCAL:GUEST`. + /// + /// An empty local port (`:GUEST`) selects an ephemeral local port. + /// + /// # Errors + /// + /// Returns a message describing the malformed mapping. + pub fn parse(text: &str) -> Result { + let parts = text.split(':').collect::>(); + let (address, local, guest) = match parts.as_slice() { + [guest] => (None, *guest, *guest), + [local, guest] => (None, *local, *guest), + [address, local, guest] => (Some(*address), *local, *guest), + _ => return Err(format!("{text:?} is not GUEST, LOCAL:GUEST, or ADDRESS:LOCAL:GUEST")), + }; + let address = match address { + None => IpAddr::from([127, 0, 0, 1]), + Some(text) => text + .parse::() + .map_err(|_| format!("{text:?} is not a local IP address"))?, + }; + let local_port = if local.is_empty() { 0 } else { parse_port(local)? }; + let guest_port = parse_port(guest)?; + if guest_port == 0 { + return Err("guest port must not be zero".into()); + } + Ok(Self { + address, + local_port, + guest_port, + }) + } +} + +fn parse_port(text: &str) -> Result { + text.parse::().map_err(|_| format!("{text:?} is not a port")) +} + +/// One running forward with its local listener task. +pub struct PortForward { + spec: ForwardSpec, + assignment: Assignment, + local: std::net::SocketAddr, + task: tokio::task::JoinHandle<()>, + status: Rc>>, +} + +impl PortForward { + /// Binds the local listener and serves connections until stopped. + /// + /// # Errors + /// + /// Returns an error when the local address cannot be bound. + pub async fn start(home: PathBuf, assignment: Assignment, spec: ForwardSpec) -> Result { + let listener = TcpListener::bind((spec.address, spec.local_port)) + .await + .map_err(Error::from)?; + let local = listener.local_addr().map_err(Error::from)?; + let status = Rc::new(RefCell::new(None)); + let task = tokio::task::spawn_local(accept_loop( + home, + assignment.clone(), + spec.guest_port, + listener, + Rc::clone(&status), + )); + Ok(Self { + spec, + assignment, + local, + task, + status, + }) + } + + /// Returns the requested mapping. + #[must_use] + pub const fn spec(&self) -> &ForwardSpec { + &self.spec + } + + /// Returns the Sandbox assignment the forward dials. + #[must_use] + pub const fn assignment(&self) -> &Assignment { + &self.assignment + } + + /// Returns the bound local address, with any ephemeral port resolved. + #[must_use] + pub const fn local_address(&self) -> std::net::SocketAddr { + self.local + } + + /// Returns the most recent connection failure, when one occurred. + #[must_use] + pub fn status(&self) -> Option { + self.status.borrow().clone() + } + + /// Reports whether the listener task has ended and stopped serving. + #[must_use] + pub fn finished(&self) -> bool { + self.task.is_finished() + } + + /// Stops the listener and drops in-flight relays. + pub fn stop(&self) { + self.task.abort(); + } +} + +impl Drop for PortForward { + fn drop(&mut self) { + self.stop(); + } +} + +async fn accept_loop( + home: PathBuf, + assignment: Assignment, + guest_port: u16, + listener: TcpListener, + status: Rc>>, +) { + // The dialer multiplexes streams over one agent connection; it is replaced + // when a connect fails, which re-reaches a Sandbox whose runtime restarted. + let mut dialer: Option> = None; + // Relays live in the accept task's JoinSet, so aborting the accept task + // drops the set and aborts every in-flight connection with it. + let mut relays = tokio::task::JoinSet::new(); + loop { + while relays.try_join_next().is_some() {} + let stream = match listener.accept().await { + Ok((stream, _)) => stream, + Err(error) => { + *status.borrow_mut() = Some(format!("accept failed: {error}")); + return; + } + }; + if dialer.is_none() { + match super::guest_tcp_dialer(&home, &assignment).await { + Ok(connected) => dialer = Some(Rc::new(connected)), + Err(error) => { + *status.borrow_mut() = Some(error.to_string()); + continue; + } + } + } + let Some(connected) = &dialer else { continue }; + match connected.connect("127.0.0.1", guest_port).await { + Ok(guest) => { + *status.borrow_mut() = None; + let status = Rc::clone(&status); + relays.spawn_local(async move { + if let Err(error) = guest.relay(stream).await { + *status.borrow_mut() = Some(error.to_string()); + } + }); + } + Err(error) => { + *status.borrow_mut() = Some(error.to_string()); + dialer = None; + } + } + } +} + +/// Dials one guest loopback port and relays it over `reader` and `writer` +/// until both directions close. +/// +/// Every call dials fresh through the recorded Sandbox Provider, so a +/// connection made after a Sandbox runtime restart needs no recovery logic. +/// +/// # Errors +/// +/// Returns an error when the Sandbox cannot be reached, the guest refuses the +/// connection, or the relay fails. +pub async fn relay_guest_port( + home: &std::path::Path, + assignment: &Assignment, + guest_port: u16, + reader: R, + writer: W, +) -> Result<(), Error> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let dialer = super::guest_tcp_dialer(home, assignment).await?; + let guest = dialer.connect("127.0.0.1", guest_port).await?; + guest.relay_io(reader, writer).await +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + #[test] + fn specs_follow_kubectl_shapes() { + assert_eq!( + ForwardSpec::parse("80").expect("guest-only spec"), + ForwardSpec { + address: IpAddr::from([127, 0, 0, 1]), + local_port: 80, + guest_port: 80, + } + ); + assert_eq!( + ForwardSpec::parse("9090:80").expect("local and guest spec"), + ForwardSpec { + address: IpAddr::from([127, 0, 0, 1]), + local_port: 9090, + guest_port: 80, + } + ); + assert_eq!( + ForwardSpec::parse("0.0.0.0:80:80").expect("address spec"), + ForwardSpec { + address: IpAddr::from([0, 0, 0, 0]), + local_port: 80, + guest_port: 80, + } + ); + assert_eq!( + ForwardSpec::parse(":80").expect("ephemeral local port"), + ForwardSpec { + address: IpAddr::from([127, 0, 0, 1]), + local_port: 0, + guest_port: 80, + } + ); + assert!(ForwardSpec::parse("web:80").is_err()); + assert!(ForwardSpec::parse("1:2:3:4").is_err()); + assert!(ForwardSpec::parse("8080:0").is_err()); + } +} diff --git a/agentctl/src/sandbox/microsandbox/execution.rs b/agentctl/src/sandbox/microsandbox/execution.rs new file mode 100644 index 0000000..966415f --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/execution.rs @@ -0,0 +1,29 @@ +//! Direct transient Execution transport for the Microsandbox Backend. + +use sandbox::{execution, provider::SandboxProvider as _}; +use sandbox_microsandbox::MicrosandboxProvider; + +use crate::{Error, sandbox::Assignment}; + +/// Starts an Execution in an already-materialized Microsandbox. +/// +/// # Errors +/// +/// Returns an error when the assignment is not materialized or the exact +/// Microsandbox cannot be inspected or start the Execution. +pub(crate) async fn start_execution( + home: &std::path::Path, + assignment: &Assignment, + spec: execution::ExecutionSpec, +) -> Result { + let provider = MicrosandboxProvider::open(home.join("microsandbox")).await?; + let Assignment::Materialized { id, .. } = assignment else { + return Err(Error::Invalid("Execution target Sandbox is not materialized".into())); + }; + provider.backend().inspect(id).await?; + provider + .backend() + .start_execution(id, execution::StartExecutionRequest::new(spec)) + .await + .map_err(Error::from) +} diff --git a/agentctl/src/sandbox/microsandbox/forward.rs b/agentctl/src/sandbox/microsandbox/forward.rs new file mode 100644 index 0000000..433f057 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/forward.rs @@ -0,0 +1,72 @@ +//! Guest TCP dialing transport for the Microsandbox Backend. + +use sandbox_microsandbox::{GuestTcpDialer, GuestTcpStream, MicrosandboxProvider}; + +use crate::{Error, sandbox::Assignment}; + +/// Dials TCP connections from inside one Agent Sandbox. +/// +/// The dialer multiplexes streams over a single Sandbox connection, so opening +/// many concurrent connections through one dialer is cheap. It stops working +/// when the Sandbox runtime restarts; create a replacement when a connect fails. +pub struct GuestDialer(GuestTcpDialer); + +/// One open TCP stream dialed from inside an Agent Sandbox. +pub struct GuestConnection(GuestTcpStream); + +impl GuestDialer { + /// Opens one TCP connection dialed from inside the guest. + /// + /// # Errors + /// + /// Returns an error when the stream cannot be opened or the guest dial is + /// rejected. + pub async fn connect(&self, host: &str, port: u16) -> Result { + Ok(GuestConnection(self.0.connect(host, port).await?)) + } +} + +impl GuestConnection { + /// Pipes bytes between a host socket and the guest connection until either + /// side closes. + /// + /// # Errors + /// + /// Returns an error when either side of the relay fails. + pub async fn relay(self, stream: tokio::net::TcpStream) -> Result<(), Error> { + self.0.relay(stream).await.map_err(Error::from) + } + + /// Pipes bytes between a host reader/writer pair, such as this process's + /// standard input and output, and the guest connection until both sides + /// close. + /// + /// # Errors + /// + /// Returns an error when either side of the relay fails. + pub async fn relay_io(self, reader: R, writer: W) -> Result<(), Error> + where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, + { + self.0.relay_io(reader, writer).await.map_err(Error::from) + } +} + +/// Connects a TCP dialer to an already-materialized Microsandbox. +/// +/// # Errors +/// +/// Returns an error when the assignment is not materialized or the exact +/// Microsandbox is not running. +pub(crate) async fn guest_tcp_dialer(home: &std::path::Path, assignment: &Assignment) -> Result { + let provider = MicrosandboxProvider::open(home.join("microsandbox")).await?; + let Assignment::Materialized { id, .. } = assignment else { + return Err(Error::Invalid("forward target Sandbox is not materialized".into())); + }; + provider + .guest_tcp_dialer(id) + .await + .map(GuestDialer) + .map_err(Error::from) +} diff --git a/agentctl/src/sandbox/microsandbox/mod.rs b/agentctl/src/sandbox/microsandbox/mod.rs new file mode 100644 index 0000000..8c8c6a9 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/mod.rs @@ -0,0 +1,218 @@ +//! Microsandbox integration for the Agent layer. + +use std::{path::Path, rc::Rc}; + +use ::sandbox::{EnsureSandboxRequest, ErrorKind, LocalFuture, Platform, SandboxHandle, SandboxService, SandboxState}; +use sandbox_microsandbox::{MicrosandboxNetworkBackend, MicrosandboxProvider}; + +use crate::{Error, authorization::AgentPolicyEngine, control_plane::AgentRecord, persistence}; + +mod execution; +mod forward; +mod preparation; +mod terminal; + +pub(super) use execution::start_execution; +pub(super) use forward::guest_tcp_dialer; +pub use forward::{GuestConnection, GuestDialer}; +pub use terminal::attach_terminal; + +use preparation::Preparation; + +use super::{Provider, ProviderEnsureOutcome, ProviderId}; + +/// `RUST_LOG` directives that keep this Provider's runtime helper processes quiet at the default level. +pub const LOG_DIRECTIVES: &str = sandbox_microsandbox::LOG_DIRECTIVES; + +pub(super) const PROVIDER_ID: &str = "microsandbox"; + +/// How long `agentd` keeps an image no Agent uses after its last use, so an Agent deleted and +/// applied again, even after a weekend, does not download its image again. +const UNUSED_IMAGE_RETENTION: std::time::Duration = std::time::Duration::from_hours(72); + +/// How often an idle `agentd` removes unused images. +const UNUSED_IMAGE_SWEEP: std::time::Duration = std::time::Duration::from_hours(1); + +/// Removes unused images while `agentd` runs, so they go even when no Agent changes. +async fn remove_unused_images_periodically(provider: std::rc::Weak) { + let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + UNUSED_IMAGE_SWEEP, UNUSED_IMAGE_SWEEP); + loop { + ticker.tick().await; + let Some(provider) = provider.upgrade() else { + return; + }; + provider.remove_unused_images().await; + } +} + +/// Sandbox-resolvable name of the Microsandbox Network Backend's host alias. +/// +/// The Backend's DNS answers this name with the per-Sandbox gateway address +/// and rewrites gateway-bound connections to host loopback at dial time, so +/// this is how processes inside a Sandbox reach the Platform API endpoint. +pub const HOST_ALIAS: &str = "host.microsandbox.internal"; + +/// Runtime-selectable Agent adapter for a Microsandbox Provider. +pub struct Adapter { + id: ProviderId, + service: SandboxService, + preparation: Preparation, + default_architecture: String, + platform_port: u16, +} + +impl Adapter { + /// Opens one configured Microsandbox Provider and its mediated Network Backend. + /// + /// # Errors + /// + /// Returns an error when Provider state cannot be opened. + pub async fn open( + home: &Path, + database: persistence::Database, + secret_store: Rc, + policy: Rc, + platform_port: u16, + ) -> Result { + let network = Rc::new(MicrosandboxNetworkBackend::new(policy.clone()).with_secret_store(secret_store)); + let service = { + let provider = Rc::new( + MicrosandboxProvider::builder(home.join("microsandbox")) + .remove_unused_images_after(UNUSED_IMAGE_RETENTION) + .open() + .await?, + ); + tokio::task::spawn_local(remove_unused_images_periodically(Rc::downgrade(&provider))); + SandboxService::new(provider) + } + .with_network_backend(network.clone()); + policy.set_platform_endpoint(HOST_ALIAS, platform_port); + Ok(Self { + id: ProviderId::new(PROVIDER_ID)?, + service, + preparation: Preparation::new(database, policy, network), + default_architecture: Platform::native("linux").architecture, + platform_port, + }) + } + + /// Resolves a platform route to the URL reachable from this Provider's Sandboxes. + /// + /// # Errors + /// + /// Returns an error unless `path` is an absolute HTTP path. + pub fn platform_url(&self, path: &str) -> Result { + if !path.starts_with('/') || path.starts_with("//") { + return Err(Error::Invalid( + "platform endpoint path must start with exactly one '/'".into(), + )); + } + Ok(format!("http://{HOST_ALIAS}:{}{path}", self.platform_port)) + } + + fn sandbox_spec(&self, record: &AgentRecord) -> ::sandbox::SandboxSpec { + record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &self.default_architecture) + } + + fn sandbox_mounts(record: &AgentRecord) -> Vec<::sandbox::mount::Mount> { + record.agent.spec.sandbox.resolved_mounts() + } +} + +impl Provider for Adapter { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async move { + match self.service.capabilities(&self.sandbox_spec(record).platform).await { + Ok(capabilities) => Ok(Self::sandbox_mounts(record) + .iter() + .all(|mount| capabilities.mount_kinds().contains(mount.kind()))), + Err(error) if error.kind() == ErrorKind::Unsupported => Ok(false), + Err(error) => Err(error.into()), + } + }) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + mut environment: std::collections::BTreeMap, + progress: ::sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let running_before = record + .agent + .status + .sandbox + .as_ref() + .and_then(super::Assignment::id) + .is_some_and(|id| self.preparation.network_is_running(id)); + let prepared = self.preparation.prepare(record).await?; + let harnesses = prepared.harnesses; + for (name, value) in prepared.environment { + if environment.insert(name.clone(), value).is_some() { + return Err(Error::Invalid(format!( + "Sandbox environment variable {name:?} collides with a mediated secret" + ))); + } + } + let sandbox_name = record.sandbox_name()?; + // Ensure starts a stopped Sandbox, and restarts a running one to replace its environment. + let runtime_restarted = match self.service.inspect(&sandbox_name).await { + Ok(sandbox) => sandbox.state == SandboxState::Stopped || sandbox.environment != environment, + Err(error) if error.is_not_found() => false, + Err(error) => return Err(error.into()), + }; + let request = EnsureSandboxRequest::new(sandbox_name, self.sandbox_spec(record)) + .with_hostname(record.sandbox_hostname()?) + .with_mounts(Self::sandbox_mounts(record)) + .with_environment(environment); + let mut sandbox = self.service.ensure(&request).forward(&progress).await?; + if prepared.bindings_changed && running_before { + self.preparation.restart_network(&sandbox).await?; + // Re-ensure starts the stopped Network with the replacement handshake bindings. + sandbox = self.service.ensure(&request).forward(&progress).await?; + } + Ok(ProviderEnsureOutcome { + sandbox, + runtime_restarted, + harnesses, + }) + }) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.service.stop(&record.sandbox_name()?).await.map_err(Error::from) }) + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a ::sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.service + .open(id, record.agent.spec.sandbox.resolved_retention_policy()) + .await + .map_err(Error::from) + }) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let name = record.sandbox_name()?; + self.service + .release(&name, record.agent.spec.sandbox.resolved_retention_policy()) + .await?; + self.preparation.remove(&name); + Ok(()) + }) + } +} diff --git a/agentctl/src/sandbox/microsandbox/preparation.rs b/agentctl/src/sandbox/microsandbox/preparation.rs new file mode 100644 index 0000000..11b15d1 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/preparation.rs @@ -0,0 +1,204 @@ +//! Host mediation setup for the Microsandbox Network Backend. + +use std::{collections::BTreeMap, rc::Rc}; + +use crate::{Error, authorization::AgentPolicyEngine, control_plane, environment, harness, persistence}; +use ::sandbox::{SandboxHandle, SandboxId, SandboxName, network::NetworkBackend as _}; +use sandbox_microsandbox::{MicrosandboxNetworkBackend, SecretBinding}; + +/// Connects Agent policy and host-owned secrets to the Microsandbox Network Backend. +pub(super) struct Preparation { + database: persistence::Database, + policy: Rc, + network: Rc, +} + +pub(super) struct PreparedNetwork { + pub(super) bindings_changed: bool, + pub(super) environment: BTreeMap, + pub(super) harnesses: Vec, +} + +impl Preparation { + /// Creates the Agent-side Microsandbox mediation adapter. + #[must_use] + pub(super) const fn new( + database: persistence::Database, + policy: Rc, + network: Rc, + ) -> Self { + Self { + database, + policy, + network, + } + } +} + +impl Preparation { + pub(super) fn network_is_running(&self, sandbox: &SandboxId) -> bool { + self.network.is_running(sandbox) + } + + pub(super) async fn restart_network(&self, sandbox: &SandboxHandle) -> Result<(), Error> { + self.network.stop(sandbox.id()).await.map_err(Error::from) + } + + pub(super) async fn prepare(&self, record: &control_plane::AgentRecord) -> Result { + let sandbox_name = record.sandbox_name()?; + let result = async { + let secrets = &record.agent.spec.secrets; + let environment = if secrets.is_empty() { + BTreeMap::new() + } else if secrets.iter().all(|secret| secret.optional) { + environment::read_or_empty(&record.env_file_path()).await? + } else { + environment::read(&record.env_file_path()).await? + }; + let mut configured_secrets = Vec::with_capacity(secrets.len()); + let mut secret_writes = Vec::with_capacity(secrets.len()); + for secret in secrets { + let value = secret_value(&environment, secret)?; + let Some(value) = value else { + continue; + }; + configured_secrets.push(secret); + secret_writes.push(persistence::StoredSecret { + name: secret.environment.clone(), + value: zeroize::Zeroizing::new(value.as_bytes().to_vec()), + }); + } + let references = self.database.replace_agent_secrets(record.id, secret_writes).await?; + let mut bindings = Vec::with_capacity(configured_secrets.len() + 1); + for (secret, reference) in configured_secrets.into_iter().zip(references) { + let binding = SecretBinding::with_placeholder(&secret.environment, secret.inert_value(), reference)?; + bindings.push(binding); + } + let mut managed_secrets = Vec::new(); + let mut managed_environments = BTreeMap::new(); + let mut managed_placeholders = BTreeMap::new(); + let mut installed = Vec::with_capacity(record.agent.spec.harnesses.len()); + for installation in &record.agent.spec.harnesses { + // Re-evaluated every pass, so signing in later installs it with no manifest change. + if installation.optional && !harness::authentication_ready(installation.kind, &self.database).await? { + continue; + } + installed.push(installation.kind); + for secret in harness::prepare(installation.kind, &self.database).await? { + if let Some(existing) = managed_environments.insert(secret.environment, installation.kind.as_str()) + { + return Err(Error::Invalid(format!( + "harnesses {:?} and {:?} use the same managed environment {:?}", + existing, + installation.kind.as_str(), + secret.environment + ))); + } + if let Some(existing) = + managed_placeholders.insert(secret.placeholder.clone(), installation.kind.as_str()) + { + return Err(Error::Invalid(format!( + "harnesses {:?} and {:?} use the same managed placeholder {:?}", + existing, + installation.kind.as_str(), + secret.placeholder + ))); + } + managed_secrets.push(secret); + } + } + self.policy.set_agent( + &sandbox_name, + &record.agent, + managed_secrets + .iter() + .map(|secret| (secret.environment.into(), secret.allowed_hosts.clone())), + ); + for secret in managed_secrets { + bindings.push(SecretBinding::with_placeholder( + secret.environment, + &secret.placeholder, + secret.reference, + )?); + } + let guest_environment = bindings + .iter() + .map(|binding| { + let (name, value) = binding.guest_environment(); + (name.to_owned(), value.to_owned()) + }) + .collect(); + let bindings_changed = self.network.set_secret_bindings(sandbox_name.clone(), bindings)?; + Ok(PreparedNetwork { + bindings_changed, + environment: guest_environment, + harnesses: installed, + }) + } + .await; + if result.is_err() { + self.remove(&sandbox_name); + } + result + } + + pub(super) fn remove(&self, sandbox: &SandboxName) { + self.policy.remove_agent(sandbox); + self.network.remove_secret_bindings(sandbox); + } +} + +fn secret_value<'a>( + environment: &'a BTreeMap>, + secret: &crate::SecretSpec, +) -> Result, Error> { + if secret.optional { + Ok(environment::optional(environment, secret.source())) + } else { + Ok(Some(environment::required(environment, secret.source())?)) + } +} + +#[cfg(test)] +mod tests { + use super::secret_value; + use crate::SecretSpec; + use std::collections::BTreeMap; + use zeroize::Zeroizing; + + fn secret(optional: bool) -> SecretSpec { + SecretSpec { + environment: "API_TOKEN".into(), + optional, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: None, + } + } + + #[test] + fn optional_secret_omits_missing_and_empty_values() -> Result<(), crate::Error> { + let mut environment = BTreeMap::new(); + assert_eq!(secret_value(&environment, &secret(true))?, None); + + environment.insert("API_TOKEN".into(), Zeroizing::new(String::new())); + assert_eq!(secret_value(&environment, &secret(true))?, None); + Ok(()) + } + + #[test] + fn optional_secret_selects_a_present_value() -> Result<(), crate::Error> { + let environment = BTreeMap::from([("API_TOKEN".into(), Zeroizing::new("token".into()))]); + + assert_eq!(secret_value(&environment, &secret(true))?, Some("token")); + Ok(()) + } + + #[test] + fn required_secret_still_rejects_a_missing_value() { + let environment = BTreeMap::new(); + let error = secret_value(&environment, &secret(false)); + + assert!(matches!(error, Err(crate::Error::Invalid(message)) if message.contains("API_TOKEN"))); + } +} diff --git a/agentctl/src/sandbox/microsandbox/terminal.rs b/agentctl/src/sandbox/microsandbox/terminal.rs new file mode 100644 index 0000000..4fbdec3 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/terminal.rs @@ -0,0 +1,30 @@ +//! Terminal attachment transport for the Microsandbox Backend. + +use sandbox::{ + provider::SandboxProvider as _, + terminal::{AttachTerminalRequest, TerminalAttachOutcome}, +}; +use sandbox_microsandbox::MicrosandboxProvider; + +use crate::{Error, sandbox::Assignment}; + +/// Attaches the caller's terminal to an Execution in a materialized Sandbox. +/// +/// # Errors +/// +/// Returns an error when the Microsandbox cannot be inspected or attached. +pub async fn attach_terminal( + home: &std::path::Path, + assignment: &Assignment, + request: AttachTerminalRequest, +) -> Result { + // TODO: Route attachment through the daemon once it can proxy an interactive terminal stream. + let provider = MicrosandboxProvider::open(home.join("microsandbox")).await?; + let Assignment::Materialized { id, .. } = assignment else { + return Err(Error::Invalid("Session target Sandbox is not materialized".into())); + }; + let sandbox = provider.backend().inspect(id).await?; + let outcome = provider.backend().attach_terminal(&sandbox.id, request).await?; + drop(provider); + Ok(outcome) +} diff --git a/agentctl/src/sandbox/mod.rs b/agentctl/src/sandbox/mod.rs new file mode 100644 index 0000000..40256a1 --- /dev/null +++ b/agentctl/src/sandbox/mod.rs @@ -0,0 +1,459 @@ +//! Runtime selection and lifecycle integration for Agent Sandboxes. + +use std::{collections::BTreeSet, path::Path, rc::Rc}; + +use ::sandbox::{LocalFuture, Platform, SandboxHandle, SandboxId}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, control_plane::AgentRecord}; + +mod execution; +pub mod forward; +pub mod microsandbox; +pub mod platform; +pub mod responsiveness; + +pub use execution::{ExecutionService, ExecutionTarget, start_execution}; +pub use microsandbox::{GuestConnection, GuestDialer}; +pub use responsiveness::UNRESPONSIVE_AFTER; + +/// What watching a guest's heartbeat found. +enum Heartbeat { + Stalled, + Advanced, +} + +/// Stable identity of one configured Sandbox Provider. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct ProviderId(String); + +impl ProviderId { + /// Creates a validated Provider identity. + /// + /// # Errors + /// + /// Returns an error when the identity is empty or not a portable identifier. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(Error::Invalid( + "Sandbox Provider ID must contain lowercase ASCII letters, digits, or '-'".into(), + )); + } + Ok(Self(value)) + } + + /// Returns the identity as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ProviderId { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: ProviderId) -> Self { + value.0 + } +} + +impl std::fmt::Display for ProviderId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Sticky runtime assignment for one Agent's Sandbox. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase", tag = "state")] +pub enum Assignment { + /// A Provider has been durably selected before external effects begin. + Selected { + /// Configured Provider identity. + provider: ProviderId, + }, + /// The selected Provider has materialized the Sandbox. + Materialized { + /// Configured Provider identity. + provider: ProviderId, + /// Provider-owned Sandbox identity. + id: SandboxId, + /// Harness installations convergence put in this Sandbox. An optional installation whose + /// host login was absent is missing here, and is installed by a later pass once it exists. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + harnesses: Vec, + }, +} + +impl Assignment { + /// Returns the sticky Provider selection. + #[must_use] + pub const fn provider(&self) -> &ProviderId { + match self { + Self::Selected { provider } | Self::Materialized { provider, .. } => provider, + } + } + + /// Returns the materialized Sandbox identity when available. + #[must_use] + pub const fn id(&self) -> Option<&SandboxId> { + match self { + Self::Selected { .. } => None, + Self::Materialized { id, .. } => Some(id), + } + } + + /// Returns the harnesses installed in the materialized Sandbox, when it exists. + #[must_use] + pub fn installed_harnesses(&self) -> Option<&[crate::Harness]> { + match self { + Self::Selected { .. } => None, + Self::Materialized { harnesses, .. } => Some(harnesses), + } + } +} + +/// One runtime-selectable implementation of Agent Sandbox lifecycle effects. +pub trait Provider { + /// Returns the configured Provider identity. + fn id(&self) -> &ProviderId; + + /// Reports whether this Provider can satisfy the Agent requirements. + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result>; + + /// Idempotently ensures the Sandbox and its Provider-specific host integration. + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + progress: ::sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result>; + + /// Idempotently stops the Sandbox and its Provider-specific host integration, + /// keeping both for a later [`Self::ensure`]. + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>>; + + /// Opens the exact already-materialized Sandbox without lifecycle effects. + fn open<'a>(&'a self, record: &'a AgentRecord, id: &'a SandboxId) -> LocalFuture<'a, Result>; + + /// Idempotently releases the Sandbox and its Provider-specific host integration. + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>>; +} + +/// Provider result retaining lifecycle information needed by dependent Sessions. +pub struct ProviderEnsureOutcome { + pub sandbox: SandboxHandle, + /// The pass started the Sandbox's runtime, so harness processes from before are gone. + pub runtime_restarted: bool, + /// Harness installations this pass prepared. + pub harnesses: Vec, +} + +/// Materialized Sandbox and relevant lifecycle transition. +pub struct EnsureOutcome { + pub id: SandboxId, + pub runtime_restarted: bool, + /// The running Sandbox, for Agent-level setup that follows platform setup. + pub sandbox: SandboxHandle, + /// Harness installations this pass prepared. + pub harnesses: Vec, +} + +/// Runtime-selectable setup for an operating system reported by a materialized Sandbox. +pub trait PlatformAdapter { + /// Reports whether this adapter supports the resolved Sandbox platform. + fn supports(&self, platform: &Platform) -> bool; + + /// Idempotently applies Agent and harness setup inside the Sandbox, + /// reporting its steps through `steps`. + fn setup<'a>( + &'a self, + record: &'a AgentRecord, + sandbox: &'a SandboxHandle, + harnesses: &'a [crate::Harness], + steps: &'a ::sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>>; +} + +/// Resolves Agent requirements against configured Providers and dispatches lifecycle effects. +pub struct Service { + providers: Vec>, + platforms: Vec>, + responsiveness: responsiveness::Tracker, +} + +impl Service { + /// Creates a runtime Provider registry. + /// + /// # Errors + /// + /// Returns an error when no Providers are configured or an identity is duplicated. + pub fn new( + providers: impl IntoIterator>, + platforms: impl IntoIterator>, + ) -> Result { + let mut identities = BTreeSet::new(); + let mut configured = Vec::new(); + for provider in providers { + if !identities.insert(provider.id().clone()) { + return Err(Error::Invalid("duplicate Sandbox Provider identity".into())); + } + configured.push(provider); + } + if configured.is_empty() { + return Err(Error::Invalid("at least one Sandbox Provider is required".into())); + } + let platforms = platforms.into_iter().collect::>(); + if platforms.is_empty() { + return Err(Error::Invalid( + "at least one Sandbox platform adapter is required".into(), + )); + } + Ok(Self { + providers: configured, + platforms, + responsiveness: responsiveness::Tracker::default(), + }) + } + + /// Selects the first configured Provider that supports the Agent requirements. + /// + /// # Errors + /// + /// Returns an error when capability discovery fails or no Provider supports the Agent. + pub async fn resolve(&self, record: &AgentRecord) -> Result { + for provider in &self.providers { + if provider.supports(record).await? { + return Ok(provider.id().clone()); + } + } + Err(Error::Invalid(format!( + "no configured Sandbox Provider supports platform {:?}", + record.agent.spec.sandbox.platform + ))) + } + + /// Runs the selected Provider and resolved Sandbox-platform setup idempotently. + /// + /// # Errors + /// + /// Returns an error when the assignment is missing, its Provider is unavailable, or setup fails. + pub async fn ensure( + &self, + record: &AgentRecord, + progress: ::sandbox::ProgressReporter, + ) -> Result { + let provider = self.assigned_provider(record)?; + let environment = crate::environment::resolve(record).await?; + let outcome = provider.ensure(record, environment, progress.clone()).await?; + let sandbox = outcome.sandbox; + let resolved_platform = &sandbox.snapshot().image.platform; + let adapter = self + .platforms + .iter() + .find(|adapter| adapter.supports(resolved_platform)) + .ok_or_else(|| { + Error::Invalid(format!( + "no Agent setup adapter supports resolved Sandbox platform {resolved_platform:?}" + )) + })?; + // The snapshot is fresh from this pass, which may have booted the + // guest again, so a heartbeat recorded before it no longer applies. + self.responsiveness + .observe(sandbox.snapshot(), tokio::time::Instant::now()); + let phase = progress.start_phase(crate::progress::SETUP).await; + self.guard_guest( + record, + &sandbox.snapshot().id, + adapter.setup(record, &sandbox, &outcome.harnesses, &progress.steps()), + ) + .await?; + phase.complete().await; + Ok(EnsureOutcome { + id: sandbox.snapshot().id.clone(), + runtime_restarted: outcome.runtime_restarted, + sandbox, + harnesses: outcome.harnesses, + }) + } + + /// Stops the Agent's Sandbox, keeping its identity and storage for a later + /// [`Self::ensure`]. An Agent without an assigned Provider has nothing to stop. + /// + /// # Errors + /// + /// Returns an error when the selected Provider is unavailable or the stop fails. + pub async fn stop(&self, record: &AgentRecord) -> Result<(), Error> { + let Some(assignment) = &record.agent.status.sandbox else { + return Ok(()); + }; + self.provider(assignment.provider())?.stop(record).await?; + if let Some(id) = assignment.id() { + self.responsiveness.forget(id); + } + Ok(()) + } + + /// Opens the persisted materialized Sandbox without lifecycle or setup effects. + /// + /// # Errors + /// + /// Returns an error unless the assignment is materialized through a configured Provider. + pub async fn open(&self, record: &AgentRecord) -> Result { + let Some(Assignment::Materialized { provider, id, .. }) = &record.agent.status.sandbox else { + return Err(Error::Invalid("Agent has no materialized Sandbox assignment".into())); + }; + self.provider(provider)?.open(record, id).await + } + + /// Whether the Sandbox's guest reported a heartbeat when last inspected. + #[must_use] + pub fn reports_heartbeat(&self, sandbox: &SandboxId) -> bool { + self.responsiveness.heartbeat(sandbox).is_some() + } + + /// Runs work that reaches into a Sandbox's guest, inspecting the Sandbox + /// every [`responsiveness::OBSERVATION_INTERVAL`] without a round trip to + /// the guest, and ends the work once the guest has stalled. + /// + /// A guest already known to be stalled is not reached at all. When the work + /// fails, such as a command timing out inside a guest that just stalled, + /// the heartbeat decides whether the failure is the stall: an advancing + /// heartbeat keeps the failure. Dropping the work closes its guest + /// connections. + /// + /// # Errors + /// + /// Returns [`Error::SandboxUnresponsive`] when the guest stalls, and + /// otherwise the work's error. + pub async fn guard_guest( + &self, + record: &AgentRecord, + sandbox: &SandboxId, + work: impl Future>, + ) -> Result { + if self.responsiveness.stalled(sandbox, tokio::time::Instant::now()) { + return Err(responsiveness::stalled()); + } + let provider = self.assigned_provider(record)?; + let result = tokio::select! { + biased; + _stalled = self.watch_heartbeat(provider, record, sandbox, None) => return Err(responsiveness::stalled()), + result = work => result, + }; + let current = self.responsiveness.heartbeat(sandbox); + match result { + Err(error) if current.is_some() => match self.watch_heartbeat(provider, record, sandbox, current).await { + Heartbeat::Stalled => Err(responsiveness::stalled()), + Heartbeat::Advanced => Err(error), + }, + result => result, + } + } + + /// Inspects the Sandbox until its guest has stalled, or, given `from`, + /// until its heartbeat moves past `from` or is no longer reported. + async fn watch_heartbeat( + &self, + provider: &dyn Provider, + record: &AgentRecord, + sandbox: &SandboxId, + from: Option<::sandbox::GuestHeartbeat>, + ) -> Heartbeat { + let interval = responsiveness::OBSERVATION_INTERVAL; + let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); + loop { + ticker.tick().await; + // A failed inspection is retried at the next tick. + if let Ok(inspected) = provider.open(record, sandbox).await { + self.responsiveness + .observe(inspected.snapshot(), tokio::time::Instant::now()); + } + if self.responsiveness.stalled(sandbox, tokio::time::Instant::now()) { + return Heartbeat::Stalled; + } + if from.is_some() && self.responsiveness.heartbeat(sandbox) != from { + return Heartbeat::Advanced; + } + } + } + + /// Releases the selected Provider idempotently. An unassigned Agent has no effect to release. + /// + /// # Errors + /// + /// Returns an error when the selected Provider is unavailable or release fails. + pub async fn release(&self, record: &AgentRecord) -> Result<(), Error> { + let Some(assignment) = &record.agent.status.sandbox else { + return Ok(()); + }; + self.provider(assignment.provider())?.release(record).await?; + if let Some(id) = assignment.id() { + self.responsiveness.forget(id); + } + Ok(()) + } + + fn assigned_provider(&self, record: &AgentRecord) -> Result<&dyn Provider, Error> { + let assignment = record + .agent + .status + .sandbox + .as_ref() + .ok_or_else(|| Error::Invalid("Agent has no Sandbox Provider assignment".into()))?; + self.provider(assignment.provider()) + } + + fn provider(&self, id: &ProviderId) -> Result<&dyn Provider, Error> { + self.providers + .iter() + .find(|provider| provider.id() == id) + .map(Rc::as_ref) + .ok_or_else(|| Error::Invalid(format!("assigned Sandbox Provider {id:?} is not configured"))) + } +} + +/// Connects a guest TCP dialer through the recorded Sandbox Provider. +/// +/// # Errors +/// +/// Returns an error when the Provider is unsupported by this client or the +/// Sandbox is not running. +pub async fn guest_tcp_dialer(home: &Path, assignment: &Assignment) -> Result { + match assignment.provider().as_str() { + microsandbox::PROVIDER_ID => microsandbox::guest_tcp_dialer(home, assignment).await, + provider => Err(Error::Invalid(format!( + "guest TCP forwarding is not supported through Sandbox Provider {provider:?}" + ))), + } +} + +/// Attaches a terminal through the recorded Sandbox Provider. +/// +/// # Errors +/// +/// Returns an error when the Provider is unsupported by this client or attachment fails. +pub async fn attach_terminal( + home: &Path, + assignment: &Assignment, + request: ::sandbox::terminal::AttachTerminalRequest, +) -> Result<::sandbox::terminal::TerminalAttachOutcome, Error> { + match assignment.provider().as_str() { + microsandbox::PROVIDER_ID => microsandbox::attach_terminal(home, assignment, request).await, + provider => Err(Error::Invalid(format!( + "terminal attachment is not supported through Sandbox Provider {provider:?}" + ))), + } +} diff --git a/agentctl/src/sandbox/platform/files.rs b/agentctl/src/sandbox/platform/files.rs new file mode 100644 index 0000000..79c7e38 --- /dev/null +++ b/agentctl/src/sandbox/platform/files.rs @@ -0,0 +1,130 @@ +//! Convergent file placement for Agent setup. +//! +//! Setup reruns on every reconciliation pass, so a file the Agent's harness watches must not be +//! rewritten unless its contents changed: every write is a new inode the harness reloads. The +//! Sandbox SDK makes each write atomic; this module additionally skips writes that would not +//! change anything. +//! +//! The files compared here are writable by the Sandbox user, so every read is bounded: a file +//! grown or redirected to something unbounded counts as changed and is replaced. + +use std::io::Cursor; + +use ::sandbox::{SandboxHandle, SandboxPath}; +use tokio::io::AsyncReadExt as _; + +use crate::Error; + +/// Reads up to `limit` bytes of a Sandbox file, or `None` when it cannot be read or is longer. +/// +/// Any read failure counts as absent: the following write reports the real problem if the +/// file is genuinely inaccessible. +pub(crate) async fn read_existing(sandbox: &SandboxHandle, path: &str, limit: usize) -> Option> { + let reader = sandbox.read_file(&SandboxPath::new(path)).await.ok()?; + let mut contents = Vec::new(); + let bound = u64::try_from(limit).ok()?.checked_add(1)?; + reader.take(bound).read_to_end(&mut contents).await.ok()?; + (contents.len() <= limit).then_some(contents) +} + +/// Writes `contents` to `path` unless the file already holds exactly those bytes. +/// +/// Returns whether the file was written. +/// +/// # Errors +/// +/// Returns an error when the Sandbox cannot write the file. +pub(crate) async fn write_if_changed(sandbox: &SandboxHandle, path: &str, contents: &[u8]) -> Result { + if read_existing(sandbox, path, contents.len()).await.as_deref() == Some(contents) { + return Ok(false); + } + sandbox + .write_file(&SandboxPath::new(path), Box::pin(Cursor::new(contents.to_vec()))) + .await?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use ::sandbox::{ + EnsureSandboxRequest, Platform, RootFilesystem, SandboxHandle, SandboxName, SandboxPath, SandboxResources, + SandboxService, SandboxSpec, image::ImageSource, memory, + }; + + use super::{read_existing, write_if_changed}; + + async fn sandbox() -> (Rc, SandboxHandle) { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let spec = SandboxSpec { + image: ImageSource::Build { + context: std::path::PathBuf::from("."), + dockerfile: std::path::PathBuf::from("Dockerfile"), + target: None, + }, + platform: Platform::native("linux"), + resources: SandboxResources::new( + "1".parse().expect("CPU"), + "512Mi".parse().expect("memory"), + RootFilesystem::layered("1Gi".parse().expect("root filesystem")), + ), + init_system: ::sandbox::init::InitSystem::Backend, + retention_policy: ::sandbox::RetentionPolicy::Retain, + }; + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + SandboxName::new("files").expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + (backend, sandbox) + } + + #[tokio::test(flavor = "local")] + async fn identical_contents_are_not_rewritten() { + let (backend, sandbox) = sandbox().await; + assert!( + write_if_changed(&sandbox, "/etc/agent.conf", b"one") + .await + .expect("write") + ); + assert!( + !write_if_changed(&sandbox, "/etc/agent.conf", b"one") + .await + .expect("compare") + ); + assert!( + write_if_changed(&sandbox, "/etc/agent.conf", b"two") + .await + .expect("rewrite") + ); + assert_eq!(backend.file_writes().len(), 2); + } + + #[tokio::test(flavor = "local")] + async fn reads_stop_at_the_bound() { + let (_, sandbox) = sandbox().await; + sandbox + .write_file( + &SandboxPath::new("/etc/large"), + Box::pin(std::io::Cursor::new(vec![b'x'; 1024])), + ) + .await + .expect("write"); + assert_eq!( + read_existing(&sandbox, "/etc/large", 1024).await, + Some(vec![b'x'; 1024]) + ); + assert_eq!(read_existing(&sandbox, "/etc/large", 1023).await, None); + assert_eq!(read_existing(&sandbox, "/etc/missing", 1024).await, None); + // A longer file than the desired contents is different without reading it all. + assert!( + write_if_changed(&sandbox, "/etc/large", b"short") + .await + .expect("rewrite") + ); + } +} diff --git a/agentctl/src/sandbox/platform/linux.rs b/agentctl/src/sandbox/platform/linux.rs new file mode 100644 index 0000000..f7d51a2 --- /dev/null +++ b/agentctl/src/sandbox/platform/linux.rs @@ -0,0 +1,625 @@ +//! Idempotent Agent setup for Linux Sandboxes. + +use std::{io::Cursor, path::Path}; + +use ::sandbox::{LocalFuture, Platform, SandboxHandle, SandboxPath, execution::ExecutionSpec}; +use ignore::WalkBuilder; + +use crate::{Error, control_plane, harness}; + +use super::{super::PlatformAdapter, files::write_if_changed}; + +/// The platform-owned Sandbox user every Session, Execution and SSH login runs as. +pub(crate) const USER: &str = "agent"; +pub(crate) const HOME: &str = "/home/agent"; +pub(crate) const WORKING_DIRECTORY: &str = "/home/agent/code"; +pub(crate) const CONTAINER_HOST: &str = "unix:///run/podman/podman.sock"; +const HOME_ARCHIVE: &str = "/tmp/agent-home.tar"; +/// Locale every Sandbox process runs with; the image ships it, so UTF-8 output +/// renders regardless of the host's locale. +pub(crate) const UTF8_LOCALE: &str = "C.UTF-8"; +/// Terminal type every Sandbox terminal runs with. Host-specific TERM names are +/// not necessarily installed in the Sandbox image; this baseline is. +pub(crate) const PORTABLE_TERMINAL: &str = "xterm-256color"; +const PODMAN: &str = "/usr/bin/podman"; +const SETUP_STDERR_LINES: usize = 3; +const SYSTEMD_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const SYSTEMD_READY_POLL: std::time::Duration = std::time::Duration::from_secs(1); +/// `systemctl`, the only supported guest service manager today. +pub(super) const SYSTEMCTL: &str = "/usr/bin/systemctl"; +/// Present exactly when systemd is the running init; the marker systemd documents for this purpose. +pub(super) const SYSTEMD_RUNNING: &str = "/run/systemd/system"; +// Podman reads these files when it creates containers. The default mount also +// reaches Buildah RUN containers and exposes the guest's superset bundle at a +// path no distro package owns. Distro trust paths are populated by an OCI hook +// that copies the bundle into the container root filesystem: a bind mount +// there would make the file a mount point, and package managers replacing the +// bundle (`apt-get install ca-certificates`) then fail with EBUSY. The hook +// also drops the mediator CA as an anchor into the distro's source directory +// so a regenerated bundle keeps trusting mediation. This is fail-open +// convenience; mediated networking remains the enforcement boundary if a +// workload bypasses the configuration. +const PODMAN_CONTAINERS_CONF: &str = "/etc/containers/containers.conf.d/50-agent-ca.conf"; +const PODMAN_RUNTIME_CONF: &str = "/etc/containers/containers.conf.d/51-agent-runtime.conf"; +const PODMAN_MOUNTS_CONF: &str = "/etc/containers/mounts.conf"; +const PODMAN_REGISTRIES_CONF: &str = "/etc/containers/registries.conf.d/50-agent-docker-hub.conf"; +const PODMAN_SOCKET_DROP_IN: &str = "/etc/systemd/system/podman.socket.d/50-agent-access.conf"; +const PODMAN_HOOKS_DIR: &str = "/etc/containers/oci/hooks.d"; +const PODMAN_CA_HOOK_CONF: &str = "/etc/containers/oci/hooks.d/50-agent-ca.json"; +const PODMAN_CA_HOOK: &str = "/usr/local/libexec/agent-container-ca"; +const PODMAN_CONTAINERS_CONF_CONTENTS: &[u8] = br#"[containers] +env = [ + "SSL_CERT_FILE=/run/agent/tls/ca-bundle.pem", + "CURL_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "REQUESTS_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem", + "GIT_SSL_CAINFO=/run/agent/tls/ca-bundle.pem", + "NPM_CONFIG_CAFILE=/run/agent/tls/ca-bundle.pem", +] +"#; +// The minimal systemd guest has no D-Bus system bus. Podman's default systemd +// cgroup manager therefore made crun fail with `cannot open sd-bus`; cgroupfs +// keeps ownership inside Podman instead of relying on unavailable systemd APIs. +// Sandbox teardown owns final cleanup, rather than systemd tracking these +// container cgroups as units. +// The compatibility API must apply Docker's implicit docker.io resolution as +// well; it does not consult registries.conf for that behavior. +// Implicit hook directories are deprecated, so the directory is named explicitly. +const PODMAN_RUNTIME_CONF_CONTENTS: &[u8] = b"[engine]\ncgroup_manager = \"cgroupfs\"\ncompat_api_enforce_docker_hub = true\nhooks_dir = [\"/etc/containers/oci/hooks.d\"]\n"; +const PODMAN_MOUNTS_CONF_CONTENTS: &[u8] = b"/etc/ssl/certs/ca-certificates.crt:/run/agent/tls/ca-bundle.pem\n"; +const PODMAN_CA_HOOK_CONF_CONTENTS: &[u8] = br#"{"version":"1.0.0","hook":{"path":"/usr/local/libexec/agent-container-ca"},"when":{"always":true},"stages":["createRuntime"]} +"#; +// Runs as an OCI `createRuntime` hook with the container state on stdin and +// the root filesystem mounted. It must not depend on tools the guest image may +// lack, so it is POSIX sh plus sed. Failures are swallowed: trust wiring is a +// convenience and must never stop a container from starting. +const PODMAN_CA_HOOK_CONTENTS: &[u8] = br#"#!/bin/sh +# Installed by agentd. Copies the mediated CA bundle into distro trust paths of a +# starting container and adds the mediator CA as an anchor for bundle regeneration. +set -u +bundle_dir=$(cat | tr -d '\n' | sed -n 's/.*"bundle"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[ -n "$bundle_dir" ] && [ -f "$bundle_dir/config.json" ] || exit 0 +rootfs=$(tr -d '\n' <"$bundle_dir/config.json" \ + | sed -n 's/.*"root"[[:space:]]*:[[:space:]]*{[^}]*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[ -n "$rootfs" ] || exit 0 +case "$rootfs" in /*) ;; *) rootfs="$bundle_dir/$rootfs" ;; esac +[ -d "$rootfs" ] || exit 0 +bundle=/etc/ssl/certs/ca-certificates.crt +anchor=/.msb/tls/ca.pem +[ -f "$bundle" ] || exit 0 +install_copy() { + rm -f "$2" 2>/dev/null + cp "$1" "$2" 2>/dev/null && chmod 0644 "$2" 2>/dev/null +} +for target in etc/ssl/certs/ca-certificates.crt etc/pki/tls/certs/ca-bundle.crt etc/ssl/cert.pem; do + directory="$rootfs/${target%/*}" + [ -d "$directory" ] || continue + if [ "$target" = etc/ssl/certs/ca-certificates.crt ] || [ -e "$rootfs/$target" ] || [ -L "$rootfs/$target" ]; then + install_copy "$bundle" "$rootfs/$target" + fi +done +if [ -f "$anchor" ]; then + if [ -d "$rootfs/usr/local/share" ]; then + mkdir -p "$rootfs/usr/local/share/ca-certificates" 2>/dev/null \ + && install_copy "$anchor" "$rootfs/usr/local/share/ca-certificates/agent-mediator.crt" + fi + if [ -d "$rootfs/etc/pki/ca-trust/source/anchors" ]; then + install_copy "$anchor" "$rootfs/etc/pki/ca-trust/source/anchors/agent-mediator.crt" + fi +fi +exit 0 +"#; +// One search registry is deterministic in enforcing mode and reproduces +// Docker's implicit docker.io[/library] normalization without alias upkeep. +const PODMAN_REGISTRIES_CONF_CONTENTS: &[u8] = + b"unqualified-search-registries = [\"docker.io\"]\nshort-name-mode = \"enforcing\"\n"; +const PODMAN_SOCKET_DROP_IN_CONTENTS: &[u8] = b"[Socket]\nDirectoryMode=0755\nSocketGroup=agent\nSocketMode=0660\n"; + +/// Agent setup for Linux Sandboxes. +pub struct Linux; + +pub(super) fn execution_spec(command: &[String], terminal: bool) -> Result { + let (executable, arguments) = command + .split_first() + .ok_or_else(|| Error::Invalid("command is required".into()))?; + let mut environment = vec![ + ("HOME".into(), HOME.into()), + ("LANG".into(), UTF8_LOCALE.into()), + ("CONTAINER_HOST".into(), CONTAINER_HOST.into()), + ]; + if terminal { + // Host-specific TERM names are not necessarily installed in the guest. + environment.push(("TERM".into(), PORTABLE_TERMINAL.into())); + } + Ok( + ExecutionSpec::command(SandboxPath::new(executable), arguments.iter().cloned()) + .with_working_directory(SandboxPath::new(WORKING_DIRECTORY)) + .with_environment(environment), + ) +} + +impl PlatformAdapter for Linux { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + record: &'a control_plane::AgentRecord, + sandbox: &'a SandboxHandle, + harnesses: &'a [crate::Harness], + steps: &'a ::sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.setup(record, sandbox, harnesses, steps)) + } +} + +impl Linux { + /// Sets up only the harnesses preparation reported installing, so setup and preparation + /// cannot disagree about an optional installation whose host login was absent. + async fn setup( + &self, + record: &control_plane::AgentRecord, + sandbox: &SandboxHandle, + harnesses: &[crate::Harness], + steps: &::sandbox::SandboxProgress, + ) -> Result<(), Error> { + let installations: Vec<&crate::HarnessSpec> = record + .agent + .spec + .harnesses + .iter() + .filter(|installation| harnesses.contains(&installation.kind)) + .collect(); + for installation in &installations { + let step = steps.start_step(format!("Verify {}", installation.kind.as_str())).await; + harness::verify_linux(installation.kind, sandbox, installation.version.as_deref()).await?; + step.complete().await; + } + let step = steps.start_step("Prepare workspace and Podman").await; + run_checked(sandbox, "/usr/bin/install", ["-d", "-m", "0755", WORKING_DIRECTORY]).await?; + configure_podman(sandbox).await?; + step.complete().await; + let step = steps.start_step("Sync home").await; + let archive = archive_home(record.source_directory.clone(), record.agent.spec.home.source.clone()).await?; + sync_home(sandbox, archive).await?; + configure_git_identity(sandbox).await?; + step.complete().await; + let instructions = read_instructions(record).await?; + let skills = read_skills(record).await?; + for installation in &installations { + let step = steps + .start_step(format!( + "Install {} instructions and Skills", + installation.kind.as_str() + )) + .await; + harness::bootstrap_linux(installation.kind, sandbox, HOME, instructions.as_deref(), &skills).await?; + step.complete().await; + } + Ok(()) + } +} + +async fn configure_git_identity(sandbox: &SandboxHandle) -> Result<(), Error> { + let environment = &sandbox.snapshot().environment; + let (Some(name), Some(email)) = (environment.get("GIT_USER_NAME"), environment.get("GIT_USER_EMAIL")) else { + if environment.contains_key("GIT_USER_NAME") || environment.contains_key("GIT_USER_EMAIL") { + return Err(Error::Invalid( + "GIT_USER_NAME and GIT_USER_EMAIL must both be configured".into(), + )); + } + return Ok(()); + }; + let present = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/env"), + ["git".into(), "--version".into()], + )) + .await?; + match present.status.code { + 127 => return Ok(()), + 0 => {} + code => { + return Err(Error::SandboxSetup(format!( + "Git presence check exited with code {code}" + ))); + } + } + run_git_config(sandbox, "user.name", name).await?; + run_git_config(sandbox, "user.email", email).await +} + +async fn run_git_config(sandbox: &SandboxHandle, key: &str, value: &str) -> Result<(), Error> { + let args = ["git", "config", "--global", key, value]; + let output = sandbox + .run_execution( + ExecutionSpec::command(SandboxPath::new("/usr/bin/env"), args.into_iter().map(str::to_owned)) + .with_environment([("HOME".into(), HOME.into())]), + ) + .await?; + checked_output("/usr/bin/env", &args, &output) +} + +async fn configure_podman(sandbox: &SandboxHandle) -> Result<(), Error> { + let present = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/test"), + ["-x".into(), PODMAN.into()], + )) + .await?; + match present.status.code { + 1 => return Ok(()), + 0 => {} + code => { + return Err(Error::SandboxSetup(format!( + "Podman presence check exited with code {code}" + ))); + } + } + + wait_for_systemd(sandbox).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0755", + "/etc/containers/containers.conf.d", + "/etc/containers/registries.conf.d", + "/etc/systemd/system/podman.socket.d", + PODMAN_HOOKS_DIR, + "/usr/local/libexec", + ], + ) + .await?; + write_if_changed(sandbox, PODMAN_CONTAINERS_CONF, PODMAN_CONTAINERS_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_RUNTIME_CONF, PODMAN_RUNTIME_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_MOUNTS_CONF, PODMAN_MOUNTS_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_REGISTRIES_CONF, PODMAN_REGISTRIES_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_SOCKET_DROP_IN, PODMAN_SOCKET_DROP_IN_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_CA_HOOK_CONF, PODMAN_CA_HOOK_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_CA_HOOK, PODMAN_CA_HOOK_CONTENTS).await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/chmod", "0755", PODMAN_CA_HOOK]).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/usr/bin/install", "-d", "-m", "0755", "/run/podman"], + ) + .await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/usr/bin/systemctl", "daemon-reload"]).await?; + // An already-listening socket retains its old mode until the next Sandbox + // boot; the compile-time drop-in is not changed independently at runtime. + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/usr/bin/systemctl", "enable", "--now", "podman.socket"], + ) + .await +} + +/// Waits until systemd is PID 1 and has finished booting the guest. +/// +/// Sandbox setup starts as soon as the Sandbox accepts Executions, which on an +/// image-init guest is before the image entrypoint has become systemd; `systemctl` +/// then reports "System has not been booted with systemd". `is-system-running +/// --wait` blocks until startup finishes once systemd is up. It runs as root +/// because the guest has no D-Bus system bus and only root reaches systemd's +/// private socket. A `degraded` system counts as ready: a failed optional unit, +/// such as a best-effort workspace clone, must not block the Podman configuration. +pub(super) async fn wait_for_systemd(sandbox: &SandboxHandle) -> Result<(), Error> { + let deadline = tokio::time::Instant::now() + SYSTEMD_READY_TIMEOUT; + loop { + // `--wait` blocks for as long as boot takes, so the deadline bounds the wait itself + // and a still-running check is killed rather than left behind in the guest. + let started = sandbox + .start_execution(::sandbox::execution::StartExecutionRequest::new( + ExecutionSpec::command( + SandboxPath::new("/usr/bin/sudo"), + ["-n", "/usr/bin/systemctl", "is-system-running", "--wait"].map(str::to_owned), + ), + )) + .await?; + let execution_id = started.id.clone(); + let output = match tokio::time::timeout_at(deadline, started.collect()).await { + Ok(output) => output?, + Err(_elapsed) => { + let _ = sandbox.kill_execution(&execution_id).await; + return Err(Error::SandboxSetup(format!( + "systemd did not finish booting within {}s", + SYSTEMD_READY_TIMEOUT.as_secs() + ))); + } + }; + let state = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if output.status.success() || matches!(state.as_str(), "running" | "degraded") { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + let last = if state.is_empty() { + String::from_utf8_lossy(&output.stderr).trim().to_owned() + } else { + state + }; + return Err(Error::SandboxSetup(format!( + "systemd did not become ready within {}s: {last}", + SYSTEMD_READY_TIMEOUT.as_secs() + ))); + } + tokio::time::sleep(SYSTEMD_READY_POLL).await; + } +} + +/// Concatenates the instruction files in manifest order, each terminated by a newline and +/// separated by a blank line, so independent documents read as sections of one file. +async fn read_instructions(record: &control_plane::AgentRecord) -> Result>, Error> { + let mut combined = Vec::new(); + for (index, spec) in record.agent.spec.instructions.iter().enumerate() { + let source = if spec.source.is_absolute() { + spec.source.clone() + } else { + record.source_directory.join(&spec.source) + }; + let metadata = tokio::fs::metadata(&source).await?; + if !metadata.is_file() { + return Err(Error::Invalid(format!( + "spec.instructions[{index}].source must identify a file" + ))); + } + let contents = tokio::fs::read(source).await?; + if !combined.is_empty() { + combined.push(b'\n'); + } + combined.extend_from_slice(contents.strip_suffix(b"\n").unwrap_or(&contents)); + combined.push(b'\n'); + } + Ok((!combined.is_empty()).then_some(combined)) +} + +async fn read_skills(record: &control_plane::AgentRecord) -> Result, Error> { + let mut skills = Vec::with_capacity(record.agent.spec.skills.len()); + for (index, spec) in record.agent.spec.skills.iter().enumerate() { + let field = format!("spec.skills[{index}].source"); + let name = spec + .name() + .ok_or_else(|| Error::Invalid(format!("{field} must end in the skill's directory name")))?; + let source = resolve_source(&record.source_directory, &spec.source, &field)?; + if !source.join("SKILL.md").is_file() { + return Err(Error::Invalid(format!("{field} must contain SKILL.md"))); + } + let files = tokio::task::spawn_blocking(move || read_skill_files(&source, &field)) + .await + .map_err(|error| Error::Daemon(format!("Agent skill scan task failed: {error}")))??; + skills.push(harness::Skill { + name: name.to_owned(), + files, + }); + } + Ok(skills) +} + +fn read_skill_files(source: &Path, field: &str) -> Result, Error> { + let mut files = Vec::new(); + walk_source(source, field, |relative, path, is_dir| { + if is_dir { + return Ok(()); + } + let relative_path = relative + .components() + .map(|component| component.as_os_str().to_str()) + .collect::>>() + .ok_or_else(|| Error::Invalid(format!("{field} contains a non-UTF-8 file name")))? + .join("/"); + files.push(harness::SkillFile { + relative_path, + contents: std::fs::read(path)?, + }); + Ok(()) + })?; + Ok(files) +} + +async fn archive_home(manifest_directory: std::path::PathBuf, source: std::path::PathBuf) -> Result, Error> { + let source = resolve_source(&manifest_directory, &source, "spec.home.source")?; + archive_directory(source, "spec.home.source".to_owned()).await +} + +/// Archives a resolved host directory on a blocking thread; `field` names the manifest field in errors. +async fn archive_directory(source: std::path::PathBuf, field: String) -> Result, Error> { + let task = format!("Agent {field} scan task failed"); + tokio::task::spawn_blocking(move || archive_directory_blocking(&source, &field)) + .await + .map_err(|error| Error::Daemon(format!("{task}: {error}")))? +} + +fn archive_directory_blocking(source: &Path, field: &str) -> Result, Error> { + let mut archive = tar::Builder::new(Vec::new()); + walk_source(source, field, |relative, path, is_dir| { + if is_dir { + archive.append_dir(relative, path)?; + } else { + archive.append_path_with_name(path, relative)?; + } + Ok(()) + })?; + archive.into_inner().map_err(Error::from) +} + +/// Visits every entry below `source` with its relative path, rejecting symbolic links. +fn walk_source( + source: &Path, + field: &str, + mut visit: impl FnMut(&Path, &Path, bool) -> Result<(), Error>, +) -> Result<(), Error> { + for result in WalkBuilder::new(source) + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_exclude(false) + .parents(false) + .follow_links(false) + .build() + { + let entry = result.map_err(|error| Error::Invalid(format!("cannot traverse {field}: {error}")))?; + let relative = entry + .path() + .strip_prefix(source) + .map_err(|_| Error::Invalid(format!("{field} traversal escaped its root")))?; + if relative.as_os_str().is_empty() { + continue; + } + // Only directories and regular files are carried into the Sandbox. A symbolic link would + // point at host content outside the source, and reading a FIFO or device would block setup. + let kind = entry + .file_type() + .ok_or_else(|| Error::Invalid(format!("{field} entry {} has no file type", relative.display())))?; + if kind.is_symlink() { + return Err(Error::Invalid(format!( + "{field} contains unsupported symbolic link {}", + relative.display() + ))); + } + if !kind.is_dir() && !kind.is_file() { + return Err(Error::Invalid(format!( + "{field} contains unsupported non-regular file {}", + relative.display() + ))); + } + visit(relative, entry.path(), kind.is_dir())?; + } + Ok(()) +} + +async fn sync_home(sandbox: &SandboxHandle, archive: Vec) -> Result<(), Error> { + sandbox + .write_file(&SandboxPath::new(HOME_ARCHIVE), Box::pin(Cursor::new(archive))) + .await?; + run_checked(sandbox, "/usr/bin/tar", ["-xf", HOME_ARCHIVE, "-C", HOME]).await +} + +/// Runs one setup command in the Sandbox and fails with what it ran and what it printed. +/// +/// # Errors +/// +/// Returns an error when the Execution cannot start or exits unsuccessfully. +pub(crate) async fn run_checked>( + sandbox: &SandboxHandle, + executable: &str, + args: impl IntoIterator, +) -> Result<(), Error> { + let args: Vec = args.into_iter().map(|arg| arg.as_ref().to_owned()).collect(); + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new(executable), args.clone())) + .await?; + checked_output(executable, &args, &output) +} + +/// Runs `test ` in the guest: whether the path exists in the tested form. +/// +/// # Errors +/// +/// Returns an error when the Execution cannot start or `test` fails for any +/// reason other than the path being absent. +pub(super) async fn path_exists(sandbox: &SandboxHandle, test: &str, path: &str) -> Result { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/test"), + [test.to_owned(), path.to_owned()], + )) + .await?; + match output.status.code { + 0 => Ok(true), + 1 => Ok(false), + code => Err(Error::SandboxSetup(format!( + "presence check `test {test} {path}` exited with code {code}" + ))), + } +} + +/// Whether the guest has `systemctl` and runs systemd as its init. +/// +/// # Errors +/// +/// Returns an error when either presence check cannot run. +pub(super) async fn systemd_available(sandbox: &SandboxHandle) -> Result { + Ok(path_exists(sandbox, "-x", SYSTEMCTL).await? && path_exists(sandbox, "-d", SYSTEMD_RUNNING).await?) +} + +fn checked_output( + executable: &str, + args: &[impl AsRef], + output: &::sandbox::execution::ExecutionOutput, +) -> Result<(), Error> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + let detail = if stderr.is_empty() { + String::new() + } else { + let tail = stderr + .lines() + .rev() + .take(SETUP_STDERR_LINES) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" | "); + format!(": {tail}") + }; + Err(Error::SandboxSetup(format!( + "command `{executable} {}` exited with code {}{detail}", + args.iter().map(AsRef::as_ref).collect::>().join(" "), + output.status.code + ))) +} + +fn resolve_source(manifest_directory: &Path, source: &Path, field: &str) -> Result { + let source = if source.is_absolute() { + source.to_path_buf() + } else { + manifest_directory.join(source) + }; + let source = std::fs::canonicalize(source)?; + if !source.is_dir() { + return Err(Error::Invalid(format!("{field} must identify a directory"))); + } + Ok(source) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use ::sandbox::execution::Program; + + #[test] + fn transient_execution_uses_agent_home_and_portable_terminal() { + let command = ["bash".to_owned(), "-l".to_owned()]; + let spec = super::execution_spec(&command, true).expect("Linux Execution spec"); + assert_eq!( + spec.working_directory().map(::sandbox::SandboxPath::as_str), + Some("/home/agent/code") + ); + assert_eq!(spec.environment().get("HOME").map(String::as_str), Some("/home/agent")); + assert_eq!(spec.environment().get("LANG").map(String::as_str), Some("C.UTF-8")); + assert_eq!( + spec.environment().get("CONTAINER_HOST").map(String::as_str), + Some("unix:///run/podman/podman.sock") + ); + assert_eq!( + spec.environment().get("TERM").map(String::as_str), + Some("xterm-256color") + ); + assert!(matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "bash" && args == &["-l"] + )); + } +} diff --git a/agentctl/src/sandbox/platform/linux_ssh.rs b/agentctl/src/sandbox/platform/linux_ssh.rs new file mode 100644 index 0000000..bf90325 --- /dev/null +++ b/agentctl/src/sandbox/platform/linux_ssh.rs @@ -0,0 +1,516 @@ +//! OpenSSH server state written into Linux Sandboxes for SSH access. +//! +//! The image provides OpenSSH, systemd and the platform user. This module owns +//! the complete server policy, unit, keys, client authorization and SSH login +//! environment, or removes all of it when access is withdrawn. Server state +//! lives under `/var/lib/agent/ssh`; the login environment uses OpenSSH's +//! standard per-user file in `/home/agent/.ssh`. + +use std::collections::BTreeMap; + +use ::sandbox::{SandboxHandle, SandboxPath, execution::ExecutionSpec}; + +use crate::{Error, ssh::GuestMaterial}; + +use super::{ + files::write_if_changed, + linux::{SYSTEMCTL, SYSTEMD_RUNNING, path_exists, run_checked, systemd_available, wait_for_systemd}, +}; + +/// Directory holding every server file `agentd` writes. +pub(crate) const STATE_DIRECTORY: &str = "/var/lib/agent/ssh"; +/// Host private key; also the unit's `ConditionPathExists`. +pub(crate) const HOST_KEY: &str = "/var/lib/agent/ssh/ssh_host_ed25519_key"; +/// Host public key, kept beside the private key as OpenSSH expects. +pub(crate) const HOST_KEY_PUBLIC: &str = "/var/lib/agent/ssh/ssh_host_ed25519_key.pub"; +/// Keys allowed to log in as the guest user. +pub(crate) const AUTHORIZED_KEYS: &str = "/var/lib/agent/ssh/authorized_keys"; +/// Platform-owned policy passed to every server invocation. +pub(crate) const SERVER_CONFIG: &str = "/var/lib/agent/ssh/sshd_config"; +/// OpenSSH privilege-separation directory, needed even when only evaluating policy. +const SERVER_RUNTIME_DIRECTORY: &str = "/run/sshd"; +/// Directory OpenSSH reads the Agent user's login environment from. +const USER_SSH_DIRECTORY: &str = "/home/agent/.ssh"; +/// Effective Sandbox environment inherited by every new SSH shell or command. +pub(crate) const USER_ENVIRONMENT: &str = "/home/agent/.ssh/environment"; +/// The server executable the image must provide. +pub(crate) const SERVER: &str = "/usr/sbin/sshd"; +/// The platform-owned unit running the server on the guest loopback. +pub(crate) const UNIT: &str = "agent-ssh.service"; +/// Where the platform installs the unit. +pub(crate) const UNIT_FILE: &str = "/etc/systemd/system/agent-ssh.service"; +const ENVIRONMENT_PROGRAM: &str = "/usr/bin/env"; +const MAX_ENVIRONMENT_ENTRIES: usize = 1000; + +/// Confirms the image has the server and systemd runtime needed by the +/// platform-owned configuration and unit. +/// +/// systemd is one init system among several a Sandbox could boot; nothing here +/// assumes it beyond checking for it, and an image without it cannot run the +/// platform-managed unit. +/// +/// # Errors +/// +/// Returns `Error::Invalid` naming the missing piece: the image is immutable for +/// the incarnation, so retrying cannot change that. +pub(crate) async fn verify_server(sandbox: &SandboxHandle) -> Result<(), Error> { + let contract = [ + ("-x", SERVER, "/usr/sbin/sshd is missing"), + ("-x", SYSTEMCTL, "systemctl is missing"), + ( + "-d", + SYSTEMD_RUNNING, + "systemd is not the running init, and the unit needs it", + ), + ]; + for (test, path, what) in contract { + if !path_exists(sandbox, test, path).await? { + return Err(Error::Invalid(crate::ssh::image_contract_missing(what))); + } + } + Ok(()) +} + +/// Writes the host key and `authorized_keys`, then enables and starts the server. +/// +/// Idempotent: unchanged files are not rewritten and a running server is only +/// restarted when its host key, policy or unit changes. +/// +/// # Errors +/// +/// Returns an error when a file cannot be written or a setup command fails. +pub(crate) async fn install_server_state(sandbox: &SandboxHandle, material: &GuestMaterial) -> Result<(), Error> { + wait_for_systemd(sandbox).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0755", + "-o", + "root", + "-g", + "root", + STATE_DIRECTORY, + // systemd normally creates this for the service, but `sshd -T` + // needs it before the service can be validated or started. + SERVER_RUNTIME_DIRECTORY, + ], + ) + .await?; + let host_key_changed = write_if_changed(sandbox, HOST_KEY, &material.host_private_key).await?; + write_if_changed( + sandbox, + HOST_KEY_PUBLIC, + format!("{}\n", material.host_public_key).as_bytes(), + ) + .await?; + let config_changed = write_if_changed(sandbox, SERVER_CONFIG, render_server_config().as_bytes()).await?; + let unit_changed = write_if_changed(sandbox, UNIT_FILE, render_unit().as_bytes()).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/bin/chown", + "root:root", + HOST_KEY, + HOST_KEY_PUBLIC, + SERVER_CONFIG, + UNIT_FILE, + ], + ) + .await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/chmod", "0600", HOST_KEY]).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/bin/chmod", "0644", HOST_KEY_PUBLIC, SERVER_CONFIG, UNIT_FILE], + ) + .await?; + verify_effective_policy(sandbox).await?; + + let environment = capture_login_environment(sandbox).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0700", + "-o", + super::linux::USER, + "-g", + super::linux::USER, + USER_SSH_DIRECTORY, + ], + ) + .await?; + write_if_changed(sandbox, AUTHORIZED_KEYS, material.authorized_keys.as_bytes()).await?; + write_if_changed(sandbox, USER_ENVIRONMENT, &environment).await?; + // StrictModes requires authorized_keys and its directory to be owned by + // root or the user and writable by no one else. + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/bin/chown", "root:root", AUTHORIZED_KEYS, USER_ENVIRONMENT], + ) + .await?; + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/bin/chmod", "0644", AUTHORIZED_KEYS, USER_ENVIRONMENT], + ) + .await?; + // A prior pass can fail after writing the unit but before systemd reloads + // it, so convergence cannot depend only on whether this pass changed it. + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, "daemon-reload"]).await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, "enable", UNIT]).await?; + let action = if host_key_changed || config_changed || unit_changed { + "restart" + } else { + "start" + }; + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, action, UNIT]).await +} + +fn render_server_config() -> String { + format!( + "# Managed by agentd; changes are replaced during reconciliation.\n\ + AddressFamily inet\n\ + ListenAddress 127.0.0.1\n\ + Port {}\n\ + \n\ + HostKey {HOST_KEY}\n\ + AuthorizedKeysFile {AUTHORIZED_KEYS}\n\ + PidFile /run/agent-sshd.pid\n\ + \n\ + AllowUsers {}\n\ + PubkeyAuthentication yes\n\ + PasswordAuthentication no\n\ + KbdInteractiveAuthentication no\n\ + PermitEmptyPasswords no\n\ + PermitRootLogin no\n\ + StrictModes yes\n\ + UsePAM no\n\ + PermitUserEnvironment yes\n\ + \n\ + AllowAgentForwarding no\n\ + AllowTcpForwarding yes\n\ + GatewayPorts no\n\ + X11Forwarding no\n\ + PermitTunnel no\n\ + \n\ + AcceptEnv LANG LC_*\n\ + PrintMotd no\n\ + LogLevel INFO\n\ + Subsystem sftp internal-sftp\n", + crate::ssh::GUEST_PORT, + super::linux::USER + ) +} + +fn render_unit() -> String { + format!( + "[Unit]\n\ + Description=OpenSSH server for Agent access on the guest loopback\n\ + Documentation=https://github.com/Altinn/altinn-studio/tree/main/src/experimental\n\ + ConditionPathExists={HOST_KEY}\n\ + After=network.target\n\ + \n\ + [Service]\n\ + RuntimeDirectory=sshd\n\ + RuntimeDirectoryMode=0755\n\ + ExecStartPre={SERVER} -t -f {SERVER_CONFIG}\n\ + ExecStart={SERVER} -D -e -f {SERVER_CONFIG}\n\ + ExecReload=/bin/kill -HUP $MAINPID\n\ + Restart=on-failure\n\ + RestartPreventExitStatus=255\n\ + \n\ + [Install]\n\ + WantedBy=multi-user.target\n" + ) +} + +/// Asks OpenSSH for the policy it will apply to the Agent's loopback connection. +/// +/// The actual host key is installed before this check because `sshd -T` refuses +/// to evaluate a configuration without at least one readable host key. Login +/// state and the service are installed only after the effective policy passes. +async fn verify_effective_policy(sandbox: &SandboxHandle) -> Result<(), Error> { + let connection = format!( + "user={},host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport={}", + super::linux::USER, + crate::ssh::GUEST_PORT + ); + let args = [ + "-n".to_owned(), + SERVER.to_owned(), + "-T".to_owned(), + "-f".to_owned(), + SERVER_CONFIG.to_owned(), + "-C".to_owned(), + connection, + ]; + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new("/usr/bin/sudo"), args)) + .await?; + if !output.status.success() { + return Err(Error::Invalid(crate::ssh::image_contract_missing(&format!( + "{SERVER} could not evaluate {SERVER_CONFIG}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )))); + } + + let policy = String::from_utf8_lossy(&output.stdout); + for (name, value, directive) in [ + ("permituserenvironment", "yes", "PermitUserEnvironment yes"), + ("usepam", "no", "UsePAM no"), + ] { + let effective = policy.lines().find_map(|line| { + let (key, value) = line.trim().split_once(char::is_whitespace)?; + key.eq_ignore_ascii_case(name).then_some(value.trim()) + }); + if effective != Some(value) { + return Err(Error::Invalid(crate::ssh::image_contract_missing(&format!( + "the platform-owned SSH policy did not effectively set {directive:?}" + )))); + } + } + Ok(()) +} + +/// Stops and disables the server and removes its state, when any exists. +/// +/// The state is removed only after the server is confirmed stopped, so a +/// failed stop is retried on the next pass instead of leaving a running server +/// behind an empty directory. Without systemd as the running init nothing +/// could have started the unit, so only the files are removed. +/// +/// # Errors +/// +/// Returns an error when the state cannot be inspected, the server cannot be +/// stopped, or the state cannot be removed. +pub(crate) async fn remove_server_state(sandbox: &SandboxHandle) -> Result<(), Error> { + let state_exists = path_exists(sandbox, "-e", STATE_DIRECTORY).await?; + let environment_exists = path_exists(sandbox, "-e", USER_ENVIRONMENT).await?; + if !state_exists && !environment_exists { + return Ok(()); + } + if state_exists && systemd_available(sandbox).await? { + wait_for_systemd(sandbox).await?; + let args = ["-n", SYSTEMCTL, "disable", "--now", UNIT]; + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/sudo"), + args.map(str::to_owned), + )) + .await?; + // A failed setup may not have loaded the unit yet; every other failure + // means the server may still be running. + if !output.status.success() && !unit_is_missing(&output) { + return Err(Error::SandboxSetup(format!( + "command `/usr/bin/sudo {}` exited with code {}: {}", + args.join(" "), + output.status.code, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + } + if state_exists { + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/rm", "-f", UNIT_FILE]).await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/rm", "-rf", STATE_DIRECTORY]).await?; + if systemd_available(sandbox).await? { + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, "daemon-reload"]).await?; + } + } + if environment_exists { + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/rm", "-f", USER_ENVIRONMENT]).await?; + } + Ok(()) +} + +/// Captures the environment an ordinary Sandbox Execution inherits and renders +/// it in OpenSSH's `~/.ssh/environment` format. SSH supplies identity and +/// terminal variables itself; platform defaults fill the only values added by +/// `agentctl exec` and Session launch rather than the Sandbox runtime. +async fn capture_login_environment(sandbox: &SandboxHandle) -> Result, Error> { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new(ENVIRONMENT_PROGRAM), + ["-0".into()], + )) + .await?; + if !output.status.success() { + return Err(Error::SandboxSetup(format!( + "command `{ENVIRONMENT_PROGRAM} -0` exited with code {}: {}", + output.status.code, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + let mut environment = parse_environment(&output.stdout)?; + environment.insert("LANG".into(), super::linux::UTF8_LOCALE.into()); + environment.insert("CONTAINER_HOST".into(), super::linux::CONTAINER_HOST.into()); + render_environment(&environment) +} + +fn parse_environment(bytes: &[u8]) -> Result, Error> { + let mut environment = BTreeMap::new(); + for entry in bytes.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) { + let text = std::str::from_utf8(entry) + .map_err(|_| Error::Invalid("the Sandbox environment contains a non-UTF-8 value".into()))?; + let (name, value) = text + .split_once('=') + .ok_or_else(|| Error::Invalid(format!("the Sandbox environment contains an invalid entry {text:?}")))?; + if !portable_name(name) { + return Err(Error::Invalid(format!( + "the Sandbox environment contains an invalid variable name {name:?}" + ))); + } + if !ssh_supplies(name) { + environment.insert(name.into(), value.into()); + } + } + Ok(environment) +} + +fn render_environment(environment: &BTreeMap) -> Result, Error> { + if environment.len() > MAX_ENVIRONMENT_ENTRIES { + return Err(Error::Invalid(format!( + "the Sandbox environment has {} entries; OpenSSH accepts at most {MAX_ENVIRONMENT_ENTRIES}", + environment.len() + ))); + } + let mut rendered = String::new(); + for (name, value) in environment { + if value.contains(['\n', '\r']) { + return Err(Error::Invalid(format!( + "Sandbox environment variable {name:?} contains a line break that OpenSSH cannot represent" + ))); + } + rendered.push_str(name); + rendered.push('='); + rendered.push_str(value); + rendered.push('\n'); + } + Ok(rendered.into_bytes()) +} + +fn portable_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +fn ssh_supplies(name: &str) -> bool { + matches!( + name, + "HOME" | "LOGNAME" | "PWD" | "SHELL" | "SHLVL" | "TERM" | "USER" | "_" + ) || name.starts_with("SSH_") + || name.starts_with("AGENT_SESSION_") +} + +/// Recognizes systemd's report that a unit file does not exist. +fn unit_is_missing(output: &::sandbox::execution::ExecutionOutput) -> bool { + let stderr = String::from_utf8_lossy(&output.stderr); + stderr.contains("does not exist") || stderr.contains("not found") || stderr.contains("No such file") +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use std::collections::BTreeMap; + + fn directives(text: &str) -> BTreeMap<&str, Vec<&str>> { + let mut directives = BTreeMap::<&str, Vec<&str>>::new(); + for line in text.lines().map(str::trim) { + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once(char::is_whitespace) else { + continue; + }; + directives.entry(key).or_default().push(value.trim()); + } + directives + } + + #[test] + fn platform_owned_server_policy_is_complete_and_not_extensible() { + let config = super::render_server_config(); + let directives = directives(&config); + let single = |key: &str| { + let values = directives.get(key).unwrap_or_else(|| panic!("{key} is set")); + assert_eq!(values.len(), 1, "{key} is set once"); + values[0] + }; + + assert_eq!(single("ListenAddress"), "127.0.0.1"); + assert_eq!(single("Port"), crate::ssh::GUEST_PORT.to_string()); + assert_eq!(single("HostKey"), super::HOST_KEY); + assert_eq!(single("AuthorizedKeysFile"), super::AUTHORIZED_KEYS); + assert_eq!(single("AllowUsers"), super::super::linux::USER); + assert_eq!(single("PubkeyAuthentication"), "yes"); + assert_eq!(single("PasswordAuthentication"), "no"); + assert_eq!(single("KbdInteractiveAuthentication"), "no"); + assert_eq!(single("PermitEmptyPasswords"), "no"); + assert_eq!(single("PermitRootLogin"), "no"); + assert_eq!(single("UsePAM"), "no"); + assert_eq!(single("PermitUserEnvironment"), "yes"); + assert_eq!(single("AllowAgentForwarding"), "no"); + assert_eq!(single("AllowTcpForwarding"), "yes"); + assert_eq!(single("GatewayPorts"), "no"); + assert_eq!(single("X11Forwarding"), "no"); + assert_eq!(single("PermitTunnel"), "no"); + assert_eq!(single("Subsystem"), "sftp internal-sftp"); + assert!(!directives.contains_key("Include")); + } + + #[test] + fn platform_owned_unit_runs_only_the_platform_policy() { + let unit = super::render_unit(); + assert!(unit.contains(&format!("ConditionPathExists={}", super::HOST_KEY))); + assert!(unit.contains(&format!( + "ExecStart={} -D -e -f {}", + super::SERVER, + super::SERVER_CONFIG + ))); + assert!(unit.contains("RuntimeDirectory=sshd")); + assert!(unit.contains("WantedBy=multi-user.target")); + } + + #[test] + fn ssh_environment_preserves_values_and_excludes_process_local_state() { + let parsed = super::parse_environment( + b"PATH=/usr/local/bin:/usr/bin\0GIT_USER_NAME=Agent #1 \"reviewer\"\0EMPTY=\0TERM=dumb\0HOME=/image-home\0AGENT_SESSION_ID=session\0", + ) + .expect("environment"); + assert_eq!( + parsed, + BTreeMap::from([ + ("EMPTY".into(), String::new()), + ("GIT_USER_NAME".into(), "Agent #1 \"reviewer\"".into()), + ("PATH".into(), "/usr/local/bin:/usr/bin".into()), + ]) + ); + assert_eq!( + super::render_environment(&parsed).expect("rendered"), + b"EMPTY=\nGIT_USER_NAME=Agent #1 \"reviewer\"\nPATH=/usr/local/bin:/usr/bin\n" + ); + } + + #[test] + fn ssh_environment_rejects_values_openssh_cannot_represent() { + let environment = BTreeMap::from([("MULTILINE".into(), "one\ntwo".into())]); + let error = super::render_environment(&environment).expect_err("line break"); + assert!(error.to_string().contains("line break"), "{error}"); + } +} diff --git a/agentctl/src/sandbox/platform/linux_vnc.rs b/agentctl/src/sandbox/platform/linux_vnc.rs new file mode 100644 index 0000000..0c41be3 --- /dev/null +++ b/agentctl/src/sandbox/platform/linux_vnc.rs @@ -0,0 +1,349 @@ +//! VNC access granted and withdrawn in Linux Sandboxes: the units the image lists in +//! `/etc/agent-access.d/vnc.conf` are enabled or disabled, then the result is checked. A grant +//! waits for the declared ports to listen; a withdrawal checks that the units are inactive. The +//! ports themselves are not checked on withdrawal, because the Agent may use them. + +use ::sandbox::{SandboxHandle, SandboxPath, execution::ExecutionSpec}; + +use crate::Error; + +use super::linux::{SYSTEMCTL, SYSTEMD_RUNNING, path_exists, run_checked, systemd_available, wait_for_systemd}; + +/// The image contract: what this image provides for `access: [{type: vnc}]`. +pub(crate) const DESCRIPTOR: &str = "/etc/agent-access.d/vnc.conf"; +/// Reads listening sockets, so a grant can be asserted rather than assumed. +const SS: &str = "/usr/bin/ss"; +/// A descriptor larger than this is not the one the contract describes. +const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024; +/// More units than any one access capability has any business owning. +const MAX_UNITS: usize = 8; +/// How long an enabled unit may take to start listening. A viewer is an ordinary service that +/// systemd reports started once it has forked, before it has bound its port. +const LISTEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const LISTEN_POLL: std::time::Duration = std::time::Duration::from_millis(100); + +/// What the image declares it provides. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct Capability { + /// Units the platform enables to grant access and disables to withdraw it. + pub(crate) units: Vec, + /// Guest loopback port carrying the RFB stream. + pub(crate) port: u16, + /// Guest loopback port serving the browser viewer; an image may offer the RFB port alone. + pub(crate) web_port: Option, +} + +impl Capability { + /// The ports the image promised, which are what a grant is checked against. + fn ports(&self) -> Vec { + std::iter::once(self.port).chain(self.web_port).collect() + } +} + +/// Confirms the image declares VNC access and returns what it declares. A missing piece is +/// `Error::Invalid`, since retrying cannot change the image. +async fn verify_capability(sandbox: &SandboxHandle) -> Result { + let contract = [ + ("-x", SYSTEMCTL, "systemctl is missing"), + ( + "-d", + SYSTEMD_RUNNING, + "systemd is not the running init, and the access units need it", + ), + ("-x", SS, "ss is missing, so a grant could not be verified"), + ("-f", DESCRIPTOR, "/etc/agent-access.d/vnc.conf is missing"), + ]; + for (test, path, what) in contract { + if !path_exists(sandbox, test, path).await? { + return Err(Error::Invalid(crate::vnc::image_contract_missing(what))); + } + } + parse_descriptor(&read_descriptor(sandbox).await?) +} + +/// Checks the image, enables its access units and waits for the declared ports to listen. +/// Idempotent: `enable --now` leaves a running unit alone. +/// +/// # Errors +/// +/// Returns an error when the image declares no VNC access, a unit cannot be enabled, or a +/// declared port is not listening within [`LISTEN_TIMEOUT`]. +pub(crate) async fn grant(sandbox: &SandboxHandle) -> Result { + let capability = verify_capability(sandbox).await?; + systemctl(sandbox, "enable", &capability).await?; + let deadline = tokio::time::Instant::now() + LISTEN_TIMEOUT; + for port in capability.ports() { + while !port_is_listening(sandbox, port).await? { + if tokio::time::Instant::now() >= deadline { + return Err(Error::SandboxSetup(format!( + "the image's VNC access units were enabled but nothing is listening on guest port {port} \ + after {}s", + LISTEN_TIMEOUT.as_secs() + ))); + } + tokio::time::sleep(LISTEN_POLL).await; + } + } + Ok(capability) +} + +/// Disables the image's access units, then checks that systemd reports them inactive. +/// +/// # Errors +/// +/// Returns an error when the units cannot be disabled, or when one is still active afterwards. +pub(crate) async fn withdraw(sandbox: &SandboxHandle) -> Result<(), Error> { + if !systemd_available(sandbox).await? || !path_exists(sandbox, "-f", DESCRIPTOR).await? { + // An image that declares no VNC access never had any units to turn off. + return Ok(()); + } + let capability = parse_descriptor(&read_descriptor(sandbox).await?)?; + systemctl(sandbox, "disable", &capability).await?; + let still_active = active_units(sandbox, &capability).await?; + if still_active.is_empty() { + return Ok(()); + } + Err(Error::SandboxSetup(format!( + "VNC access was withdrawn but {} still active", + still_active.join(", ") + ))) +} + +/// Returns the image's access units that systemd does not report inactive. +async fn active_units(sandbox: &SandboxHandle, capability: &Capability) -> Result, Error> { + let arguments = ["-n", SYSTEMCTL, "is-active"] + .into_iter() + .chain(capability.units.iter().map(String::as_str)) + .map(ToOwned::to_owned) + .collect::>(); + // `is-active` exits non-zero whenever a unit is not active, so its exit status says nothing + // here; the one state line it prints per unit, in argument order, is the answer. + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new("/usr/bin/sudo"), arguments)) + .await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let states: Vec<&str> = stdout.lines().map(str::trim).collect(); + if states.len() != capability.units.len() { + return Err(Error::SandboxSetup(format!( + "`systemctl is-active` reported {} states for {} units: {}", + states.len(), + capability.units.len(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(capability + .units + .iter() + .zip(states) + .filter(|(_, state)| !matches!(*state, "inactive" | "failed")) + .map(|(unit, state)| format!("{unit} is {state}")) + .collect()) +} + +/// Runs `systemctl --now` on the image's access units once systemd has booted. +async fn systemctl(sandbox: &SandboxHandle, action: &str, capability: &Capability) -> Result<(), Error> { + wait_for_systemd(sandbox).await?; + let arguments = ["-n", SYSTEMCTL, action, "--now"] + .into_iter() + .chain(capability.units.iter().map(String::as_str)); + run_checked(sandbox, "/usr/bin/sudo", arguments).await +} + +async fn read_descriptor(sandbox: &SandboxHandle) -> Result { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/bin/cat"), + [DESCRIPTOR.to_owned()], + )) + .await?; + if !output.status.success() { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} could not be read: {}", + String::from_utf8_lossy(&output.stderr).trim() + )))); + } + if output.stdout.len() > MAX_DESCRIPTOR_BYTES { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} is larger than {MAX_DESCRIPTOR_BYTES} bytes" + )))); + } + String::from_utf8(output.stdout.to_vec()).map_err(|_| { + Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} is not UTF-8" + ))) + }) +} + +fn setting<'a>(descriptor: &'a str, key: &str) -> Option<&'a str> { + descriptor + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#')) + .find_map(|line| { + let (name, value) = line.split_once('=')?; + (name.trim() == key).then(|| value.trim()) + }) +} + +fn missing(key: &str) -> Error { + Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} does not set {key}" + ))) +} + +/// Parses the descriptor, refusing unit names that are not plain units, since they reach +/// `systemctl` as arguments, and ports other than the agreed ones. +fn parse_descriptor(descriptor: &str) -> Result { + let units: Vec = setting(descriptor, "units") + .ok_or_else(|| missing("units"))? + .split_whitespace() + .map(ToOwned::to_owned) + .collect(); + if units.is_empty() || units.len() > MAX_UNITS { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} must declare between 1 and {MAX_UNITS} units" + )))); + } + for unit in &units { + if !valid_unit_name(unit) { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{unit:?} is not a systemd socket or service unit name" + )))); + } + } + let port = declared_port(descriptor, "port", crate::vnc::GUEST_PORT)?.ok_or_else(|| missing("port"))?; + let web_port = declared_port(descriptor, "web-port", crate::vnc::WEB_GUEST_PORT)?; + Ok(Capability { units, port, web_port }) +} + +/// Reads a declared port, which must be the one both sides agree on when it is declared at all. +fn declared_port(descriptor: &str, key: &str, expected: u16) -> Result, Error> { + let Some(value) = setting(descriptor, key) else { + return Ok(None); + }; + let parsed: u16 = value.parse().map_err(|_| { + Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} sets {key} to {value:?}, which is not a port" + ))) + })?; + if parsed != expected { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} sets {key} to {parsed}, but VNC access uses guest port {expected}" + )))); + } + Ok(Some(parsed)) +} + +fn valid_unit_name(unit: &str) -> bool { + let Some(stem) = unit.strip_suffix(".socket").or_else(|| unit.strip_suffix(".service")) else { + return false; + }; + // A leading `-` would reach `systemctl` as an option rather than a unit. + !stem.is_empty() + && !stem.starts_with('-') + && stem.len() <= 200 + && stem + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '@')) +} + +async fn port_is_listening(sandbox: &SandboxHandle, port: u16) -> Result { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new(SS), + [ + "-ltnH".to_owned(), + "sport".to_owned(), + "=".to_owned(), + format!(":{port}"), + ], + )) + .await?; + if !output.status.success() { + return Err(Error::SandboxSetup(format!( + "`ss -ltnH sport = :{port}` exited with code {}: {}", + output.status.code, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::{Capability, parse_descriptor, valid_unit_name}; + + const VALID: &str = "# a comment\nunits=agent-vnc.socket agent-vnc-web.service\nport=5900\nweb-port=6080\n"; + + #[test] + fn the_units_and_ports_come_from_the_image_descriptor() { + assert_eq!( + parse_descriptor(VALID).expect("descriptor"), + Capability { + units: vec!["agent-vnc.socket".to_owned(), "agent-vnc-web.service".to_owned()], + port: 5900, + web_port: Some(6080), + } + ); + } + + #[test] + fn an_image_may_offer_the_rfb_port_without_a_browser_viewer() { + let capability = parse_descriptor("units=agent-vnc.socket\nport=5900\n").expect("descriptor"); + assert_eq!(capability.web_port, None); + assert_eq!(capability.ports(), vec![5900], "only the declared port is promised"); + } + + #[test] + fn a_descriptor_that_could_not_be_acted_on_is_refused() { + for (descriptor, expected) in [ + ("port=5900\nweb-port=6080\n", "does not set units"), + ("units=agent-vnc.socket\nweb-port=6080\n", "does not set port"), + ( + "units=\nport=5900\nweb-port=6080\n", + "must declare between 1 and 8 units", + ), + ( + "units=agent-vnc.socket --now other\nport=5900\nweb-port=6080\n", + "is not a systemd socket or service unit name", + ), + ( + "units=agent-vnc.socket\nport=5901\nweb-port=6080\n", + "but VNC access uses guest port 5900", + ), + ( + "units=agent-vnc.socket\nport=nope\nweb-port=6080\n", + "which is not a port", + ), + ( + "# units=agent-vnc.socket\nport=5900\nweb-port=6080\n", + "does not set units", + ), + ] { + let error = parse_descriptor(descriptor).expect_err("refused"); + assert!(error.to_string().contains(expected), "{descriptor:?} -> {error}"); + } + } + + #[test] + fn unit_names_that_could_become_systemctl_options_are_rejected() { + assert!(valid_unit_name("agent-vnc.socket")); + assert!(valid_unit_name("agent-vnc-web.service")); + assert!(valid_unit_name("getty@tty1.service")); + for rejected in [ + "--now", + "--now.service", + "-H.socket", + "-f", + "agent-vnc", + "agent-vnc.timer", + ".socket", + "a b.service", + "../x.service", + ] { + assert!(!valid_unit_name(rejected), "accepted {rejected:?}"); + } + } +} diff --git a/agentctl/src/sandbox/platform/mod.rs b/agentctl/src/sandbox/platform/mod.rs new file mode 100644 index 0000000..b372f91 --- /dev/null +++ b/agentctl/src/sandbox/platform/mod.rs @@ -0,0 +1,28 @@ +//! Sandbox operating-system-specific adapters. + +pub(crate) mod files; +mod linux; +pub(crate) mod linux_ssh; +pub(crate) mod linux_vnc; + +use ::sandbox::execution::ExecutionSpec; + +use crate::Error; + +pub use linux::Linux; +pub(crate) use linux::{CONTAINER_HOST, HOME, PORTABLE_TERMINAL, USER, UTF8_LOCALE, WORKING_DIRECTORY, run_checked}; + +/// Builds the Agent-conventional Execution environment for one Sandbox OS. +/// +/// # Errors +/// +/// Returns an error when the command is empty or the Sandbox operating system +/// has no Agent execution adapter. +pub fn execution_spec(os: &str, command: &[String], terminal: bool) -> Result { + match os { + "linux" => linux::execution_spec(command, terminal), + os => Err(Error::Invalid(format!( + "command execution is not supported on Sandbox operating system {os:?}" + ))), + } +} diff --git a/agentctl/src/sandbox/responsiveness.rs b/agentctl/src/sandbox/responsiveness.rs new file mode 100644 index 0000000..217eec2 --- /dev/null +++ b/agentctl/src/sandbox/responsiveness.rs @@ -0,0 +1,193 @@ +//! Whether each running Sandbox's guest still makes progress. +//! +//! A Sandbox whose guest has stalled keeps its VM process, so its lifecycle +//! state stays running while every Execution into it waits forever. The +//! Backend reports the guest's heartbeat without a round trip to the guest; +//! this tracker records when each heartbeat last changed on the host clock and +//! calls a guest stalled once it has not changed for [`UNRESPONSIVE_AFTER`]. +//! Guest-written times are never compared: the guest clock falls behind the +//! host's while the guest is stalled. + +use std::{cell::RefCell, collections::HashMap, time::Duration}; + +use ::sandbox::{GuestHeartbeat, Sandbox, SandboxId, SandboxState}; +use tokio::time::Instant; + +/// How long a running guest's heartbeat may stay unchanged before the guest +/// counts as stalled. The guest agent beats about once a second, also while +/// its vCPUs are saturated. +pub const UNRESPONSIVE_AFTER: Duration = Duration::from_secs(15); + +/// How often guest-touching work inspects its Sandbox's heartbeat. +pub const OBSERVATION_INTERVAL: Duration = Duration::from_secs(2); + +/// Describes a stalled guest for conditions and errors. +#[must_use] +pub fn stall_detail() -> String { + format!( + "the guest has not reported progress for more than {}s; stopping and starting the Agent restarts it", + UNRESPONSIVE_AFTER.as_secs() + ) +} + +/// The error that ends guest-touching work once its guest has stalled. +#[must_use] +pub fn stalled() -> crate::Error { + crate::Error::SandboxUnresponsive(stall_detail()) +} + +/// When each running Sandbox's heartbeat last changed, keyed by Sandbox. +#[derive(Default)] +pub struct Tracker { + sandboxes: RefCell>, +} + +struct Tracked { + heartbeat: GuestHeartbeat, + /// When `heartbeat` was first observed. + since: Instant, +} + +impl Tracker { + /// Records one inspection of a Sandbox. A Sandbox that is not running, or + /// reports no heartbeat, has no evidence and is forgotten. + pub fn observe(&self, sandbox: &Sandbox, now: Instant) { + let mut sandboxes = self.sandboxes.borrow_mut(); + let Some(heartbeat) = sandbox + .guest_heartbeat + .filter(|_| sandbox.state == SandboxState::Running) + else { + sandboxes.remove(&sandbox.id); + return; + }; + if sandboxes + .get(&sandbox.id) + .is_none_or(|tracked| tracked.heartbeat != heartbeat) + { + sandboxes.insert(sandbox.id.clone(), Tracked { heartbeat, since: now }); + } + } + + /// The Sandbox's last observed heartbeat, if it reports one. + #[must_use] + pub fn heartbeat(&self, id: &SandboxId) -> Option { + self.sandboxes.borrow().get(id).map(|tracked| tracked.heartbeat) + } + + /// Whether the Sandbox's heartbeat has not changed for [`UNRESPONSIVE_AFTER`]. + /// Only a changed heartbeat clears a stall, so a pause in observation can + /// delay, but never hide, one. + #[must_use] + pub fn stalled(&self, id: &SandboxId, now: Instant) -> bool { + self.sandboxes + .borrow() + .get(id) + .is_some_and(|tracked| now.saturating_duration_since(tracked.since) >= UNRESPONSIVE_AFTER) + } + + /// Forgets a released Sandbox. + pub fn forget(&self, id: &SandboxId) { + self.sandboxes.borrow_mut().remove(id); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::collections::BTreeMap; + + use ::sandbox::{ + ByteQuantity, CpuQuantity, GuestHeartbeat, Hostname, Platform, RootFilesystem, Sandbox, SandboxName, + SandboxResources, SandboxState, image, init::InitSystem, + }; + use tokio::time::{Duration, Instant}; + + use super::{Tracker, UNRESPONSIVE_AFTER}; + + fn sandbox(state: SandboxState, heartbeat: Option) -> Sandbox { + Sandbox { + image: image::ResolvedImage { + source: image::ImageSource::Reference { + reference: "example.test/agent:latest".into(), + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: "sha256:1234".into(), + }, + id: "00000000-0000-4000-8000-000000000001".parse().expect("test Sandbox ID"), + name: SandboxName::new("worker").expect("test Sandbox name"), + hostname: Hostname::new("worker").expect("test hostname"), + resources: SandboxResources::new( + "1".parse::().expect("test CPU"), + "512Mi".parse::().expect("test memory"), + RootFilesystem::layered("4Gi".parse::().expect("test root filesystem")), + ), + init_system: InitSystem::Backend, + state, + guest_heartbeat: heartbeat.map(GuestHeartbeat::new), + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + } + } + + fn running(heartbeat: u64) -> Sandbox { + sandbox(SandboxState::Running, Some(heartbeat)) + } + + #[test] + fn a_heartbeat_that_stops_advancing_makes_the_guest_stalled() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(1).id; + + tracker.observe(&running(1), start); + tracker.observe(&running(2), start + Duration::from_secs(1)); + let stalled = start + Duration::from_secs(1) + UNRESPONSIVE_AFTER; + tracker.observe(&running(2), stalled - Duration::from_millis(1)); + assert!(!tracker.stalled(&id, stalled - Duration::from_millis(1))); + assert!(tracker.stalled(&id, stalled)); + + tracker.observe(&running(3), stalled + Duration::from_secs(1)); + assert!(!tracker.stalled(&id, stalled + Duration::from_secs(1))); + } + + #[test] + fn a_guest_first_observed_stalled_becomes_stalled() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(48).id; + + tracker.observe(&running(48), start); + tracker.observe(&running(48), start + UNRESPONSIVE_AFTER); + + assert!(tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + } + + #[test] + fn a_restarted_guest_starts_over_and_its_reset_sequence_counts_as_progress() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(1).id; + tracker.observe(&running(40), start); + assert!(tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + + // The runtime removes the heartbeat before every boot, and the new + // boot counts from the start again. + tracker.observe(&sandbox(SandboxState::Running, None), start + UNRESPONSIVE_AFTER); + assert!(!tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + tracker.observe(&running(1), start + UNRESPONSIVE_AFTER * 2); + assert!(!tracker.stalled(&id, start + UNRESPONSIVE_AFTER * 2)); + } + + #[test] + fn a_stopped_sandbox_has_no_evidence_even_with_a_recorded_heartbeat() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(1).id; + tracker.observe(&running(1), start); + + tracker.observe(&sandbox(SandboxState::Stopped, Some(1)), start + UNRESPONSIVE_AFTER); + + assert!(!tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + } +} diff --git a/agentctl/src/sessions/activity.rs b/agentctl/src/sessions/activity.rs new file mode 100644 index 0000000..0bc560a --- /dev/null +++ b/agentctl/src/sessions/activity.rs @@ -0,0 +1,155 @@ +//! Harness activity, folded from the reports a running harness makes about itself. + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +/// Coarse phase of the harness's current work, derived from activity events. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Phase { + /// No activity event has been observed yet. + #[default] + Unknown, + /// The harness is mid-turn: a prompt was submitted or a tool is running. + Working, + /// The harness finished a turn or is blocked on the operator. + WaitingForInput, +} + +/// Harness activity folded from the harness's reports. +/// +/// Shaped after the Agent Host Protocol's per-chat `activity`/`status` so a +/// later AHP runtime can populate it by field mapping. Owned by the +/// authenticated report handler; see [`super::Reported`]. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Activity { + /// Current coarse work phase. + #[serde(default)] + pub phase: Phase, + /// Reported turn endings (including interruption) observed for the running harness launch. + #[serde(default)] + pub turns: u64, + /// Time of the most recent activity event, when one has been observed. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub last_event_at: Option, + /// When `phase` last changed. A repeated signal of the same phase, such as + /// an idle notification while waiting for input, does not move it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub phase_since: Option, +} + +/// One activity signal a harness reports, before it is folded into [`Activity`]. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ActivityEvent { + /// The harness process started (also carries the native session ID). + SessionStart, + /// The operator submitted a prompt; a turn began. + TurnStarted, + /// The harness ended a turn and is awaiting input; this does not imply success. + TurnCompleted, + /// The harness is blocked awaiting an operator decision. + WaitingForInput, +} + +impl Activity { + /// Folds one activity event into the accumulated activity, at `at`. + #[must_use] + pub fn folded(mut self, event: ActivityEvent, at: OffsetDateTime) -> Self { + let previous = self.phase; + match event { + // A delayed or duplicate start report must not move a Session back + // from a later activity state. + ActivityEvent::SessionStart if !matches!(self.phase, Phase::Unknown) => return self, + ActivityEvent::SessionStart | ActivityEvent::TurnStarted => { + self.phase = Phase::Working; + } + ActivityEvent::TurnCompleted => { + self.phase = Phase::WaitingForInput; + self.turns = self.turns.saturating_add(1); + } + ActivityEvent::WaitingForInput => self.phase = Phase::WaitingForInput, + } + if self.phase != previous || self.phase_since.is_none() { + self.phase_since = Some(at); + } + self.last_event_at = Some(at); + self + } +} + +#[cfg(test)] +mod tests { + use time::OffsetDateTime; + + use super::{Activity, ActivityEvent, Phase}; + + fn at(seconds: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp") + } + + #[test] + fn the_phase_keeps_its_start_time_through_repeated_signals() { + let at = |seconds| OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + let waiting = Activity::default() + .folded(ActivityEvent::TurnStarted, at(1)) + .folded(ActivityEvent::TurnCompleted, at(10)); + assert_eq!(waiting.phase_since, Some(at(10))); + let notified = waiting.folded(ActivityEvent::WaitingForInput, at(70)); + assert_eq!( + notified.phase_since, + Some(at(10)), + "an idle notification does not restart the wait" + ); + assert_eq!(notified.last_event_at, Some(at(70))); + assert_eq!( + notified.folded(ActivityEvent::TurnStarted, at(80)).phase_since, + Some(at(80)) + ); + } + + #[test] + fn folds_events_into_phase_and_turn_count() { + let cases: &[(ActivityEvent, Phase, u64)] = &[ + (ActivityEvent::SessionStart, Phase::Working, 0), + (ActivityEvent::TurnStarted, Phase::Working, 0), + (ActivityEvent::WaitingForInput, Phase::WaitingForInput, 0), + (ActivityEvent::TurnCompleted, Phase::WaitingForInput, 1), + (ActivityEvent::TurnStarted, Phase::Working, 1), + (ActivityEvent::TurnCompleted, Phase::WaitingForInput, 2), + ]; + let mut activity = Activity::default(); + for (index, (event, phase, turns)) in cases.iter().enumerate() { + let now = at(i64::try_from(index).expect("index") + 1); + activity = activity.folded(*event, now); + assert_eq!(activity.phase, *phase, "phase after {event:?}"); + assert_eq!(activity.turns, *turns, "turns after {event:?}"); + assert_eq!(activity.last_event_at, Some(now), "timestamp after {event:?}"); + } + } + + #[test] + fn turn_count_saturates() { + let activity = Activity { + turns: u64::MAX, + ..Activity::default() + }; + assert_eq!(activity.folded(ActivityEvent::TurnCompleted, at(1)).turns, u64::MAX); + } + + #[test] + fn a_late_start_does_not_regress_waiting_activity() { + let waiting = Activity::default().folded(ActivityEvent::WaitingForInput, at(1)); + + assert_eq!(waiting.clone().folded(ActivityEvent::SessionStart, at(2)), waiting); + } +} diff --git a/agentctl/src/sessions/controller.rs b/agentctl/src/sessions/controller.rs new file mode 100644 index 0000000..0245487 --- /dev/null +++ b/agentctl/src/sessions/controller.rs @@ -0,0 +1,101 @@ +//! Session specialization of the generic keyed reconciliation controller. + +use std::{rc::Rc, time::Duration}; + +use crate::{Error, controller}; + +use super::{SessionId, SharedStore}; + +/// Observes recoverable Session reconciliation errors without stopping the controller. +pub type ErrorHandler = controller::ErrorHandler; + +/// A handle for requesting immediate Session convergence. +pub type Wakeup = controller::Wakeup; + +struct Source(SharedStore); + +impl controller::Source for Source { + fn list_keys(&self) -> ::sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.0 + .list_all_sessions() + .await + .map(|sessions| sessions.into_iter().map(|session| session.id).collect()) + }) + } +} + +/// Generic keyed reconciliation specialized for durable Sessions. +pub struct Controller(controller::Controller); + +/// Wakes Sessions affected by an Agent readiness or Sandbox transition. +pub struct AgentNotifier { + store: SharedStore, + wakeup: Wakeup, + on_error: Rc, +} + +impl AgentNotifier { + /// Creates a notifier over durable Sessions and their controller. + #[must_use] + pub fn new(store: SharedStore, wakeup: Wakeup, on_error: Rc) -> Self { + Self { + store, + wakeup, + on_error, + } + } +} + +impl crate::control_plane::SessionNotifier for AgentNotifier { + fn notify(&self, id: crate::AgentId) { + let store = self.store.clone(); + let wakeup = self.wakeup.clone(); + let on_error = self.on_error.clone(); + tokio::task::spawn_local(async move { + match store.list_all_sessions().await { + Ok(sessions) => { + for session in sessions.into_iter().filter(|session| session.agent_id == id) { + wakeup.notify(session.id); + } + } + Err(error) => on_error(&error), + } + }); + } + + fn settle(&self, id: crate::AgentId) -> ::sandbox::LocalFuture<'_, ()> { + Box::pin(async move { + let sessions = match self.store.list_all_sessions().await { + Ok(sessions) => sessions, + Err(error) => return (self.on_error)(&error), + }; + let passes = sessions + .into_iter() + .filter(|session| session.agent_id == id) + .map(|session| self.wakeup.reconcile(session.id)); + // A failed pass is reported by the controller and retried; it still ends the wait. + futures_util::future::join_all(passes).await; + }) + } +} + +impl Controller { + /// Creates a Session controller and its independently shareable wake-up handle. + #[must_use] + pub fn new( + store: SharedStore, + reconciler: Rc>, + interval: Duration, + on_error: ErrorHandler, + ) -> (Self, Wakeup) { + let (controller, wakeup) = + controller::Controller::new(Rc::new(Source(store)), reconciler, interval, "Session", on_error); + (Self(controller), wakeup) + } + + /// Reconciles existing Sessions immediately and then continuously. + pub async fn run(self) { + self.0.run().await; + } +} diff --git a/agentctl/src/sessions/mod.rs b/agentctl/src/sessions/mod.rs new file mode 100644 index 0000000..7f9afa1 --- /dev/null +++ b/agentctl/src/sessions/mod.rs @@ -0,0 +1,866 @@ +//! Durable, runtime-driven Sessions owned by the Agent daemon. + +mod activity; +mod controller; +mod reconciler; +mod runtime; +mod sandboxes; +mod service; +mod transcript; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{AgentId, Error, Harness, ModelSelection, sandbox}; + +pub use crate::controller::Reconcile; +pub use activity::{Activity, ActivityEvent, Phase}; +pub use controller::{AgentNotifier, Controller, ErrorHandler, Wakeup}; +pub use reconciler::Reconciler; +pub use runtime::{Observation, SessionRuntime, Tmux}; +pub use sandboxes::AgentSandboxes; +pub use service::{Service, UpgradeReadiness}; +pub use transcript::{Message, Part, Role, Turn}; + +/// Immutable identity of one Session incarnation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SessionId(Uuid); + +impl SessionId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } +} + +impl std::fmt::Display for SessionId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for SessionId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Validated persistent name of one Session. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct SessionName(String); + +impl SessionName { + /// Creates a validated Session name. + /// + /// # Errors + /// + /// Returns an error unless the name is 1–64 portable ASCII characters. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(Error::Invalid( + "Session name must be 1-64 ASCII letters, digits, '-' or '_'".into(), + )); + } + Ok(Self(value)) + } + + /// Returns the name as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for SessionName { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: SessionName) -> Self { + value.0 + } +} + +impl std::fmt::Display for SessionName { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Lifecycle state observed by the Session reconciler: whether the harness +/// process is meant to be, and is, running. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LifecycleState { + /// The Session has not yet reached a running harness. + #[default] + Starting, + /// A resumed harness is running but has not reached its input prompt. + Resuming, + /// The harness process is running in its Sandbox. + Running, + /// The harness was deliberately stopped after inactivity. + Idle, + /// The harness is stopped and stays stopped until the Session is unarchived. + Archived, + /// Reconciliation most recently failed. + Failed, +} + +/// The one Session state operators and orchestrators read. +/// +/// Derived from the reconciler's lifecycle and the harness's reports; the two +/// halves it is computed from stay available for diagnosis. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum State { + /// The current launch has not reported in yet: not launched, or booting. + #[default] + Starting, + /// The harness is mid-turn. + Working, + /// The harness is idle at its prompt or blocked on the operator. + WaitingForInput, + /// The harness was deliberately stopped after inactivity. + Idle, + /// The Session was archived, but its harness has not stopped yet: a turn + /// in progress is finishing, or stopping it failed and is retried. + Archiving, + /// The Session was archived: its harness is stopped until it is unarchived. + Archived, + /// Reconciliation most recently failed. + Failed, +} + +/// Most recently observed Session state. +/// +/// Two writers own two halves: the lifecycle reconciler writes [`Lifecycle`] +/// and the harness's own reports write [`Reported`]. Persistence stores +/// them in separate columns, so a lifecycle write can never clobber a report. +/// [`Status::state`] is derived from both at read time. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Status { + /// Derived Session state; see [`State`]. + #[serde(default)] + pub state: State, + /// When the Session entered `state`, from the half that decides it, when known. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub state_since: Option, + /// Lifecycle observed by the reconciler. + #[serde(default)] + pub lifecycle: Lifecycle, + /// Facts the running harness reported about itself. + #[serde(default)] + pub reported: Reported, +} + +impl Status { + /// Combines the two halves of a Session that is not archived and whose + /// lifecycle change time is unknown; see [`Status::observed`]. + #[must_use] + pub const fn new(lifecycle: Lifecycle, reported: Reported) -> Self { + Self::observed(lifecycle, reported, None, None) + } + + /// Derives the Session state, and when it was entered, from both halves, + /// when the lifecycle state last changed and when the Session was archived. + /// + /// An archived Session is Archived once its harness has stopped and + /// Archiving until then, since the archive was requested. An unarchived + /// Session whose harness an archive stopped is Idle, as unarchiving never + /// launches it. One whose stop failed may still run, so its reports decide + /// until the reconciler settles it. Any other state is entered when the + /// activity phase changed while the harness runs and reports, otherwise + /// when the lifecycle state changed. + #[must_use] + pub const fn observed( + lifecycle: Lifecycle, + reported: Reported, + lifecycle_since: Option, + archived_at: Option, + ) -> Self { + let lifecycle_archived = matches!(lifecycle.state, LifecycleState::Archived); + let stop_failed = lifecycle_archived && lifecycle.failure.is_some(); + let (state, state_since) = match archived_at { + Some(_) if lifecycle_archived && !stop_failed => (State::Archived, lifecycle_since), + Some(since) => (State::Archiving, Some(since)), + None if stop_failed => Self::entered(Self::running_state(&reported), &reported, lifecycle_since), + None if lifecycle_archived => (State::Idle, lifecycle_since), + None => Self::entered( + Self::harness_state_of(&lifecycle, &reported), + &reported, + lifecycle_since, + ), + }; + Self { + state, + state_since, + lifecycle, + reported, + } + } + + /// The state the harness itself is in, whether or not the Session is + /// archived: what an upgrade must not interrupt. + #[must_use] + pub const fn harness_state(&self) -> State { + Self::harness_state_of(&self.lifecycle, &self.reported) + } + + const fn harness_state_of(lifecycle: &Lifecycle, reported: &Reported) -> State { + match lifecycle.state { + LifecycleState::Failed => State::Failed, + LifecycleState::Idle => State::Idle, + LifecycleState::Archived => State::Archived, + LifecycleState::Starting | LifecycleState::Resuming => State::Starting, + LifecycleState::Running => Self::running_state(reported), + } + } + + /// A running harness's state from its reports. A start report always + /// folds to `Working`, so an `Unknown` phase means the current launch has + /// not reported yet, even when an earlier launch left a native ID behind + /// for resumption. + const fn running_state(reported: &Reported) -> State { + if reported.harness_session_id.is_none() { + return State::Starting; + } + match reported.activity.phase { + Phase::Unknown => State::Starting, + Phase::WaitingForInput => State::WaitingForInput, + Phase::Working => State::Working, + } + } + + const fn entered( + state: State, + reported: &Reported, + lifecycle_since: Option, + ) -> (State, Option) { + match state { + State::Working | State::WaitingForInput => (state, reported.activity.phase_since), + State::Starting | State::Idle | State::Archiving | State::Archived | State::Failed => { + (state, lifecycle_since) + } + } + } +} + +/// Lifecycle half of [`Status`], written only by the Session reconciler. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Lifecycle { + /// Normalized lifecycle state. + #[serde(default)] + pub state: LifecycleState, + /// Failure from the latest reconciliation attempt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, +} + +impl Lifecycle { + /// A running harness with no failure. + #[must_use] + pub const fn running() -> Self { + Self { + state: LifecycleState::Running, + failure: None, + } + } + + /// A deliberately stopped harness. + #[must_use] + pub const fn idle() -> Self { + Self { + state: LifecycleState::Idle, + failure: None, + } + } + + /// A stopped harness of an archived Session. + #[must_use] + pub const fn archived() -> Self { + Self { + state: LifecycleState::Archived, + failure: None, + } + } + + /// An archived Session whose harness could not be stopped yet. + pub fn archived_with(failure: impl Into) -> Self { + Self { + state: LifecycleState::Archived, + failure: Some(failure.into()), + } + } + + /// A resumed harness waiting to reach its input prompt. + #[must_use] + pub const fn resuming() -> Self { + Self { + state: LifecycleState::Resuming, + failure: None, + } + } + + /// A resumed harness whose latest readiness attempt was interrupted. + pub fn resuming_with(failure: impl Into) -> Self { + Self { + state: LifecycleState::Resuming, + failure: Some(failure.into()), + } + } + + /// Not yet running, with the reason. + pub fn starting(failure: impl Into) -> Self { + Self { + state: LifecycleState::Starting, + failure: Some(failure.into()), + } + } + + /// The latest reconciliation failed, with the reason. + pub fn failed(failure: impl Into) -> Self { + Self { + state: LifecycleState::Failed, + failure: Some(failure.into()), + } + } +} + +/// Report half of [`Status`]: what the running harness said about itself. +/// +/// Recorded only for the current launch (see [`SessionReports`]) and cleared +/// by the reconciler when the Sandbox carrying the conversation is replaced. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Reported { + /// Harness-native conversation ID reported by the running harness. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_session_id: Option, + /// Harness-native transcript location inside the Sandbox, when the harness + /// reports one. Opaque to everything but the Session runtime. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_transcript_path: Option, + /// Harness activity folded from its reports. + #[serde(default)] + pub activity: Activity, +} + +/// Durable bookkeeping for the most recent harness launch of one Session. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LaunchState { + /// Bearer token authenticating reports from this launch. + pub(crate) token: LaunchToken, + /// Sandbox ID the harness was launched in. + pub sandbox: String, + /// Launch time as Unix seconds. + pub launched_at: i64, + /// Consecutive launches without a sustained healthy observation. + pub attempts: u32, +} + +/// Opaque bearer token authenticating one exact harness launch. +#[derive(Clone, Eq, PartialEq)] +pub struct LaunchToken(Uuid); + +impl LaunchToken { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + pub(crate) fn expose(&self) -> String { + self.0.to_string() + } +} + +impl std::fmt::Debug for LaunchToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("LaunchToken([redacted])") + } +} + +impl std::str::FromStr for LaunchToken { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// One new harness launch to persist before its external effects begin. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LaunchRecord { + /// Per-launch bearer token a harness report must carry to be accepted. + pub token: LaunchToken, + /// Sandbox ID the harness is being launched in. + pub sandbox: String, + /// Launch time as Unix seconds. + pub launched_at: i64, + /// Consecutive launches without a sustained healthy observation. + pub attempts: u32, +} + +/// Persistent identity and observed state of one named Session. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Session { + /// Immutable Session identity. + pub id: SessionId, + /// Immutable identity of the owning Agent incarnation. + pub agent_id: AgentId, + /// Owning Agent name. + pub agent: String, + /// User-facing name scoped to the Agent incarnation. + pub name: SessionName, + /// Immutable harness installation selected for this Session. + pub harness: Harness, + /// Immutable model and effort level resolved when the Session was created: + /// the caller's request, then the installation's manifest defaults. Every + /// launch of the harness applies it; an unselected field leaves the harness default. + #[serde(default, skip_serializing_if = "ModelSelection::is_empty")] + pub model_selection: ModelSelection, + /// First time the Session was requested. + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + /// When release was requested. A marked Session is no longer listed or + /// resolvable by name; the reconciler stops its harness and then removes it. + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub deletion_timestamp: Option, + /// When archiving was requested. The reconciler stops the harness of an + /// archived Session and never relaunches it until it is unarchived. + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub archived_at: Option, + /// Most recently observed driver state. + #[serde(default)] + pub status: Status, + /// Desired activation revision, written only by explicit Session ensure. + #[serde(skip)] + pub(crate) activation_generation: u64, + /// Activation revision observed by the lifecycle reconciler. + #[serde(skip)] + pub(crate) observed_activation_generation: u64, +} + +impl Session { + /// Whether release of this Session has been requested. + #[must_use] + pub const fn is_deleting(&self) -> bool { + self.deletion_timestamp.is_some() + } + + /// Whether archiving this Session has been requested. + #[must_use] + pub const fn is_archived(&self) -> bool { + self.archived_at.is_some() + } + + /// Describes why an operation cannot use this Session's running harness. + pub(crate) fn not_running_error(&self) -> Error { + if self.is_archived() { + return self.archived_error(); + } + let detail = self + .status + .lifecycle + .failure + .as_deref() + .unwrap_or(match self.status.lifecycle.state { + LifecycleState::Starting => "its lifecycle is starting", + LifecycleState::Resuming => "its harness is resuming", + LifecycleState::Idle => "its lifecycle is idle", + LifecycleState::Archived => "its harness was stopped when it was archived", + LifecycleState::Failed => "its lifecycle failed without a recorded reason", + LifecycleState::Running => "its harness has not reported readiness", + }); + Error::Invalid(format!("Session \"{}\" is not running: {detail}", self.name)) + } + + /// Refuses an operation on an archived Session, whatever its harness does. + pub(crate) fn archived_error(&self) -> Error { + Error::Invalid(format!("Session \"{}\" is archived", self.name)) + } +} + +/// What a caller may choose when ensuring a Session. Every field is optional. +/// +/// The selections apply only when the call creates the Session: an omitted +/// harness, model or effort falls back to the Agent's default installation and +/// that installation's manifest defaults, and the resolved values become the +/// Session's immutable properties. For an existing Session, an explicit value +/// that differs from the recorded one is rejected; omitted ones are ignored. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SessionRequest { + /// Harness installation to bind. + pub harness: Option, + /// Model and effort level the harness launches with. + pub model_selection: ModelSelection, + /// First prompt, handed to the harness at its first launch without replay. + pub initial_prompt: Option, +} + +/// Resolved, immutable selections recorded when a Session is created. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NewSession { + /// Harness installation the Session binds to. + pub harness: Harness, + /// Model and effort level the harness launches with, as requested or defaulted. + pub model_selection: ModelSelection, + /// The part of [`Self::model_selection`] the caller chose explicitly. When the + /// Session already exists, only these fields may conflict with what it recorded. + pub requested: ModelSelection, + /// First prompt, handed to the harness at its first launch without replay. + pub initial_prompt: Option, +} + +impl NewSession { + /// A Session bound to `harness` with every other selection left to the harness. + #[must_use] + pub const fn for_harness(harness: Harness) -> Self { + Self { + harness, + model_selection: ModelSelection { + model: None, + effort: None, + }, + requested: ModelSelection { + model: None, + effort: None, + }, + initial_prompt: None, + } + } + + /// Resolves `requested` against an installation's manifest `defaults`. + #[must_use] + pub fn resolved(harness: Harness, requested: ModelSelection, defaults: &ModelSelection) -> Self { + Self { + harness, + model_selection: requested.clone().or(defaults), + requested, + initial_prompt: None, + } + } +} + +/// Non-secret information required for a terminal attachment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AttachTarget { + /// Persistent Session metadata and driver assignment. + pub session: Session, + // TODO: Replace this provider assignment with a daemon-owned attachment capability. + /// Provider-qualified materialized Sandbox assignment. + pub sandbox: sandbox::Assignment, +} + +/// Persistent Session operations required by reconciliation. +pub trait SessionStore: SessionReports { + /// Creates or gets one named Session for the active Agent incarnation. + /// + /// `new` is recorded only when the Session is created: its harness, model and + /// effort become the Session's immutable properties, and its initial prompt is + /// handed to the harness at the first launch attempt, without automatic replay. + /// An existing Session is returned as recorded, unless `new` names another + /// harness or its explicitly requested model or effort differs, so concurrent + /// creations cannot silently drop one caller's choice. + fn ensure_session<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + new: NewSession, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Gets one Session by immutable identity. + fn get_session(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result>; + + /// Gets one named Session from the active incarnation of an Agent. + fn get_agent_session<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Lists every persistent Session. + fn list_all_sessions(&self) -> ::sandbox::LocalFuture<'_, Result, Error>>; + + /// Lists Sessions for the active incarnation of one Agent name. + fn list_agent_sessions<'a>(&'a self, agent: &'a str) -> ::sandbox::LocalFuture<'a, Result, Error>>; + + /// Replaces the lifecycle half of the status for the desired activation + /// revision observed by the reconciler; the reported half is untouched. + fn update_session_lifecycle( + &self, + id: SessionId, + lifecycle: Lifecycle, + observed_activation_generation: u64, + ) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; + + /// Requests that an Idle Session become active and returns the new desired revision. + fn activate_session(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result>; + + /// Atomically records the first release request for one named Session of an + /// active Agent incarnation, and returns it as marked. + fn mark_session_deleting<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Records whether one named Session of an active Agent incarnation should be + /// archived, keeping the first archive time, and returns it as recorded. + fn set_session_archived<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + archived: bool, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Removes a marked Session once its harness has been released. Everything + /// keyed to the Session, including its activity reports, goes with it. + fn finalize_session_deletion(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; + + /// Resolves a ready Session into a terminal attachment target. + fn session_attach_target(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result>; + + /// Clears everything the previous harness incarnation reported: the native + /// conversation ID, its transcript location and the folded activity. + fn clear_session_report(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; + + /// Durably records a new harness launch and consumes its initial prompt atomically. + /// Returns the consumed prompt for this attempt. It is never restored, even + /// if launch fails; recovery can therefore start an empty conversation. + /// The previous launch's activity is reset so the Session reads as + /// [`State::Starting`] until this launch reports; the native ID and + /// transcript location survive because a resumed conversation keeps them. + fn record_session_launch( + &self, + id: SessionId, + launch: LaunchRecord, + ) -> ::sandbox::LocalFuture<'_, Result, Error>>; + + /// Reads the most recent launch bookkeeping, when one exists. + fn session_launch_state(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result, Error>>; + + /// Resets the consecutive-launch counter after a sustained healthy observation. + fn reset_session_launch_attempts(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; +} + +/// What a running harness reports about itself, recorded for its exact launch. +/// +/// This is the only write capability the harness-facing surface holds: it can +/// say what a launch reported, and nothing else about a Session. How the +/// reports travel (today, harness hooks posting to the Platform API) is a +/// transport detail below this trait. +pub trait SessionReports { + /// Atomically records start identity, transcript location and activity for + /// this launch. Duplicate event IDs return `None`; stale tokens return `Error::NotFound`. + fn record_session_start_for_launch<'a>( + &'a self, + id: SessionId, + token: &'a LaunchToken, + event_id: uuid::Uuid, + native: &'a str, + transcript_path: Option<&'a str>, + at: time::OffsetDateTime, + ) -> ::sandbox::LocalFuture<'a, Result, Error>>; + + /// Folds one activity event into the Session's activity, only when `token` + /// still identifies this exact launch, and returns the folded activity. + /// A stale token or duplicate event ID is a no-op that returns `None`. + fn apply_session_activity_for_launch<'a>( + &'a self, + id: SessionId, + token: &'a LaunchToken, + event_id: uuid::Uuid, + event: ActivityEvent, + at: OffsetDateTime, + ) -> ::sandbox::LocalFuture<'a, Result, Error>>; +} + +pub(crate) type SharedStore = std::rc::Rc; + +/// Attaches a local terminal to the Session's runtime. +/// +/// Delegates to the M0 [`Tmux`] runtime through the [`SessionRuntime`] seam; +/// only the runtime knows how a Session is carried inside the Sandbox. +/// +/// # Errors +/// +/// Returns an error when the Session is not ready or the recorded Sandbox +/// Provider cannot carry the attachment. +pub async fn attach(home: &std::path::Path, target: &AttachTarget) -> Result<(), Error> { + runtime::Tmux.attach(home, target).await +} + +#[cfg(test)] +mod tests { + use super::{Activity, Lifecycle, LifecycleState, Phase, Reported, State, Status}; + + #[test] + fn an_archived_session_is_refused_as_archived_whatever_its_harness_does() { + let session = |archived_at| super::Session { + id: "dd4cdbaf-9ea0-477e-96dd-bbd6b1e4f7dc".parse().expect("Session ID"), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + agent: "worker".into(), + name: "s1".to_string().try_into().expect("Session name"), + harness: crate::harness::test_harness(), + model_selection: crate::ModelSelection::default(), + created_at: time::OffsetDateTime::UNIX_EPOCH, + deletion_timestamp: None, + archived_at, + status: Status::new(Lifecycle::running(), Reported::default()), + activation_generation: 0, + observed_activation_generation: 0, + }; + let refused = session(Some(time::OffsetDateTime::UNIX_EPOCH)) + .not_running_error() + .to_string(); + assert!( + refused.ends_with("Session \"s1\" is archived"), + "an archive that arrives while a prompt waits for input readiness: {refused}" + ); + assert!( + session(None) + .not_running_error() + .to_string() + .contains("has not reported readiness") + ); + } + + #[test] + fn an_archive_decides_the_state_over_the_harness() { + let at = |seconds| time::OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + let working = Reported { + harness_session_id: Some("native".into()), + harness_transcript_path: None, + activity: Activity { + phase: Phase::Working, + phase_since: Some(at(1)), + ..Activity::default() + }, + }; + let status = + |lifecycle: Lifecycle, archived_at| Status::observed(lifecycle, working.clone(), Some(at(2)), archived_at); + + let archiving = status(Lifecycle::running(), Some(at(3))); + assert_eq!(archiving.state, State::Archiving, "the turn is finishing"); + assert_eq!(archiving.state_since, Some(at(3)), "since the archive was requested"); + assert_eq!(archiving.harness_state(), State::Working, "the harness still works"); + assert_eq!( + status(Lifecycle::archived_with("unreachable"), Some(at(3))).state, + State::Archiving, + "a stop that failed is retried" + ); + let archived = status(Lifecycle::archived(), Some(at(3))); + assert_eq!((archived.state, archived.state_since), (State::Archived, Some(at(2)))); + for lifecycle in [Lifecycle::idle(), Lifecycle::failed("boom")] { + assert_eq!( + status(lifecycle, Some(at(3))).state, + State::Archiving, + "until a pass records the harness as stopped" + ); + } + assert_eq!( + status(Lifecycle::archived(), None).state, + State::Idle, + "unarchived before the reconciler settles it" + ); + let unarchived_after_failed_stop = status(Lifecycle::archived_with("unreachable"), None); + assert_eq!( + ( + unarchived_after_failed_stop.state, + unarchived_after_failed_stop.state_since + ), + (State::Working, Some(at(1))), + "the harness a failed stop left running reports for itself" + ); + assert_eq!(status(Lifecycle::running(), None).state, State::Working); + } + + #[test] + fn a_session_entered_its_state_when_the_half_that_decides_it_changed() { + let at = |seconds| time::OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + let reported = Reported { + harness_session_id: Some("native".into()), + harness_transcript_path: None, + activity: Activity { + phase: Phase::WaitingForInput, + phase_since: Some(at(10)), + ..Activity::default() + }, + }; + let waiting = Status::observed(Lifecycle::running(), reported.clone(), Some(at(1)), None); + assert_eq!( + waiting.state_since, + Some(at(10)), + "a running Session is in its activity phase" + ); + let failed = Status::observed(Lifecycle::failed("boom"), reported, Some(at(20)), None); + assert_eq!(failed.state_since, Some(at(20)), "otherwise the lifecycle decides"); + } + + #[test] + fn state_is_derived_from_both_halves() { + let reported = |phase: Phase| Reported { + harness_session_id: Some("native".into()), + harness_transcript_path: None, + activity: Activity { + phase, + ..Activity::default() + }, + }; + let cases = [ + (Lifecycle::default(), Reported::default(), State::Starting), + (Lifecycle::resuming(), Reported::default(), State::Starting), + (Lifecycle::running(), Reported::default(), State::Starting), + (Lifecycle::running(), reported(Phase::Working), State::Working), + (Lifecycle::running(), reported(Phase::Unknown), State::Starting), + ( + Lifecycle::running(), + reported(Phase::WaitingForInput), + State::WaitingForInput, + ), + (Lifecycle::idle(), reported(Phase::WaitingForInput), State::Idle), + (Lifecycle::failed("boom"), reported(Phase::Working), State::Failed), + ( + Lifecycle::starting("not ready"), + reported(Phase::Working), + State::Starting, + ), + ]; + for (lifecycle, reported, expected) in cases { + let status = Status::new(lifecycle.clone(), reported); + assert_eq!(status.state, expected, "{lifecycle:?}"); + assert_eq!(status.lifecycle, lifecycle); + } + assert_eq!(Lifecycle::idle().state, LifecycleState::Idle); + } +} diff --git a/agentctl/src/sessions/reconciler.rs b/agentctl/src/sessions/reconciler.rs new file mode 100644 index 0000000..c636a84 --- /dev/null +++ b/agentctl/src/sessions/reconciler.rs @@ -0,0 +1,468 @@ +//! At-least-once convergence of one durable Session. + +use std::{rc::Rc, time::Duration}; + +use ::sandbox::LocalFuture; + +use crate::Error; + +use super::{ + Activity, ActivityEvent, AgentSandboxes, LaunchRecord, LaunchToken, Lifecycle, LifecycleState, Phase, Session, + SessionId, SessionRuntime, SharedStore, runtime::Observation, +}; + +/// A launch is considered healthy after surviving this long, resetting backoff. +const HEALTHY_AFTER_SECONDS: i64 = 60; + +/// Longest wait between relaunches of a repeatedly exiting harness. +const MAX_BACKOFF_SECONDS: i64 = 600; + +/// Stop an unattached harness after this long without terminal output, +/// transcript writes or reported activity. +const IDLE_AFTER_SECONDS: u64 = 30 * 60; + +/// A turn whose terminal and transcript stay quiet this long is not waited for +/// when archiving: the harness is stuck, gone, or was never prompted. +const ARCHIVE_TURN_QUIET_SECONDS: u64 = 60; + +/// Maximum time for a resumed harness to reach its empty input prompt. +const RESUME_READY_TIMEOUT: Duration = Duration::from_secs(15); +const RESUME_READY_POLL: Duration = Duration::from_millis(100); + +/// Converges persistent Sessions onto the tmux runtime in their Agent's Sandbox. +pub struct Reconciler { + sessions: SharedStore, + sandboxes: Rc, + runtime: Rc, + session_hook_url: String, +} + +impl Reconciler { + /// Creates a Session reconciler over durable state and the Agent Sandbox service. + /// + /// `session_hook_url` is the Sandbox-reachable start-hook endpoint handed to + /// every harness launch. + #[must_use] + pub fn new( + sessions: SharedStore, + sandboxes: Rc, + runtime: Rc, + session_hook_url: String, + ) -> Self { + Self { + sessions, + sandboxes, + runtime, + session_hook_url, + } + } + + /// Stops the harness of a Session marked for release and then removes it. + /// + /// A Sandbox that is gone, unmaterialized or stopped took the harness + /// process with it, so there is nothing left to stop; anything else is an + /// error, and the Session stays marked until a later pass can release it. + async fn release(&self, session: &Session) -> Result<(), Error> { + if let Some(sandbox) = self.release_sandbox(session).await? { + self.runtime.stop(session, &sandbox).await?; + } + self.sessions.finalize_session_deletion(session.id).await + } + + /// The Sandbox still holding this Session's harness, if one does. + async fn release_sandbox(&self, session: &Session) -> Result, Error> { + let agent = match self.sandboxes.agent(session.agent_id).await { + Ok(agent) => agent, + Err(Error::NotFound) => return Ok(None), + Err(error) => return Err(error), + }; + if agent.agent.metadata.deletion_timestamp.is_some() + || !matches!( + agent.agent.status.sandbox, + Some(crate::sandbox::Assignment::Materialized { .. }) + ) + { + return Ok(None); + } + let sandbox = match self.sandboxes.open(&agent).await { + Ok(sandbox) => sandbox, + Err(Error::Sandbox(error)) if error.is_not_found() => return Ok(None), + Err(error) => return Err(error), + }; + if sandbox.snapshot().state == ::sandbox::SandboxState::Stopped { + return Ok(None); + } + Ok(Some(sandbox)) + } + + /// Stops the harness of an archived Session and keeps it stopped. + /// + /// A turn in progress is waited for, so archiving never cuts one short; a + /// later pass retries. A turn waiting for approval is not waited for, as + /// nobody answers an archived Session. A Sandbox that is gone, stopped or + /// unmaterialized has no harness left to stop. + async fn converge_archive(&self, session: &Session) -> Result { + if session.status.lifecycle.state == LifecycleState::Archived && session.status.lifecycle.failure.is_none() { + return Ok(Lifecycle::archived()); + } + if let Some(sandbox) = self.release_sandbox(session).await? { + if self.mid_turn(session, &sandbox).await? { + return Ok(session.status.lifecycle.clone()); + } + self.runtime.stop(session, &sandbox).await?; + } + self.sessions.reset_session_launch_attempts(session.id).await?; + Ok(Lifecycle::archived()) + } + + /// Whether the harness is visibly working on a turn: it reports working, or + /// has not reported yet, is still running, and its terminal or transcript + /// moved recently. The report alone can be stale, for example after a crash. + /// + /// The harness's own report decides, not the derived state, which reads + /// Archived once an earlier archive pass has failed. + async fn mid_turn(&self, session: &Session, sandbox: &::sandbox::SandboxHandle) -> Result { + if !matches!(session.status.reported.activity.phase, Phase::Working | Phase::Unknown) { + return Ok(false); + } + let Observation::Alive { idle_seconds, .. } = self.runtime.observe(session, sandbox).await? else { + return Ok(false); + }; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + Ok(effective_idle_seconds(&session.status.reported.activity, idle_seconds, now) < ARCHIVE_TURN_QUIET_SECONDS) + } + + /// Settles a Session unarchived before its archive could stop the harness: + /// a harness still running is adopted, otherwise the Session is Idle. + /// Nothing is launched until the next attach. + async fn settle_unarchived(&self, session: &Session) -> Result { + if let Some(sandbox) = self.release_sandbox(session).await? + && matches!( + self.runtime.observe(session, &sandbox).await?, + Observation::Alive { .. } + ) + { + return Ok(Lifecycle::running()); + } + Ok(Lifecycle::idle()) + } + + async fn converge(&self, session: &Session) -> Result { + if session.activation_generation == session.observed_activation_generation { + // An unarchived Session stays stopped, like an Idle one, until it is attached. + match (&session.status.lifecycle.state, &session.status.lifecycle.failure) { + (LifecycleState::Idle, _) | (LifecycleState::Archived, None) => return Ok(Lifecycle::idle()), + (LifecycleState::Archived, Some(_)) => return self.settle_unarchived(session).await, + _ => {} + } + } + let agent = self.sandboxes.agent(session.agent_id).await?; + // A stopped Agent's harnesses stop with its VM. The Session is Idle, as after + // inactivity, so the next attach after a start resumes its conversation. + // A recorded stop counts too: a start may already be asked for while the + // stop waits for its Sessions to see it. + if agent.agent.spec.is_stopped() || agent.agent.status.is_stopped() { + self.sessions.reset_session_launch_attempts(session.id).await?; + return Ok(Lifecycle::idle()); + } + if let Some(held) = launch_blocked(&agent, session) { + return Ok(held); + } + let sandbox = self.sandboxes.open(&agent).await?; + let platform = &sandbox.snapshot().image.platform; + // TODO: Generalize the Session runtime when a concrete non-Linux driver establishes its required contract. + if platform.os != "linux" { + return Err(Error::Session(format!( + "tmux Sessions require a Linux Sandbox, but the materialized platform is {:?}", + platform.os + ))); + } + let sandbox_id = sandbox.snapshot().id.to_string(); + let launch = self.sessions.session_launch_state(session.id).await?; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + + if let Observation::Alive { attached, idle_seconds } = self.runtime.observe(session, &sandbox).await? { + if !attached + && effective_idle_seconds(&session.status.reported.activity, idle_seconds, now) >= IDLE_AFTER_SECONDS + { + self.runtime.stop(session, &sandbox).await?; + self.sessions.reset_session_launch_attempts(session.id).await?; + return Ok(Lifecycle::idle()); + } + if let Some(state) = &launch + && state.attempts > 0 + && now - state.launched_at >= HEALTHY_AFTER_SECONDS + { + self.sessions.reset_session_launch_attempts(session.id).await?; + } + if session.status.lifecycle.state == LifecycleState::Resuming { + let state = launch + .as_ref() + .ok_or_else(|| Error::Session("resumed harness has no launch record".into()))?; + if state.sandbox != sandbox_id { + return Err(Error::Session("resumed harness belongs to a replaced Sandbox".into())); + } + self.wait_for_resumed_input(session, &sandbox, &state.token).await?; + } + return Ok(Lifecycle::running()); + } + + let mut attempts = 0; + let mut resume = session.status.reported.harness_session_id.clone(); + if let Some(state) = launch { + if state.sandbox == sandbox_id { + attempts = state.attempts; + let wait = backoff_seconds(attempts); + if attempts > 0 && now < state.launched_at + wait { + return Ok(Lifecycle::failed(format!( + "harness exited; relaunching after up to {wait}s of backoff" + ))); + } + } else { + // The Sandbox was replaced, and the harness conversation state + // lived inside it. Start a fresh conversation instead of + // resuming an ID whose files no longer exist. + resume = None; + attempts = 0; + self.sessions.clear_session_report(session.id).await?; + } + } + self.launch( + session, + &sandbox, + LaunchRecord { + token: LaunchToken::generate(), + sandbox: sandbox_id, + launched_at: now, + attempts: attempts + 1, + }, + resume.as_deref(), + ) + .await + } + + /// Consumes the first prompt before launch; recovery never replays it. + async fn launch( + &self, + session: &Session, + sandbox: &::sandbox::SandboxHandle, + record: LaunchRecord, + resume: Option<&str>, + ) -> Result { + let token = record.token.clone(); + let initial_prompt = self.sessions.record_session_launch(session.id, record).await?; + if resume.is_some() { + self.sessions + .update_session_lifecycle(session.id, Lifecycle::resuming(), session.activation_generation) + .await?; + } + self.runtime + .start( + session, + sandbox, + &self.session_hook_url, + &token, + resume, + initial_prompt.as_deref().filter(|_| resume.is_none()), + ) + .await?; + if resume.is_some() { + self.wait_for_resumed_input(session, sandbox, &token).await?; + } + Ok(Lifecycle::running()) + } + + async fn wait_for_resumed_input( + &self, + session: &Session, + sandbox: &::sandbox::SandboxHandle, + token: &LaunchToken, + ) -> Result<(), Error> { + let waiting = async { + loop { + let current = self.sessions.get_session(session.id).await?; + let ready = match current.status.reported.activity.phase { + Phase::WaitingForInput => return Ok(()), + Phase::Unknown | Phase::Working => { + self.runtime.input_ready(¤t, sandbox).await.unwrap_or(false) + } + }; + if ready { + let applied = self + .sessions + .apply_session_activity_for_launch( + session.id, + token, + uuid::Uuid::new_v4(), + ActivityEvent::WaitingForInput, + time::OffsetDateTime::now_utc(), + ) + .await?; + return applied.map(|_| ()).ok_or_else(|| { + Error::Session("resumed harness launch changed while waiting for input".into()) + }); + } + tokio::time::sleep(RESUME_READY_POLL).await; + } + }; + if let Ok(result) = tokio::time::timeout(RESUME_READY_TIMEOUT, waiting).await { + return result; + } + let current = self.sessions.get_session(session.id).await?; + self.runtime.stop(¤t, sandbox).await?; + let error = Error::Session(format!( + "resumed harness did not become ready for input within {} seconds", + RESUME_READY_TIMEOUT.as_secs() + )); + self.sessions + .update_session_lifecycle( + session.id, + Lifecycle::failed(error.to_string()), + session.activation_generation, + ) + .await?; + Err(error) + } +} + +impl crate::controller::Reconcile for Reconciler { + fn reconcile(&self, id: SessionId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let session = match self.sessions.get_session(id).await { + Ok(session) => session, + Err(Error::NotFound) => return Ok(()), + Err(error) => return Err(error), + }; + if session.is_deleting() { + return self.release(&session).await; + } + let converged = if session.is_archived() { + self.converge_archive(&session).await + } else { + self.converge(&session).await + }; + match converged { + Ok(lifecycle) => { + self.sessions + .update_session_lifecycle(session.id, lifecycle, session.activation_generation) + .await + } + Err(error) => { + let current = self.sessions.get_session(session.id).await?; + let lifecycle = if current.is_archived() { + // A failed archive stays archived, so unarchiving never relaunches the harness. + Lifecycle::archived_with(error.to_string()) + } else if current.status.lifecycle.state == LifecycleState::Resuming { + Lifecycle::resuming_with(error.to_string()) + } else { + Lifecycle::failed(error.to_string()) + }; + self.sessions + .update_session_lifecycle(session.id, lifecycle, session.activation_generation) + .await?; + Err(error) + } + } + }) + } +} + +/// Seconds a Session has been inactive, taking the smaller of runtime +/// inactivity (terminal or transcript) and time since the last reported event. +fn effective_idle_seconds(activity: &Activity, runtime_idle_seconds: u64, now: i64) -> u64 { + activity.last_event_at.map_or(runtime_idle_seconds, |at| { + let since_event = u64::try_from((now - at.unix_timestamp()).max(0)).unwrap_or(u64::MAX); + runtime_idle_seconds.min(since_event) + }) +} + +/// Seconds to wait after launch attempt `attempts` before relaunching. +fn backoff_seconds(attempts: u32) -> i64 { + if attempts == 0 { + return 0; + } + let exponent = (attempts - 1).min(6); + let wait = 10_i64 << exponent; + if wait > MAX_BACKOFF_SECONDS { + MAX_BACKOFF_SECONDS + } else { + wait + } +} + +/// Holds a Session short of launching while its Agent cannot run it. +/// +/// The image ships every harness binary, so launching one convergence never installed starts a +/// process that sits at a login prompt nobody can answer and reports the Session as running. +fn launch_blocked(agent: &crate::control_plane::AgentRecord, session: &Session) -> Option { + let installed = agent + .agent + .status + .sandbox + .as_ref() + .and_then(crate::sandbox::Assignment::installed_harnesses); + let name = &agent.agent.metadata.name; + let reason = if agent.agent.metadata.deletion_timestamp.is_some() { + format!("Agent {name:?} is being deleted") + } else if !agent.agent.status.is_ready() { + // Says why, such as a guest that stopped responding. + agent.agent.status.ready_condition().map_or_else( + || format!("Agent {name:?} is not ready"), + |ready| format!("Agent {name:?} is not ready: {}", ready.detail().trim_end()), + ) + } else if !installed.is_some_and(|installed| installed.contains(&session.harness)) { + format!( + "Agent {:?} does not carry harness {:?}; sign in on the host and the next Agent \ + convergence installs it", + agent.agent.metadata.name, + session.harness.as_str() + ) + } else { + return None; + }; + Some(if session.status.lifecycle.state == LifecycleState::Resuming { + Lifecycle::resuming_with(reason) + } else { + Lifecycle::starting(reason) + }) +} + +#[cfg(test)] +mod tests { + use super::{Activity, effective_idle_seconds}; + + #[test] + fn idle_age_is_the_terminal_age_until_the_harness_reports_activity() { + assert_eq!(effective_idle_seconds(&Activity::default(), 1_900, 10_000), 1_900); + } + + #[test] + fn idle_age_is_the_fresher_of_terminal_and_reported_activity() { + let reported = Activity { + last_event_at: Some(time::OffsetDateTime::from_unix_timestamp(9_940).expect("timestamp")), + ..Activity::default() + }; + assert_eq!( + effective_idle_seconds(&reported, 1_900, 10_000), + 60, + "a recent report counts as activity" + ); + assert_eq!( + effective_idle_seconds(&reported, 5, 10_000), + 5, + "terminal output counts too" + ); + // A report stamped ahead of the daemon clock never yields a negative age. + assert_eq!(effective_idle_seconds(&reported, 30, 9_000), 0); + } + + #[test] + fn backoff_grows_and_caps() { + assert_eq!(super::backoff_seconds(0), 0); + assert_eq!(super::backoff_seconds(1), 10); + assert_eq!(super::backoff_seconds(2), 20); + assert_eq!(super::backoff_seconds(5), 160); + assert_eq!(super::backoff_seconds(7), super::MAX_BACKOFF_SECONDS); + assert_eq!(super::backoff_seconds(u32::MAX), super::MAX_BACKOFF_SECONDS); + } +} diff --git a/agentctl/src/sessions/runtime/deliver.sh b/agentctl/src/sessions/runtime/deliver.sh new file mode 100644 index 0000000..f8ee317 --- /dev/null +++ b/agentctl/src/sessions/runtime/deliver.sh @@ -0,0 +1,8 @@ +file=$1 +buffer=$2 +target=$3 +trap '/usr/bin/tmux delete-buffer -b "$buffer" 2>/dev/null; /bin/rm -f -- "$file"' EXIT +/usr/bin/tmux load-buffer -b "$buffer" "$file" && + /usr/bin/tmux paste-buffer -d -p -b "$buffer" -t "$target" && + /bin/sleep 0.2 && + /usr/bin/tmux send-keys -t "$target" Enter diff --git a/agentctl/src/sessions/runtime/mod.rs b/agentctl/src/sessions/runtime/mod.rs new file mode 100644 index 0000000..fbfdf22 --- /dev/null +++ b/agentctl/src/sessions/runtime/mod.rs @@ -0,0 +1,107 @@ +//! Session runtime seam: how a harness is carried inside a Sandbox. +//! +//! The runtime owns process lifecycle, terminal state, operator input and the +//! conversation record for a Session. Tmux is the M0 Unix implementation; +//! nothing above this trait names tmux, panes, buffers or transcript files, so +//! a later Agent Host Protocol runtime can replace [`Tmux`] without touching +//! the reconciler, the Session service, the control API, the CLI or persistence. + +mod tmux; + +pub use tmux::Tmux; + +use ::sandbox::SandboxHandle; + +use crate::Error; + +use super::{AttachTarget, LaunchToken, Session, Turn}; + +/// Runtime-observed liveness and inactivity for a Session. +/// +/// The idle age is calculated against the guest clock so host/microVM skew +/// cannot make an active Session look idle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Observation { + /// The Session's process is not present in the runtime. + Missing, + /// The Session is present, with attachment and activity age. + Alive { + /// Whether a client terminal is attached. + attached: bool, + /// Seconds since the last terminal activity or transcript write. + idle_seconds: u64, + }, +} + +/// One harness Session carried inside a Sandbox. +/// +/// Every method takes the materialized [`SandboxHandle`]; the runtime holds no +/// Sandbox state of its own, mirroring how tmux is addressed per execution. +pub trait SessionRuntime { + /// Observes runtime liveness, attachment and inactivity. + fn observe<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Starts the harness process for `session`. + /// + /// `resume` continues that harness-native conversation. `initial_prompt` + /// is the first operator prompt of a fresh conversation, handed to the + /// harness at launch so it starts working immediately; it is never + /// combined with `resume`. + fn start<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + session_hook_url: &'a str, + token: &'a LaunchToken, + resume: Option<&'a str>, + initial_prompt: Option<&'a str>, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; + + /// Stops a deliberately idle Session. + fn stop<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; + + /// Whether a running harness can accept input. Some harnesses do not create + /// a conversation until the first prompt arrives. + fn input_ready<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Submits `prompt` to the running harness as operator input. + /// Completes submission before returning; the service serializes delivery + /// and starts the completion timeout afterwards. + fn prompt<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + prompt: &'a str, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; + + /// Reads the Session's conversation so far as ordered turns, optionally + /// limiting work and output to the last `last` complete turns. + /// + /// A conversation that has not produced a record yet is empty, not an error. + fn turns<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + last: Option, + ) -> ::sandbox::LocalFuture<'a, Result, Error>>; + + /// Attaches a local terminal to the Session; a client capability distinct + /// from daemon-owned convergence. It never creates or resumes a Session. + fn attach<'a>( + &'a self, + home: &'a std::path::Path, + target: &'a AttachTarget, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; +} diff --git a/agentctl/src/sessions/runtime/observe.sh b/agentctl/src/sessions/runtime/observe.sh new file mode 100644 index 0000000..53bd77e --- /dev/null +++ b/agentctl/src/sessions/runtime/observe.sh @@ -0,0 +1,16 @@ +transcript=$2 +values=$(/usr/bin/tmux list-sessions -F '#{session_attached} #{session_activity}' -f "#{==:#{session_name},$1}") +status=$? +case $status in 0) ;; 1) exit 10 ;; *) exit 11 ;; esac +set -- $values +[ "$#" -eq 0 ] && exit 10 +[ "$#" -eq 2 ] || exit 11 +latest=$2 +if [ -f "$transcript" ]; then + modified=$(/usr/bin/stat -c %Y -- "$transcript") || exit 11 + [ "$modified" -le "$latest" ] || latest=$modified +fi +now=$(/usr/bin/date +%s) || exit 11 +age=$((now - latest)) +[ "$age" -ge 0 ] || age=0 +printf '%s %s\n' "$1" "$age" diff --git a/agentctl/src/sessions/runtime/stop.sh b/agentctl/src/sessions/runtime/stop.sh new file mode 100644 index 0000000..30ad535 --- /dev/null +++ b/agentctl/src/sessions/runtime/stop.sh @@ -0,0 +1,4 @@ +/usr/bin/tmux kill-session -t "$1" 2>/dev/null && exit 0 +# A Session that is already gone, or a server that is no longer running, is stopped. +/usr/bin/tmux has-session -t "$1" 2>/dev/null && exit 1 +exit 0 diff --git a/agentctl/src/sessions/runtime/tmux.rs b/agentctl/src/sessions/runtime/tmux.rs new file mode 100644 index 0000000..c17f919 --- /dev/null +++ b/agentctl/src/sessions/runtime/tmux.rs @@ -0,0 +1,873 @@ +//! Linux tmux Session runtime and terminal capability. +//! +//! Tmux is the M0 Unix Sandbox implementation detail behind Sessions: it owns +//! the harness PTY, retained terminal state, normal-screen scrollback and client +//! attachment. Nothing tmux-native is persisted; the tmux session name is +//! derived from the platform `SessionId`. The conversation record is the +//! harness's own transcript file, located by the path the harness reported. + +use ::sandbox::{ + SandboxHandle, SandboxPath, + execution::{ExecutionOutput, ExecutionSpec, ExitStatus, StartExecutionRequest}, + terminal::{AttachTerminalRequest, TerminalAttachOutcome}, +}; + +use crate::{ + Error, harness, + sandbox::platform::{PORTABLE_TERMINAL, UTF8_LOCALE}, +}; + +use super::Observation; +use crate::sessions::{Activity, AttachTarget, LaunchToken, LifecycleState, Phase, Session, Turn}; + +/// Quiet period after the latest hook event before input is pasted into a +/// harness that is not waiting for input. The start hook fires before the +/// harness TUI's input loop is up, and a paste that lands in that gap is lost; +/// tmux has no readiness signal of its own, so recent hook activity stands in. +const INPUT_READY_GRACE: std::time::Duration = std::time::Duration::from_secs(2); +/// Bound on every Session runtime execution in the guest, so a stalled guest +/// fails the operation instead of holding it. +const LIFECYCLE_EXECUTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const LIFECYCLE_EXECUTION_KILL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +const DETACH_KEYS: &str = "ctrl-b,d"; + +// Set history-limit before pane creation; reapply on attach for existing servers. +// Mouse mode routes wheels to copy mode or the application: https://man.openbsd.org/tmux.1#mouse +// Reserve index 99: appending would grow terminal-features on every attach. +// Ctrl-Z would stop the harness with no shell to resume it, so swallow it in a Session's +// own harness pane (a pane started with a command). It is bound on the shared tmux server, +// so the guard is scoped to agent-session-* names; every other pane, including a shell +// opened with Ctrl-b c, keeps normal job control and has Ctrl-Z forwarded. +fn terminal_options() -> Vec { + [ + "set-option", + "-g", + "history-limit", + "50000", + ";", + "set-option", + "-g", + "mouse", + "on", + ";", + "set-option", + "-s", + "focus-events", + "on", + ";", + "set-option", + "-s", + "extended-keys", + "on", + ";", + "set-option", + "-s", + "terminal-features[99]", + "xterm*:extkeys", + ";", + "bind-key", + "-n", + "C-z", + "if-shell", + "-F", + "#{&&:#{m:agent-session-*,#{session_name}},#{!=:#{pane_start_command},}}", + "", + "send-keys C-z", + ";", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +fn session_name(session: &Session) -> String { + format!("agent-session-{}", session.id) +} + +fn exact_target(session: &Session) -> String { + format!("={}", session_name(session)) +} + +/// Exact-session pane target (`=name:`) for commands that address a pane rather +/// than a session; a bare `=name` resolves only for session-targeting commands. +fn pane_target(session: &Session) -> String { + format!("={}:", session_name(session)) +} + +// Both timestamps come from the Sandbox. A missing transcript is normal before +// the harness creates its conversation; its contents are not needed here. +const OBSERVE_SCRIPT: &str = include_str!("observe.sh"); + +// Stopping is repeated after an interrupted release, so a Session that is +// already gone counts as stopped. +const STOP_SCRIPT: &str = include_str!("stop.sh"); + +/// Observes attachment and the freshest terminal or transcript activity. +async fn observe(session: &Session, sandbox: &SandboxHandle) -> Result { + let inspected = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + OBSERVE_SCRIPT.into(), + "agent-session-observe".into(), + session_name(session), + session + .status + .reported + .harness_transcript_path + .clone() + .unwrap_or_default(), + ], + ), + "tmux observation", + ) + .await?; + classify_observation(inspected.status, &inspected.stdout) +} + +fn classify_observation(status: ExitStatus, stdout: &[u8]) -> Result { + if status.code == 10 { + return Ok(Observation::Missing); + } + if !status.success() { + return Err(Error::Session(format!( + "tmux observation failed with exit code {}", + status.code + ))); + } + let output = std::str::from_utf8(stdout) + .map_err(|error| Error::Session(format!("tmux returned non-UTF-8 observation: {error}")))?; + parse_observation(output) +} + +fn parse_observation(output: &str) -> Result { + let mut fields = output.split_ascii_whitespace(); + let attached = fields + .next() + .ok_or_else(|| Error::Session("tmux returned an empty observation".into()))?; + let idle_seconds = fields + .next() + .ok_or_else(|| Error::Session("tmux omitted its activity age".into()))? + .parse::() + .map_err(|error| Error::Session(format!("tmux returned an invalid activity age: {error}")))?; + if fields.next().is_some() || !matches!(attached, "0" | "1") { + return Err(Error::Session("tmux returned an invalid observation".into())); + } + Ok(Observation::Alive { + attached: attached == "1", + idle_seconds, + }) +} + +/// Stops a tmux Session, succeeding when it is already gone. +async fn stop(session: &Session, sandbox: &SandboxHandle) -> Result<(), Error> { + let stopped = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + STOP_SCRIPT.into(), + "agent-session-stop".into(), + exact_target(session), + ], + ), + "tmux stop", + ) + .await?; + if stopped.status.success() { + Ok(()) + } else { + Err(Error::Session(format!( + "tmux failed to stop Session {} with exit code {}", + session.id, stopped.status.code + ))) + } +} + +async fn run_lifecycle_execution( + sandbox: &SandboxHandle, + spec: ExecutionSpec, + operation: &str, +) -> Result { + let deadline = tokio::time::Instant::now() + LIFECYCLE_EXECUTION_TIMEOUT; + let started = tokio::time::timeout_at(deadline, sandbox.start_execution(StartExecutionRequest::new(spec))) + .await + .map_err(|_| Error::Session(format!("{operation} timed out while starting")))??; + let execution_id = started.id.clone(); + let mut collecting = std::pin::pin!(started.collect()); + if let Ok(output) = tokio::time::timeout_at(deadline, &mut collecting).await { + output.map_err(Error::from) + } else { + match tokio::time::timeout(LIFECYCLE_EXECUTION_KILL_TIMEOUT, sandbox.kill_execution(&execution_id)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!(%error, %execution_id, "failed to kill timed-out Session runtime execution"); + } + Err(_) => tracing::warn!(%execution_id, "timed out killing Session runtime execution"), + } + Err(Error::Session(format!("{operation} timed out"))) + } +} + +/// Builds the tmux `new-session` arguments for one launch of `session`'s harness. +/// +/// Per-launch values travel as tmux session environment (`-e`) rather than +/// becoming defaults for subsequently created sessions. Sessions share one +/// Unix identity and tmux server, so this is not a security boundary between +/// sibling Sessions; the token only rejects stale or accidental reports. The +/// Session's recorded model selection is part of every launch, resumed or not. +fn launch_arguments( + session: &Session, + session_hook_url: &str, + token: &LaunchToken, + resume: Option<&str>, + initial_message: Option<&str>, +) -> Vec { + let launch = harness::launch_linux( + session.harness, + &harness::LaunchRequest { + home: crate::sandbox::platform::HOME, + resume, + initial_prompt: initial_message, + model_selection: &session.model_selection, + }, + ); + let mut arguments = terminal_options(); + arguments.extend(["new-session".into(), "-d".into(), "-s".into(), session_name(session)]); + let session_environment = launch.environment.iter().cloned().chain([ + ("CONTAINER_HOST".into(), crate::sandbox::platform::CONTAINER_HOST.into()), + ("AGENT_SESSION_ID".into(), session.id.to_string()), + ("AGENT_SESSION_TOKEN".into(), token.expose()), + ("AGENT_SESSION_HOOK_URL".into(), session_hook_url.into()), + ]); + for (name, value) in session_environment { + arguments.push("-e".into()); + arguments.push(format!("{name}={value}")); + } + arguments.push(launch.command); + arguments +} + +/// Creates the named detached tmux session running the harness. +async fn launch( + session: &Session, + sandbox: &SandboxHandle, + session_hook_url: &str, + token: &LaunchToken, + resume: Option<&str>, + initial_message: Option<&str>, +) -> Result<(), Error> { + let arguments = launch_arguments(session, session_hook_url, token, resume, initial_message); + let created = run_lifecycle_execution( + sandbox, + ExecutionSpec::command(SandboxPath::new("/usr/bin/tmux"), arguments) + .with_working_directory(SandboxPath::new(crate::sandbox::platform::WORKING_DIRECTORY)) + .with_environment([ + ("HOME".into(), crate::sandbox::platform::HOME.into()), + ("LANG".into(), UTF8_LOCALE.into()), + ]), + "tmux Session launch", + ) + .await?; + if created.status.success() { + return Ok(()); + } + // Concurrent creation is excluded by per-Session serialization, but an + // "already exists" result from a raced earlier pass still converges. + if matches!(observe(session, sandbox).await?, Observation::Alive { .. }) { + return Ok(()); + } + Err(Error::Session(format!( + "tmux failed to create Session {} with exit code {}", + session.id, created.status.code + ))) +} + +/// Attaches a local terminal to an existing tmux-backed Session. +/// +/// This client capability is separate from daemon-owned lifecycle +/// convergence. It never creates or resumes a Session. +/// +/// # Errors +/// +/// Returns an error unless the Session is ready and the Sandbox Provider +/// supports direct terminal attachment. +async fn attach_terminal(home: &std::path::Path, target: &AttachTarget) -> Result<(), Error> { + if target.session.status.lifecycle.state != LifecycleState::Running { + return Err(target.session.not_running_error()); + } + let request = attach_request(&target.session); + match crate::sandbox::attach_terminal(home, &target.sandbox, request).await? { + TerminalAttachOutcome::Exited(status) if status.success() => Ok(()), + TerminalAttachOutcome::Detached => Ok(()), + TerminalAttachOutcome::Exited(status) => Err(Error::Session(format!( + "tmux attachment exited with code {}", + status.code + ))), + _ => Err(Error::Session( + "terminal attachment returned an unsupported outcome".into(), + )), + } +} + +fn attach_arguments(session: &Session) -> Vec { + let mut arguments = terminal_options(); + // A session-local override can shadow the global default on older sessions. + arguments.extend([ + "set-option".into(), + "-t".into(), + pane_target(session), + "mouse".into(), + "on".into(), + ";".into(), + "attach-session".into(), + "-t".into(), + exact_target(session), + ]); + arguments +} + +fn attach_spec(session: &Session) -> ExecutionSpec { + ExecutionSpec::command(SandboxPath::new("/usr/bin/tmux"), attach_arguments(session)) + // Host-specific TERM names are not necessarily installed in the guest. + // Use the broadly available baseline while tmux mediates the terminal. + .with_environment([ + ("LANG".into(), UTF8_LOCALE.into()), + ("TERM".into(), PORTABLE_TERMINAL.into()), + ]) +} + +fn attach_request(session: &Session) -> AttachTerminalRequest { + AttachTerminalRequest::new(attach_spec(session)).with_detach_keys(DETACH_KEYS) +} + +/// The M0 Unix Session runtime backed by tmux. +#[derive(Clone, Copy, Debug, Default)] +pub struct Tmux; + +impl super::SessionRuntime for Tmux { + fn observe<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result> { + Box::pin(observe(session, sandbox)) + } + + fn start<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + session_hook_url: &'a str, + token: &'a LaunchToken, + resume: Option<&'a str>, + initial_prompt: Option<&'a str>, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(launch( + session, + sandbox, + session_hook_url, + token, + resume, + initial_prompt, + )) + } + + fn stop<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(stop(session, sandbox)) + } + + fn input_ready<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + match session.status.reported.activity.phase { + Phase::WaitingForInput => return Ok(true), + Phase::Working => { + return Ok( + input_ready_in(&session.status.reported.activity, time::OffsetDateTime::now_utc()).is_none(), + ); + } + Phase::Unknown => {} + } + let output = run_lifecycle_execution(sandbox, ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + ["-c".into(), + "/usr/bin/tmux display-message -p -t \"$1\" '#{cursor_flag} #{cursor_y} #{pane_title}' && /usr/bin/tmux capture-pane -p -t \"$1\"".into(), + "agent-input-ready".into(), pane_target(session)], + ), "input readiness check").await?; + // Provisioning publishes the launch before its pane necessarily exists. + // tmux exits 1 while there is no server or target pane to inspect. + if output.status.code == 1 { + return Ok(false); + } + if !output.status.success() { + return Err(Error::Session("could not inspect the harness input readiness".into())); + } + let screen = std::str::from_utf8(&output.stdout) + .map_err(|error| Error::Session(format!("invalid terminal input state: {error}")))?; + Ok(ready_input(screen) + .is_some_and(|(line, title)| harness::input_ready_without_report(session.harness, line, title))) + }) + } + + fn prompt<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + prompt: &'a str, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(deliver(session, sandbox, prompt)) + } + + fn turns<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + last: Option, + ) -> ::sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(turns(session, sandbox, last)) + } + + fn attach<'a>( + &'a self, + home: &'a std::path::Path, + target: &'a AttachTarget, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(attach_terminal(home, target)) + } +} + +/// Selects the current input cursor's line from a terminal snapshot. Readiness +/// must not be inferred from a prompt retained elsewhere in terminal history. +fn ready_input(screen: &str) -> Option<(&str, &str)> { + let mut lines = screen.lines(); + let mut cursor = lines.next()?.split_whitespace(); + if cursor.next()? != "1" { + return None; + } + let row = cursor.next()?.parse::().ok()?; + let title = cursor.next()?; + if cursor.next().is_some() { + return None; + } + lines.nth(row).map(|line| (line, title)) +} + +/// Delivers operator input to a running tmux Session. +/// +/// Input is held for [`INPUT_READY_GRACE`] after the latest reported hook +/// event unless the harness is waiting for input (see [`input_ready_in`]). The +/// prompt is then written to a Sandbox file and loaded into a private tmux +/// buffer, pasted into the Session's pane with bracketed paste so newlines +/// stay literal input, then submitted with a trailing Enter after a short settling +/// interval so the TUI can consume the paste before handling submission. Bracketed paste +/// is why a multi-line prompt is not submitted line by line by the harness TUI. +/// +/// File and buffer carry a per-delivery name, so two deliveries in flight for +/// the same Session cannot overwrite each other's payload; the Session service +/// additionally serializes deliveries per Session. +async fn deliver(session: &Session, sandbox: &SandboxHandle, prompt: &str) -> Result<(), Error> { + use std::io::Cursor; + + let buffer = format!("agent-prompt-{}-{}", session.id, uuid::Uuid::new_v4()); + let file = format!("/tmp/{buffer}"); + if let Some(quiet) = input_ready_in(&session.status.reported.activity, time::OffsetDateTime::now_utc()) { + tokio::time::sleep(quiet).await; + } + tokio::time::timeout( + LIFECYCLE_EXECUTION_TIMEOUT, + sandbox.write_file( + &SandboxPath::new(file.clone()), + Box::pin(Cursor::new(prompt.as_bytes().to_vec())), + ), + ) + .await + .map_err(|_| Error::Session("writing the prompt into the Sandbox timed out".into()))??; + let delivered = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + include_str!("deliver.sh").into(), + "agent-session-deliver".into(), + file, + buffer, + pane_target(session), + ], + ), + "prompt delivery", + ) + .await + .map_err(|error| match error { + Error::Session(message) if message == "prompt delivery timed out" => Error::Session( + "prompt delivery timed out; the prompt may have reached the harness, so check turns before retrying".into(), + ), + error => error, + })?; + if delivered.status.success() { + Ok(()) + } else { + Err(Error::Session(format!( + "tmux failed to deliver input to Session {} with exit code {}", + session.id, delivered.status.code + ))) + } +} + +/// How much longer to hold input for a harness whose latest hook event is +/// recent, or `None` when it can take input now: a harness waiting for input +/// is ready by definition, and one whose last event is older than +/// [`INPUT_READY_GRACE`] has had its input loop up for at least that long. +fn input_ready_in(activity: &Activity, now: time::OffsetDateTime) -> Option { + if activity.phase == Phase::WaitingForInput { + return None; + } + let last = activity.last_event_at?; + let quiet = now - last; + let grace = time::Duration::try_from(INPUT_READY_GRACE).ok()?; + if quiet >= grace { + None + } else { + std::time::Duration::try_from(grace - quiet).ok() + } +} + +/// Reads the harness transcript the Session reported and parses it into turns. +/// +/// The path travels as a positional argument, never interpolated into shell +/// text. A harness that has not reported a transcript yet, or has reported one +/// it has not created yet, has an empty conversation. +async fn turns(session: &Session, sandbox: &SandboxHandle, last: Option) -> Result, Error> { + const MAX_TRANSCRIPT_BYTES: usize = 2 * 1024 * 1024; + const SCRIPT: &str = "[ -f \"$1\" ] || exit 0; exec /usr/bin/tail -c \"$2\" -- \"$1\""; + let Some(path) = session.status.reported.harness_transcript_path.as_deref() else { + return Ok(Vec::new()); + }; + if last == Some(0) { + return Ok(Vec::new()); + } + let read = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + SCRIPT.into(), + "agent-session-transcript".into(), + path.into(), + (MAX_TRANSCRIPT_BYTES + 1).to_string(), + ], + ), + "reading the Session conversation", + ) + .await?; + if !read.status.success() { + return Err(Error::Session(format!( + "reading the conversation of Session {} failed with exit code {}", + session.id, read.status.code + ))); + } + parse_transcript_suffix(session.harness, &read.stdout, last, MAX_TRANSCRIPT_BYTES) +} + +fn parse_transcript_suffix( + kind: crate::Harness, + bytes: &[u8], + last: Option, + max_bytes: usize, +) -> Result, Error> { + let truncated = bytes.len() > max_bytes; + if truncated && last.is_none() { + return Err(Error::Session(format!( + "conversation exceeds the {} MiB read limit; retry with --last", + max_bytes / 1024 / 1024 + ))); + } + let bytes = if truncated { + let after_partial_line = bytes + .iter() + .position(|byte| *byte == b'\n') + .map_or_else(|| &bytes[bytes.len()..], |newline| &bytes[newline + 1..]); + harness::trim_partial_transcript(kind, after_partial_line) + } else { + bytes + }; + let mut turns = harness::parse_transcript(kind, bytes)?; + if let Some(last) = last { + if truncated && turns.len() < last { + return Err(Error::Session(format!( + "the last {last} complete turns do not fit within the {} MiB transcript read limit; request fewer turns", + max_bytes / 1024 / 1024 + ))); + } + if turns.len() > last { + turns.drain(0..turns.len() - last); + } + } + Ok(turns) +} + +#[cfg(test)] +mod tests { + use sandbox::execution::ExitStatus; + use time::OffsetDateTime; + + use crate::{ + harness, + sessions::{Activity, Lifecycle, Part, Phase, Reported, Status}, + }; + + use super::{Observation, Session, input_ready_in, parse_transcript_suffix}; + + #[test] + fn a_truncated_suffix_starts_at_the_first_complete_turn() { + let transcript = concat!( + "partial record\n", + r#"{"type":"assistant","message":{"id":"old","content":[{"type":"text","text":"partial answer"}]}}"#, + "\n", + r#"{"type":"user","message":{"content":"latest prompt"}}"#, + "\n", + r#"{"type":"assistant","message":{"id":"new","content":[{"type":"text","text":"latest answer"}]}}"#, + "\n", + ); + let turns = parse_transcript_suffix( + harness::test_harness(), + transcript.as_bytes(), + Some(1), + transcript.len() - 1, + ) + .expect("last complete turn"); + assert!(matches!( + turns[0].messages[0].parts[0], + Part::Text { ref text } if text == "latest prompt" + )); + } + + #[test] + fn a_truncated_transcript_requires_a_satisfiable_last_bound() { + let transcript = concat!( + "partial record\n", + r#"{"type":"user","message":{"content":"only complete prompt"}}"#, + "\n", + ); + let max_bytes = transcript.len() - 1; + + let unbounded = parse_transcript_suffix(harness::test_harness(), transcript.as_bytes(), None, max_bytes) + .expect_err("unbounded truncated transcript"); + assert!(unbounded.to_string().contains("retry with --last")); + + let too_many = parse_transcript_suffix(harness::test_harness(), transcript.as_bytes(), Some(2), max_bytes) + .expect_err("too many complete turns"); + assert!(too_many.to_string().contains("request fewer turns")); + } + + #[test] + #[ignore = "requires Node.js and tmux; exercises input in an isolated terminal server"] + fn delivery_and_transcript_freshness_in_a_real_terminal() { + let output = std::process::Command::new("node") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/tmux_delivery.mjs")) + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/src/sessions/runtime/deliver.sh")) + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/src/sessions/runtime/observe.sh")) + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/src/sessions/runtime/stop.sh")) + .output() + .expect("Node.js"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn readiness_requires_a_visible_cursor_on_the_current_line() { + assert_eq!( + super::ready_input("1 1 title\nold prompt\ncurrent input\n"), + Some(("current input", "title")) + ); + assert_eq!(super::ready_input("0 1 title\nold prompt\ncurrent input\n"), None); + assert_eq!(super::ready_input("1 8 title\nold prompt\n"), None); + assert_eq!(super::ready_input("invalid\nold prompt\n"), None); + } + + #[test] + fn input_waits_out_the_grace_after_a_recent_event_unless_the_harness_is_waiting() { + let now = time::OffsetDateTime::from_unix_timestamp(10_000).expect("timestamp"); + let just_started = Activity { + phase: Phase::Working, + last_event_at: Some(now - time::Duration::milliseconds(500)), + ..Activity::default() + }; + assert_eq!( + input_ready_in(&just_started, now), + Some(std::time::Duration::from_millis(1_500)) + ); + let quiet = Activity { + phase: Phase::Working, + last_event_at: Some(now - time::Duration::seconds(30)), + ..Activity::default() + }; + assert_eq!(input_ready_in(&quiet, now), None); + let waiting = Activity { + phase: Phase::WaitingForInput, + ..just_started + }; + assert_eq!( + input_ready_in(&waiting, now), + None, + "a completed turn means the input loop is up" + ); + assert_eq!( + input_ready_in(&Activity::default(), now), + None, + "nothing reported yet imposes no grace" + ); + } + + #[test] + fn parses_guest_calculated_idle_age() { + let Observation::Alive { attached, idle_seconds } = + super::parse_observation("0 301\n").expect("valid observation") + else { + panic!("expected a live Session"); + }; + assert!(!attached); + assert_eq!(idle_seconds, 301); + } + + #[test] + fn distinguishes_a_missing_session_from_an_observation_failure() { + assert!(matches!( + super::classify_observation(ExitStatus { code: 10 }, &[]).expect("missing observation"), + Observation::Missing + )); + + let Err(error) = super::classify_observation(ExitStatus { code: 2 }, &[]) else { + panic!("tmux failure must not look like a missing Session"); + }; + assert!(error.to_string().contains("exit code 2")); + } + + fn test_session(model_selection: crate::ModelSelection) -> Session { + Session { + id: "dd4cdbaf-9ea0-477e-96dd-bbd6b1e4f7dc".parse().expect("Session ID"), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + agent: "worker".into(), + name: "s1".to_string().try_into().expect("Session name"), + harness: crate::harness::test_harness(), + model_selection, + created_at: OffsetDateTime::UNIX_EPOCH, + deletion_timestamp: None, + archived_at: None, + status: Status::new(Lifecycle::running(), Reported::default()), + activation_generation: 0, + observed_activation_generation: 0, + } + } + + #[test] + fn every_launch_carries_the_recorded_model_selection() { + let selection = crate::ModelSelection { + model: Some(crate::Model::new("haiku").expect("model")), + effort: Some(crate::Effort::new("low").expect("effort")), + }; + let session = test_session(selection); + let token = super::LaunchToken::generate(); + for resume in [None, Some("160cdb4b-5997-464c-9d22-602786eb45d4")] { + let arguments = super::launch_arguments(&session, "http://hook", &token, resume, None); + let command = arguments.last().expect("tmux command"); + assert!(command.contains("'haiku'") && command.contains("'low'"), "{command}"); + assert_eq!(command.contains("--resume"), resume.is_some(), "{command}"); + } + let plain = super::launch_arguments( + &test_session(crate::ModelSelection::default()), + "http://hook", + &token, + None, + None, + ); + assert!(!plain.last().expect("tmux command").contains("haiku")); + } + + #[test] + fn terminal_options_precede_creation_and_attachment() { + let session = test_session(crate::ModelSelection::default()); + let options = super::terminal_options(); + let launch = super::launch_arguments(&session, "http://hook", &super::LaunchToken::generate(), None, None); + assert!(launch.starts_with(&options)); + assert_eq!(launch[options.len()], "new-session"); + let attach = super::attach_arguments(&session); + assert!(attach.starts_with(&options)); + assert_eq!( + &attach[options.len()..options.len() + 6], + ["set-option", "-t", &super::pane_target(&session), "mouse", "on", ";"] + ); + assert_eq!(attach[options.len() + 6], "attach-session"); + } + + #[test] + #[cfg(target_os = "linux")] + #[ignore = "requires Node.js, tmux and script; exercises scrollback in an isolated terminal server"] + fn scrollback_in_a_real_terminal() { + let session = test_session(crate::ModelSelection::default()); + let output = std::process::Command::new("node") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/tmux_scrollback.mjs")) + .arg(serde_json::to_string(&super::terminal_options()).expect("options")) + .arg(serde_json::to_string(&super::attach_arguments(&session)).expect("attachment")) + .arg(super::session_name(&session)) + .output() + .expect("Node.js"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + #[cfg(target_os = "linux")] + #[ignore = "requires Node.js, tmux and script; exercises Ctrl-Z in an isolated terminal server"] + fn suspend_is_refused_in_a_real_terminal() { + let session = test_session(crate::ModelSelection::default()); + let output = std::process::Command::new("node") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/tmux_suspend.mjs")) + .arg(serde_json::to_string(&super::terminal_options()).expect("options")) + .arg(serde_json::to_string(&super::attach_arguments(&session)).expect("attachment")) + .arg(super::session_name(&session)) + .output() + .expect("Node.js"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn attachment_uses_portable_utf8_terminal_environment() { + let session = test_session(crate::ModelSelection::default()); + + let request = super::attach_request(&session); + let spec = request.spec(); + + assert_eq!(spec.environment().get("LANG").map(String::as_str), Some("C.UTF-8")); + assert_eq!( + spec.environment().get("TERM").map(String::as_str), + Some("xterm-256color") + ); + assert_eq!(request.detach_keys(), Some("ctrl-b,d")); + } +} diff --git a/agentctl/src/sessions/sandboxes.rs b/agentctl/src/sessions/sandboxes.rs new file mode 100644 index 0000000..38ee81c --- /dev/null +++ b/agentctl/src/sessions/sandboxes.rs @@ -0,0 +1,52 @@ +//! Resolving a Session's owning Agent to its materialized Sandbox. +//! +//! The reconciler and the Session service both need "the Sandbox this +//! Session's Agent runs in"; this is the one place that lookup lives. + +use std::rc::Rc; + +use ::sandbox::SandboxHandle; + +use crate::{AgentId, Error, control_plane::AgentRecord, control_plane::AgentStore}; + +/// Agent records and their Sandboxes, as one lookup. +pub struct AgentSandboxes { + agents: Rc, + sandboxes: Rc, +} + +impl AgentSandboxes { + /// Pairs the Agent store with the Sandbox service. + #[must_use] + pub fn new(agents: Rc, sandboxes: Rc) -> Self { + Self { agents, sandboxes } + } + + /// Gets the active Agent record by name. + /// + /// # Errors + /// + /// Returns an error when no active Agent has that name. + pub async fn agent_by_name(&self, name: &str) -> Result { + self.agents.get_by_name(name).await + } + + /// Gets an Agent record by identity. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist. + pub async fn agent(&self, id: AgentId) -> Result { + self.agents.get(id).await + } + + /// Opens the materialized Sandbox of `record`. + /// + /// # Errors + /// + /// Returns an error when the Agent has no materialized Sandbox or its + /// Provider cannot open it. + pub async fn open(&self, record: &AgentRecord) -> Result { + self.sandboxes.open(record).await + } +} diff --git a/agentctl/src/sessions/service.rs b/agentctl/src/sessions/service.rs new file mode 100644 index 0000000..e0d6424 --- /dev/null +++ b/agentctl/src/sessions/service.rs @@ -0,0 +1,621 @@ +//! User-facing Session operations coordinated with the reconciler. + +use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration}; + +use ::sandbox::SandboxHandle; +use tokio::sync::Notify; + +use crate::{Error, control_plane, control_plane::WaitPolicy}; + +use super::{ + AgentSandboxes, AttachTarget, LifecycleState, NewSession, Session, SessionId, SessionName, SessionRequest, + SessionRuntime, SharedStore, State, Turn, Wakeup, +}; + +/// Ceiling for completion waiting after prompt submission. +const PROMPT_TIMEOUT_MAX: Duration = Duration::from_mins(30); + +/// Polls durable activity while a caller waits for completion. +const ACTIVITY_POLL: Duration = Duration::from_millis(250); + +/// Maximum time to wait for a newly launched harness to accept input. +const INPUT_READY_TIMEOUT: Duration = Duration::from_secs(15); +const INPUT_READY_POLL: Duration = Duration::from_millis(100); +const UPGRADE_SESSION_PASS_TIMEOUT: Duration = Duration::from_mins(1); +const UPGRADE_SANDBOX_INSPECTION_TIMEOUT: Duration = Duration::from_secs(5); + +/// Sessions that block an upgrade and Sessions that will restart without resumption. +#[derive(Debug, Default, Eq, PartialEq)] +pub struct UpgradeReadiness { + /// Active work or attachments that make the transition unsafe. + pub blockers: Vec, + /// Quiescent Sessions without a harness-native conversation to resume. + pub warnings: Vec, +} + +/// Durable Session registry whose effects are owned by the daemon controller. +pub struct Service { + store: SharedStore, + sandboxes: Rc, + runtime: Rc, + convergence: control_plane::Convergence, + wakeup: Wakeup, + /// Sessions with a delivery in flight, each with the signal its waiters + /// sleep on. Two concurrent prompts would interleave their keystrokes in + /// the harness's single input line, so deliveries are serialized per Session. + deliveries: RefCell>>, +} + +/// Marks one Session busy delivering for as long as it lives; dropping it, +/// including on cancellation, releases the Session and wakes the next sender. +struct Delivering<'a> { + deliveries: &'a RefCell>>, + session: SessionId, +} + +impl<'a> Delivering<'a> { + async fn acquire(deliveries: &'a RefCell>>, session: SessionId) -> Self { + loop { + let busy = { + let mut map = deliveries.borrow_mut(); + if let Some(released) = map.get(&session) { + released.clone() + } else { + map.insert(session, Rc::new(Notify::new())); + break; + } + }; + busy.notified().await; + } + Self { deliveries, session } + } +} + +impl Drop for Delivering<'_> { + fn drop(&mut self) { + if let Some(released) = self.deliveries.borrow_mut().remove(&self.session) { + released.notify_waiters(); + } + } +} + +impl Service { + /// Creates a Session service over durable storage, Agent Sandboxes, + /// Agent convergence and the Session controller. + #[must_use] + pub fn new( + store: SharedStore, + sandboxes: Rc, + runtime: Rc, + convergence: control_plane::Convergence, + wakeup: Wakeup, + ) -> Self { + Self { + store, + sandboxes, + runtime, + convergence, + wakeup, + deliveries: RefCell::default(), + } + } + + /// Creates or gets one named Session and waits until its driver is ready. + /// + /// `request` applies only when this call creates the Session. Its harness, + /// model and effort resolve in that order of precedence: the explicit + /// request, then the selected installation's manifest defaults, then the + /// harness's own defaults; the resolved values are recorded with the + /// Session. The initial prompt is handed to the harness at its first + /// launch, so the harness starts working before this call returns. + /// + /// # Errors + /// + /// Returns an error when persistence fails, the Agent is invalid, or an + /// explicit selection conflicts with an existing Session; with + /// [`WaitPolicy::FirstPass`] also when the single Agent pass fails. + pub async fn ensure( + &self, + agent: &str, + name: &SessionName, + request: SessionRequest, + wait: WaitPolicy, + ) -> Result { + let (owner, session) = self.prepare(agent, name, request).await?; + let converged = self.convergence.converge(agent, wait).await?; + if converged.id != owner.id { + // The Agent was deleted and its name reused while this request waited. + return Err(Error::Conflict); + } + converged.reject_stopped()?; + // On a brand-new Agent this is the first moment the answer exists. + Self::reject_omitted_optional_harness(&converged, session.harness)?; + self.wakeup.reconcile(session.id).await?; + self.store.session_attach_target(session.id).await + } + + /// Delivers a prompt to a running Session's harness. + /// + /// With `wait`, snapshots the completed-turn counter before delivery and + /// waits for it to advance with identical waiting activity in two consecutive + /// polls, 250 ms apart. Work observed during settling requires another + /// completion. This is a timing heuristic, not identification of an answer + /// to this prompt. + /// Read the conversation separately with [`Self::turns`]. + /// + /// In both modes delivery waits for input readiness. The runtime may establish + /// readiness before the harness reports its first conversation. The completion + /// timeout starts after submission; queuing, readiness and delivery are excluded. + /// Activity is polled from the local database every 250 ms. + /// + /// # Errors + /// + /// Returns an error when the Session is not running, the harness has not + /// become ready for input within a short grace period, the input cannot be + /// delivered, the Session fails mid-turn, or the wait exceeds `timeout`. + pub async fn prompt( + &self, + agent: &str, + name: &SessionName, + prompt: &str, + wait: bool, + timeout: Option, + ) -> Result<(), Error> { + if timeout.is_some_and(|timeout| timeout > PROMPT_TIMEOUT_MAX) { + return Err(Error::Invalid(format!( + "completion timeout must not exceed {}m", + PROMPT_TIMEOUT_MAX.as_secs() / 60 + ))); + } + let (session, sandbox) = self.open_running(agent, name).await?; + let id = session.id; + let delivering = Delivering::acquire(&self.deliveries, id).await; + let session = self.ready_to_prompt(id, name, &sandbox).await?; + let completed_before = session.status.reported.activity.turns; + self.runtime.prompt(&session, &sandbox, prompt).await?; + drop(delivering); + if !wait { + return Ok(()); + } + tokio::time::timeout( + timeout.unwrap_or(PROMPT_TIMEOUT_MAX), + self.wait_for_completion(id, name, completed_before), + ).await.map_err(|_| Error::Session(format!( + "timed out waiting for Session \"{name}\" to complete; the prompt was submitted; inspect turns before retrying" + )))? + } + + async fn wait_for_completion( + &self, + id: SessionId, + name: &SessionName, + mut completed_before: u64, + ) -> Result<(), Error> { + let mut settling = None; + loop { + let current = self.store.get_session(id).await?; + let activity = ¤t.status.reported.activity; + let waiting = match current.status.state { + State::Failed => { + return Err(Error::Session(format!( + "Session \"{name}\" failed while waiting for turn completion: {}", + current.status.lifecycle.failure.as_deref().unwrap_or("unknown error") + ))); + } + State::Idle => { + return Err(Error::Session(format!( + "Session \"{name}\" was stopped while waiting for turn completion" + ))); + } + State::Archived => { + return Err(Error::Session(format!( + "Session \"{name}\" was archived while waiting for turn completion" + ))); + } + State::WaitingForInput => true, + // An archive that has not stopped the harness yet leaves the turn to its own report. + State::Archiving => activity.phase == super::Phase::WaitingForInput, + State::Starting | State::Working => false, + }; + if activity.turns > completed_before && waiting { + if settling.as_ref() == Some(activity) { + return Ok(()); + } + settling = Some(activity.clone()); + } else { + // A new turn can already be running when the previous completion + // is observed. Its permission waits must not satisfy this wait. + completed_before = completed_before.max(activity.turns); + settling = None; + } + tokio::time::sleep(ACTIVITY_POLL).await; + } + } + + /// Waits for a report or runtime-observed input readiness. A harness may + /// create its conversation only after input arrives, so the first prompt + /// cannot depend on that conversation's start report. + async fn ready_to_prompt( + &self, + id: SessionId, + name: &SessionName, + sandbox: &SandboxHandle, + ) -> Result { + tokio::time::timeout(INPUT_READY_TIMEOUT, async { + loop { + let session = self.store.get_session(id).await?; + match session.status.state { + State::Working | State::WaitingForInput => return Ok(session), + State::Idle | State::Archiving | State::Archived | State::Failed => { + return Err(session.not_running_error()); + } + State::Starting => { + if self.runtime.input_ready(&session, sandbox).await? { + return Ok(session); + } + } + } + tokio::time::sleep(INPUT_READY_POLL).await; + } + }) + .await + .map_err(|_| Error::Session(format!("timed out waiting for Session \"{name}\" to accept input")))? + } + + /// Reads the Session's conversation as ordered turns, optionally the last `last`. + /// + /// # Errors + /// + /// Returns an error when the Session or its Sandbox is unavailable or the + /// conversation cannot be read. + pub async fn turns(&self, agent: &str, name: &SessionName, last: Option) -> Result, Error> { + let session = self.visible(agent, name).await?; + let owner = self.sandboxes.agent(session.agent_id).await?; + owner.reject_stopped()?; + let sandbox = self.sandboxes.open(&owner).await?; + let session = self.store.get_session(session.id).await?; + self.runtime.turns(&session, &sandbox, last).await + } + + async fn open_running(&self, agent: &str, name: &SessionName) -> Result<(Session, SandboxHandle), Error> { + let session = self.visible(agent, name).await?; + if session.is_archived() { + return Err(session.archived_error()); + } + let owner = self.sandboxes.agent(session.agent_id).await?; + owner.reject_stopped()?; + if session.status.lifecycle.state != LifecycleState::Running { + return Err(session.not_running_error()); + } + let sandbox = self.sandboxes.open(&owner).await?; + Ok((session, sandbox)) + } + + /// Refuses a Session on an optional installation this Agent's Sandbox does not carry. + /// + /// Reports the reason to the caller; the Session reconciler enforces it. Before the Sandbox is + /// materialized nothing is known, so the decision is deferred to the next attach. + fn reject_omitted_optional_harness( + owner: &control_plane::AgentRecord, + harness: crate::Harness, + ) -> Result<(), Error> { + let Some(installation) = owner.agent.spec.harness(harness) else { + return Ok(()); + }; + let Some(crate::sandbox::Assignment::Materialized { harnesses, .. }) = &owner.agent.status.sandbox else { + return Ok(()); + }; + if !installation.optional || harnesses.contains(&harness) { + return Ok(()); + } + Err(Error::Invalid(format!( + "Agent {:?} declares harness {:?} as optional and it is not installed, because its \ + host login is absent; sign in on the host and the next Agent convergence installs it", + owner.agent.metadata.name, + installation.kind.as_str() + ))) + } + + async fn prepare( + &self, + agent: &str, + name: &SessionName, + request: SessionRequest, + ) -> Result<(control_plane::AgentRecord, Session), Error> { + let owner = self.sandboxes.agent_by_name(agent).await?; + if owner.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + owner.reject_stopped()?; + if let Some(harness) = request.harness + && owner.agent.spec.harness(harness).is_none() + { + return Err(Error::Invalid(format!( + "Agent {agent:?} does not declare harness {:?}", + harness.as_str() + ))); + } + let existing = match self.visible(agent, name).await { + Ok(session) => { + if session.is_archived() { + return Err(Error::Invalid(format!( + "Session \"{name}\" is archived; unarchive it before attaching or prompting" + ))); + } + reject_conflicting_selections(name, &session, &request)?; + Some(session) + } + Err(Error::NotFound) => None, + Err(error) => return Err(error), + }; + let harness = match (&existing, request.harness) { + (Some(session), _) => session.harness, + (None, Some(harness)) => harness, + (None, None) => { + owner + .agent + .spec + .default_harness() + .ok_or_else(|| Error::Invalid(format!("Agent {agent:?} has no default harness")))? + .kind + } + }; + // Validated before the Session is persisted: a Session name is bound to its harness for the + // life of the Session, so a refused attempt must not leave the name claimed. + Self::reject_omitted_optional_harness(&owner, harness)?; + let session = if let Some(session) = existing { + session + } else { + let installation = owner.agent.spec.harness(harness).ok_or_else(|| { + Error::Invalid(format!( + "Agent {agent:?} does not declare harness {:?}", + harness.as_str() + )) + })?; + if let Some(initial_prompt) = &request.initial_prompt { + crate::harness::validate_initial_prompt(initial_prompt)?; + } + let new = NewSession { + initial_prompt: request.initial_prompt, + ..NewSession::resolved(installation.kind, request.model_selection, &installation.defaults) + }; + self.store.ensure_session(agent, name, new).await? + }; + if session.agent_id != owner.id { + return Err(Error::Conflict); + } + self.store.activate_session(session.id).await?; + Ok((owner, session)) + } + + /// Gets one durable Session from the active Agent incarnation. + /// + /// # Errors + /// + /// Returns an error when either resource is missing or persistent state cannot be read. + pub async fn get(&self, agent: &str, name: &SessionName) -> Result { + self.visible(agent, name).await + } + + /// Lists durable Sessions, optionally scoped to one active Agent incarnation. + /// + /// # Errors + /// + /// Returns an error when the scoped Agent is missing or persistent state cannot be read. + pub async fn list(&self, agent: Option<&str>) -> Result, Error> { + let Some(agent) = agent else { + return self.live_sessions().await; + }; + self.sandboxes.agent_by_name(agent).await?; + Ok(live(self.store.list_agent_sessions(agent).await?)) + } + + /// Releases one Session: its harness is stopped and the Session is removed. + /// + /// The request is recorded first, so a Session that cannot be released yet + /// stays marked and is retried by the Session controller instead of leaving + /// a harness running with nothing tracking it. The Session is no longer + /// listed or resolvable by name from the moment it is marked, and its name + /// becomes available again once the harness is gone. Repeating the request + /// while the release is still pending is safe. + /// + /// # Errors + /// + /// Returns an error when the Agent or Session is missing, the request + /// cannot be recorded, or the release pass fails; the marker survives a + /// failed pass. + pub async fn delete(&self, agent: &str, name: &SessionName) -> Result<(), Error> { + let session = self.store.mark_session_deleting(agent, name).await?; + self.wakeup.reconcile(session.id).await.map_err(|error| { + Error::Session(format!( + "Session \"{name}\" is marked for deletion and will be retried; stopping its harness failed: {error}" + )) + }) + } + + /// Archives or unarchives one Session and returns it as recorded. + /// + /// Archiving stops the harness and keeps it stopped, once any turn in + /// progress has ended; the Session keeps its name and conversation. + /// Unarchiving leaves it Idle, so the next attach resumes it. Repeating + /// either is safe. + /// + /// # Errors + /// + /// Returns an error when the Agent or Session is missing, the request + /// cannot be recorded, or the pass fails; the request survives a failed pass. + pub async fn set_archived(&self, agent: &str, name: &SessionName, archived: bool) -> Result { + let session = self.store.set_session_archived(agent, name, archived).await?; + self.wakeup.reconcile(session.id).await?; + self.store.get_session(session.id).await + } + + /// Every Session that is not being deleted. One already on its way out + /// is gone as far as listings and upgrades are concerned. + async fn live_sessions(&self) -> Result, Error> { + Ok(live(self.store.list_all_sessions().await?)) + } + + /// Resolves a Session a caller may still act on. A Session marked for + /// release is already gone as far as its name is concerned. + async fn visible(&self, agent: &str, name: &SessionName) -> Result { + let session = self.store.get_agent_session(agent, name).await?; + if session.is_deleting() { + return Err(Error::NotFound); + } + Ok(session) + } + + /// Lists active work and terminal attachments that must finish before an upgrade. + /// + /// # Errors + /// + /// Returns an error when the durable Session or Sandbox state cannot be inspected. + pub async fn upgrade_readiness(&self) -> Result { + tokio::time::timeout(UPGRADE_SESSION_PASS_TIMEOUT, self.inspect_upgrade_readiness()) + .await + .map_err(|_| Error::Session("timed out checking Sessions before upgrade".into()))? + } + + async fn inspect_upgrade_readiness(&self) -> Result { + let mut readiness = UpgradeReadiness::default(); + for session in self.live_sessions().await? { + let label = format!("session/{}/{}", session.agent, session.name); + // The harness's own state, so an archive waiting for a turn to end + // still holds the upgrade back until it has. + let state = session.status.harness_state(); + if state == State::Working { + readiness.blockers.push(format!("{label} (working)")); + continue; + } + if state == State::Starting && session.status.reported.harness_session_id.is_some() { + readiness.blockers.push(format!("{label} (starting)")); + continue; + } + let Some(sandbox) = self.upgrade_sandbox(&session).await? else { + if state == State::Starting { + readiness + .warnings + .push(format!("{label} will start a new conversation")); + } + continue; + }; + match self.runtime.observe(&session, &sandbox).await? { + super::runtime::Observation::Alive { attached: true, .. } => { + readiness.blockers.push(format!("{label} (terminal attached)")); + } + super::runtime::Observation::Alive { attached: false, .. } + if session.status.reported.harness_session_id.is_none() => + { + readiness + .warnings + .push(format!("{label} will start a new conversation")); + } + super::runtime::Observation::Missing if state == State::Starting => { + readiness + .warnings + .push(format!("{label} will start a new conversation")); + } + super::runtime::Observation::Missing | super::runtime::Observation::Alive { .. } => {} + } + } + Ok(readiness) + } + + /// Stops quiescent Session runtimes once after a software upgrade so normal + /// reconciliation relaunches them with the current harness hooks. + /// + /// # Errors + /// + /// Returns an error, without clearing the caller-owned marker, when a + /// running Sandbox cannot be inspected or a Session becomes attached. + pub async fn relaunch_after_upgrade(&self) -> Result<(), Error> { + tokio::time::timeout(UPGRADE_SESSION_PASS_TIMEOUT, self.relaunch_sessions()) + .await + .map_err(|_| Error::Session("timed out relaunching Sessions after upgrade".into()))? + } + + async fn relaunch_sessions(&self) -> Result<(), Error> { + // An archived Session stays stopped, even one still finishing its last turn. + for session in self + .live_sessions() + .await? + .into_iter() + .filter(|session| !session.is_archived()) + { + let Some(sandbox) = self.upgrade_sandbox(&session).await? else { + self.store.reset_session_launch_attempts(session.id).await?; + continue; + }; + match self.runtime.observe(&session, &sandbox).await? { + super::runtime::Observation::Missing => { + self.store.activate_session(session.id).await?; + } + super::runtime::Observation::Alive { attached: true, .. } => { + return Err(Error::Session(format!( + "Session \"{}/{}\" became attached during upgrade", + session.agent, session.name + ))); + } + super::runtime::Observation::Alive { attached: false, .. } => { + // Persist the relaunch request before removing an Idle runtime. + self.store.activate_session(session.id).await?; + self.runtime.stop(&session, &sandbox).await?; + } + } + self.store.reset_session_launch_attempts(session.id).await?; + } + Ok(()) + } + + async fn upgrade_sandbox(&self, session: &Session) -> Result, Error> { + let owner = self.sandboxes.agent(session.agent_id).await?; + if !matches!( + owner.agent.status.sandbox, + Some(crate::sandbox::Assignment::Materialized { .. }) + ) { + return Ok(None); + } + let opened = tokio::time::timeout(UPGRADE_SANDBOX_INSPECTION_TIMEOUT, self.sandboxes.open(&owner)) + .await + .map_err(|_| { + Error::Session(format!( + "timed out inspecting the Sandbox for Session \"{}\"", + session.name + )) + })?; + let sandbox = match opened { + Ok(sandbox) => sandbox, + Err(Error::Sandbox(error)) if error.is_not_found() => return Ok(None), + Err(error) => return Err(error), + }; + if sandbox.snapshot().state == ::sandbox::SandboxState::Stopped { + return Ok(None); + } + Ok(Some(sandbox)) + } +} + +/// Leaves out Sessions that are being deleted. +fn live(sessions: Vec) -> Vec { + sessions.into_iter().filter(|session| !session.is_deleting()).collect() +} + +/// An existing Session keeps its recorded harness, model and effort; only an +/// explicit, differing request is an error, so a manifest default that changed +/// after creation never conflicts with relaunching or attaching. +fn reject_conflicting_selections(name: &SessionName, session: &Session, request: &SessionRequest) -> Result<(), Error> { + if let Some(harness) = request.harness + && harness != session.harness + { + return Err(Error::Invalid(format!( + "Session \"{name}\" already uses harness {:?}, not {:?}", + session.harness.as_str(), + harness.as_str() + ))); + } + if let Some(conflict) = session.model_selection.conflict_with(&request.model_selection) { + return Err(Error::Invalid(format!("Session \"{name}\" {conflict}"))); + } + Ok(()) +} diff --git a/agentctl/src/sessions/transcript.rs b/agentctl/src/sessions/transcript.rs new file mode 100644 index 0000000..8c2ff67 --- /dev/null +++ b/agentctl/src/sessions/transcript.rs @@ -0,0 +1,50 @@ +//! Runtime-neutral conversation turns read from a Session. + +use serde::{Deserialize, Serialize}; + +/// One operator prompt and everything the harness produced in response. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Turn { + /// Messages that make up the turn, in order. + pub messages: Vec, +} + +/// One message inside a [`Turn`]. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Message { + /// Who authored the message. + pub role: Role, + /// Ordered content parts. + pub parts: Vec, +} + +/// Author of a [`Message`]. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Role { + /// The operator (or an upstream orchestrator). + User, + /// The harness model. + Assistant, +} + +/// One content part of a [`Message`], shaped after AHP chat parts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Part { + /// Plain assistant or operator text. + Text { + /// The text content. + text: String, + }, + /// A tool invocation and, when known, whether it failed. + ToolCall { + /// Harness-native tool name. + name: String, + /// Whether the recorded result was an error. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + failed: bool, + }, +} diff --git a/agentctl/src/ssh/client_config.rs b/agentctl/src/ssh/client_config.rs new file mode 100644 index 0000000..fcdce6a --- /dev/null +++ b/agentctl/src/ssh/client_config.rs @@ -0,0 +1,635 @@ +//! OpenSSH client configuration files owned by the platform. +//! +//! `agentd` rewrites `/ssh/config` and `/ssh/known_hosts` as Agents +//! are applied and deleted; `agentctl ssh-config install` inserts one `Include` +//! of that config at the top of the user's own `~/.ssh/config` and touches +//! nothing else in it. + +use std::{ + fmt::Write as _, + path::{Path, PathBuf}, +}; + +use crate::Error; + +/// One `Host` block in the generated client configuration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostEntry { + /// `Host` alias, `agentctl-`. + pub alias: String, + /// Guest user. + pub user: String, + /// Complete `ProxyCommand` value. + pub proxy_command: String, + /// `HostKeyAlias`, the stable incarnation-keyed `known_hosts` name. + pub host_key_alias: String, + /// Absolute private key path. + pub identity_file: PathBuf, + /// Absolute `known_hosts` path. + pub known_hosts_file: PathBuf, +} + +/// Renders the platform-owned client configuration for every SSH-enabled Agent. +#[must_use] +pub fn render_config(entries: &[HostEntry], user_home: Option<&Path>) -> String { + let mut text = String::from( + "# Generated by agentd for `agentctl ssh`; every edit is overwritten.\n\ + # Include it from your own client configuration with `agentctl ssh-config install`.\n", + ); + for entry in entries { + let _infallible = writeln!( + text, + "\nHost {}\n User {}\n ProxyCommand {}\n HostKeyAlias {}\n IdentityFile {}\n UserKnownHostsFile {}\n IdentitiesOnly yes", + entry.alias, + entry.user, + entry.proxy_command, + entry.host_key_alias, + render_path(&entry.identity_file, user_home), + render_path(&entry.known_hosts_file, user_home), + ); + } + text +} + +/// Renders a host path for `IdentityFile`, `UserKnownHostsFile` or `Include`. +/// +/// A path below the user's home is written `~/...` with forward slashes, which +/// every OpenSSH port expands; anything else is written as the host spells it. +/// Anything outside a conservative character set is double-quoted with `"` and +/// `\` escaped, which is what OpenSSH's tokenizer understands, and `%` is +/// doubled because OpenSSH percent-expands all three directives. +#[must_use] +pub fn render_path(path: &Path, user_home: Option<&Path>) -> String { + let text = user_home + .and_then(|home| path.strip_prefix(home).ok()) + .filter(|relative| !relative.as_os_str().is_empty()) + .map_or_else( + || path.display().to_string(), + |relative| { + let mut text = String::from("~"); + for component in relative.components() { + text.push('/'); + text.push_str(&component.as_os_str().to_string_lossy()); + } + text + }, + ); + quote_config_value(&text).replace('%', "%%") +} + +/// How the host runs the string OpenSSH hands to `ProxyCommand`. +/// +/// Unix OpenSSH runs it through `/bin/sh -c`; Win32-OpenSSH hands it to +/// `CreateProcess`, which knows only double quotes. `agentd` generates the +/// configuration on the host that runs `ssh`, so it picks the host's shell. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandShell { + /// `/bin/sh`: single quotes make every character literal. + Posix, + /// `CreateProcess` command-line rules: double quotes, no expansion. + Windows, +} + +impl CommandShell { + /// Returns the shell of the host this binary runs on. + #[must_use] + pub const fn host() -> Self { + if cfg!(windows) { Self::Windows } else { Self::Posix } + } +} + +/// Renders the `ProxyCommand` value dialing one Agent through `agentctl`. +/// +/// The executable is always absolute: the shell running the command on Unix +/// would expand `~`, Win32-OpenSSH would not, and neither has `agentctl` on a +/// predictable `PATH`. Three parsers see the value: OpenSSH percent-expands it +/// (`%h`, `%p`, so a literal `%` becomes `%%`), then the host's shell splits +/// it. Quoting for that shell makes `$`, backticks, `;`, spaces and quotes in +/// a user's directory name literal, so such paths work rather than being +/// refused; only characters no configuration line can carry are rejected. +/// +/// # Errors +/// +/// Returns an error when the path is not UTF-8 or contains a line break or NUL. +pub fn render_proxy_command(agentctl: &Path, agent: &str, shell: CommandShell) -> Result { + let Some(executable) = agentctl.to_str() else { + return Err(Error::Invalid(format!( + "agentctl path {} is not valid UTF-8 and cannot be written to the SSH client configuration", + agentctl.display() + ))); + }; + if executable.contains(['\n', '\r', '\0']) { + return Err(Error::Invalid(format!( + "agentctl path {executable:?} contains a line break, which no SSH configuration line can carry" + ))); + } + let quoted = match shell { + CommandShell::Posix => posix_quote(executable), + CommandShell::Windows => windows_quote(executable), + }; + Ok(format!("{} {}", quoted.replace('%', "%%"), proxy_arguments(agent))) +} + +/// The arguments every Agent's `ProxyCommand` ends with, whatever the executable. +fn proxy_arguments(agent: &str) -> String { + format!("ssh-proxy agent/{agent}") +} + +/// Returns whether OpenSSH's resolved configuration for an Agent's alias, as +/// `ssh -G` prints it, dials the Agent through `agentctl`. +/// +/// This is how the user's own client configuration is known to reach the +/// generated one: OpenSSH applies its own `Include`, `Host` and `Match` +/// rules, so however the user included it, only the outcome is checked. The +/// executable is not compared, since any `agentctl` reaches the same daemon. +#[must_use] +pub fn resolves_through_agentctl(resolved: &str, agent: &str) -> bool { + let arguments = format!(" {}", proxy_arguments(agent)); + resolved.lines().any(|line| { + line.split_once(' ') + .is_some_and(|(keyword, value)| keyword == "proxycommand" && value.trim_end().ends_with(&arguments)) + }) +} + +/// Quotes one word for `/bin/sh`: single quotes, with an embedded `'` written as `'\''`. +fn posix_quote(text: &str) -> String { + if is_plain(text) { + return text.to_owned(); + } + format!("'{}'", text.replace('\'', r"'\''")) +} + +/// Quotes one word for `CreateProcess` command-line parsing: double quotes, in +/// which a backslash is literal unless it precedes a `"`; `"` is not valid in a +/// Windows path, so it is escaped defensively. +fn windows_quote(text: &str) -> String { + if is_plain(text) { + return text.to_owned(); + } + format!("\"{}\"", text.replace('"', "\\\"")) +} + +/// Whether a token needs no quoting under any of the parsers involved. +fn is_plain(text: &str) -> bool { + !text.is_empty() + && text + .chars() + .all(|character| character.is_ascii_alphanumeric() || "/~._-:+@".contains(character)) +} + +/// Quotes an OpenSSH configuration token unless it consists only of characters +/// that need no quoting. Inside quotes `\` and `"` are backslash-escaped. +fn quote_config_value(text: &str) -> String { + if is_plain(text) { + return text.to_owned(); + } + let mut quoted = String::with_capacity(text.len() + 2); + quoted.push('"'); + for character in text.chars() { + if character == '"' || character == '\\' { + quoted.push('\\'); + } + quoted.push(character); + } + quoted.push('"'); + quoted +} + +/// Replaces every `known_hosts` line for `alias` with `public_key`. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read or rewritten. +pub fn upsert_known_host(path: &Path, alias: &str, public_key: &str) -> Result<(), Error> { + let mut lines = known_hosts_without(path, alias)?; + lines.push(format!("{alias} {public_key}")); + write_lines(path, &lines) +} + +/// Removes every `known_hosts` line for `alias`, leaving other entries untouched. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read or rewritten. +pub fn remove_known_host(path: &Path, alias: &str) -> Result<(), Error> { + if !path.exists() { + return Ok(()); + } + let lines = known_hosts_without(path, alias)?; + write_lines(path, &lines) +} + +fn known_hosts_without(path: &Path, alias: &str) -> Result, Error> { + let existing = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.into()), + }; + Ok(existing + .lines() + .filter(|line| line.split_whitespace().next() != Some(alias)) + .map(str::to_owned) + .collect()) +} + +fn write_lines(path: &Path, lines: &[String]) -> Result<(), Error> { + let mut text = lines.join("\n"); + if !text.is_empty() { + text.push('\n'); + } + write_private_file(path, text.as_bytes()) +} + +/// Writes a user-only file atomically: the content lands under a temporary +/// name beside the target and is renamed over it. +/// +/// # Errors +/// +/// Returns an error when the parent directory is missing or the file cannot be written. +pub(super) fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), Error> { + let directory = path + .parent() + .ok_or_else(|| Error::Invalid(format!("{} has no parent directory", path.display())))?; + let file_name = path + .file_name() + .ok_or_else(|| Error::Invalid(format!("{} has no file name", path.display())))? + .to_string_lossy(); + let temporary = directory.join(format!(".{file_name}.{}.tmp", std::process::id())); + std::fs::write(&temporary, contents)?; + crate::local::home::secure_file(&temporary)?; + if let Err(error) = std::fs::rename(&temporary, path) { + let _ignored = std::fs::remove_file(&temporary); + return Err(error.into()); + } + Ok(()) +} + +/// Result of installing the `Include` line into the user's client configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IncludeOutcome { + /// The line was inserted at the top of the file. + Installed, + /// The file already carried the line. + AlreadyInstalled, +} + +/// Renders the `Include` directive for the generated configuration. +#[must_use] +pub fn render_include(config: &Path, user_home: Option<&Path>) -> String { + format!("Include {}", render_path(config, user_home)) +} + +/// Idempotently inserts `include` as the first line of `user_config`. +/// +/// OpenSSH applies an `Include` inside a `Host` or `Match` block only to that +/// block, so only a copy that precedes the first block counts as installed; a +/// copy nested in a block is left alone and a global one is added above it. +/// Every existing line is kept verbatim. A symbolic link, as dotfile managers +/// create, is followed so the linked file is updated and the link survives, +/// also when its target does not exist yet. A missing file or directory is +/// created with user-only access. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read or written. +pub fn install_include(user_config: &Path, include: &str) -> Result { + let target = link_target(user_config)?; + let existing = match std::fs::read_to_string(&target) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.into()), + }; + if global_lines(&existing).any(|line| line == include) { + return Ok(IncludeOutcome::AlreadyInstalled); + } + if let Some(directory) = target.parent() + && !directory.exists() + { + std::fs::create_dir_all(directory)?; + crate::local::home::secure_directory(directory)?; + } + let mut text = format!("{include}\n"); + if !existing.is_empty() { + text.push('\n'); + text.push_str(&existing); + } + write_private_file(&target, text.as_bytes())?; + Ok(IncludeOutcome::Installed) +} + +/// Follows a chain of symbolic links to the file they name, whether or not it +/// exists yet, so a write lands in the linked file and the link survives. +fn link_target(path: &Path) -> Result { + let mut current = path.to_path_buf(); + for _ in 0..40 { + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let next = std::fs::read_link(¤t)?; + current = if next.is_absolute() { + next + } else { + current + .parent() + .map_or_else(|| next.clone(), |parent| parent.join(&next)) + }; + } + Ok(_) => return Ok(current), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current), + Err(error) => return Err(error.into()), + } + } + Err(Error::Invalid(format!( + "{} is a chain of too many symbolic links", + path.display() + ))) +} + +/// Yields the trimmed lines of an OpenSSH client configuration that apply +/// unconditionally: everything before the first `Host` or `Match` keyword. +fn global_lines(text: &str) -> impl Iterator { + text.lines().map(str::trim).take_while(|line| { + let keyword = line + .split(|character: char| character.is_whitespace() || character == '=') + .next() + .unwrap_or_default(); + !keyword.eq_ignore_ascii_case("host") && !keyword.eq_ignore_ascii_case("match") + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use std::path::Path; + + use super::{ + CommandShell, HostEntry, IncludeOutcome, install_include, remove_known_host, render_config, render_include, + render_path, render_proxy_command, resolves_through_agentctl, upsert_known_host, + }; + + fn entry(name: &str, id: &str, root: &Path) -> HostEntry { + HostEntry { + alias: format!("agentctl-{name}"), + user: "agent".into(), + proxy_command: render_proxy_command(Path::new("/usr/local/bin/agentctl"), name, CommandShell::Posix) + .expect("proxy command"), + host_key_alias: format!("agent-{id}"), + identity_file: root.join(id).join("id_ed25519"), + known_hosts_file: root.join("known_hosts"), + } + } + + #[test] + fn config_dials_through_agentctl_and_pins_the_incarnation_key() { + let home = Path::new("/home/me"); + let root = home.join(".agent").join("ssh"); + let text = render_config(&[entry("worker", "1111", &root)], Some(home)); + let expected = "\ +# Generated by agentd for `agentctl ssh`; every edit is overwritten. +# Include it from your own client configuration with `agentctl ssh-config install`. + +Host agentctl-worker + User agent + ProxyCommand /usr/local/bin/agentctl ssh-proxy agent/worker + HostKeyAlias agent-1111 + IdentityFile ~/.agent/ssh/1111/id_ed25519 + UserKnownHostsFile ~/.agent/ssh/known_hosts + IdentitiesOnly yes +"; + assert_eq!(text, expected); + assert_eq!(render_config(&[], Some(home)).lines().count(), 2); + } + + #[test] + fn paths_outside_home_and_with_spaces_are_absolute_and_quoted() { + assert_eq!( + render_path( + Path::new("/srv/agent home/ssh/known_hosts"), + Some(Path::new("/home/me")) + ), + "\"/srv/agent home/ssh/known_hosts\"" + ); + assert_eq!( + render_path(Path::new("/home/me"), Some(Path::new("/home/me"))), + "/home/me" + ); + assert_eq!( + render_proxy_command(Path::new("/opt/agent tools/agentctl"), "worker", CommandShell::Posix).expect("posix"), + "'/opt/agent tools/agentctl' ssh-proxy agent/worker" + ); + assert_eq!( + render_path(Path::new("/srv/say \"hi\"/known_hosts"), None), + r#""/srv/say \"hi\"/known_hosts""# + ); + assert_eq!( + render_path(Path::new("/srv/100%/known_hosts"), None), + r#""/srv/100%%/known_hosts""#, + "IdentityFile and UserKnownHostsFile are percent-expanded" + ); + assert_eq!( + render_include(Path::new("/srv/100%/config"), None), + r#"Include "/srv/100%%/config""#, + "Include is percent-expanded like IdentityFile" + ); + } + + #[test] + fn proxy_command_quotes_for_the_host_shell_and_doubles_percent() { + // Windows: CreateProcess rules, backslashes literal, `%` doubled for OpenSSH. + assert_eq!( + render_proxy_command( + Path::new(r"C:\Users\100%$user\.local\bin\agentctl.exe"), + "worker", + CommandShell::Windows + ) + .expect("Windows"), + r#""C:\Users\100%%$user\.local\bin\agentctl.exe" ssh-proxy agent/worker"# + ); + assert_eq!( + render_proxy_command(Path::new(r"C:\agent\agentctl.exe"), "worker", CommandShell::Windows) + .expect("Windows plain"), + r#""C:\agent\agentctl.exe" ssh-proxy agent/worker"# + ); + // Unix: single quotes make shell syntax in the user's directory name literal. + assert_eq!( + render_proxy_command(Path::new("/home/we$ird;u'ser/agentctl"), "worker", CommandShell::Posix) + .expect("posix"), + r"'/home/we$ird;u'\''ser/agentctl' ssh-proxy agent/worker" + ); + assert_eq!( + render_proxy_command(Path::new("/home/100%/agentctl"), "worker", CommandShell::Posix).expect("posix"), + "'/home/100%%/agentctl' ssh-proxy agent/worker" + ); + for impossible in ["/opt/line\nbreak/agentctl", "/opt/nul\0/agentctl"] { + assert!( + render_proxy_command(Path::new(impossible), "worker", CommandShell::Posix).is_err(), + "{impossible:?} cannot be written on one configuration line" + ); + } + } + + /// The Unix form survives the real shell: what `sh` sees after OpenSSH has + /// undone `%%` is exactly the installed path. + #[cfg(unix)] + #[test] + fn posix_proxy_command_round_trips_through_sh() { + for path in [ + "/opt/agent tools/agentctl", + "/home/we$ird;u'ser/`id`/agentctl", + "/home/100%/agent\"quote/agentctl", + ] { + let rendered = render_proxy_command(Path::new(path), "worker", CommandShell::Posix).expect("posix"); + let command = rendered.replace("%%", "%"); + let word = command.strip_suffix(" ssh-proxy agent/worker").expect("suffix"); + let output = std::process::Command::new("sh") + .arg("-c") + .arg(format!("printf '%s' {word}")) + .output() + .expect("sh"); + assert_eq!(String::from_utf8(output.stdout).expect("UTF-8"), path); + } + } + + #[test] + fn known_hosts_entries_are_keyed_by_alias() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("known_hosts"); + std::fs::write(&path, "github.com ssh-ed25519 AAAAgithub\n").expect("seed"); + upsert_known_host(&path, "agent-1", "ssh-ed25519 AAAAone").expect("insert"); + upsert_known_host(&path, "agent-2", "ssh-ed25519 AAAAtwo").expect("insert second"); + upsert_known_host(&path, "agent-1", "ssh-ed25519 AAAAone-rotated").expect("replace"); + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "github.com ssh-ed25519 AAAAgithub\nagent-2 ssh-ed25519 AAAAtwo\nagent-1 ssh-ed25519 AAAAone-rotated\n" + ); + remove_known_host(&path, "agent-1").expect("remove"); + remove_known_host(&path, "agent-missing").expect("remove absent alias"); + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "github.com ssh-ed25519 AAAAgithub\nagent-2 ssh-ed25519 AAAAtwo\n" + ); + remove_known_host(&directory.path().join("absent"), "agent-1").expect("absent file is fine"); + } + + #[test] + fn include_goes_to_the_top_once_and_keeps_every_other_line() { + let directory = tempfile::tempdir().expect("temporary directory"); + let config = directory.path().join(".ssh").join("config"); + let include = "Include ~/.agent/ssh/config"; + + assert_eq!( + install_include(&config, include).expect("fresh install"), + IncludeOutcome::Installed + ); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + "Include ~/.agent/ssh/config\n" + ); + assert_eq!( + install_include(&config, include).expect("repeat"), + IncludeOutcome::AlreadyInstalled + ); + + let existing = "# mine\nHost github.com\n User git\n"; + std::fs::write(&config, existing).expect("user config"); + assert_eq!( + install_include(&config, include).expect("install"), + IncludeOutcome::Installed + ); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + format!("Include ~/.agent/ssh/config\n\n{existing}") + ); + assert_eq!( + install_include(&config, include).expect("repeat"), + IncludeOutcome::AlreadyInstalled + ); + + // A copy scoped to a Host block does not apply to the Agent aliases, so the + // global line is still added; the scoped copy is kept verbatim. + let scoped = "Host x\n Include ~/.agent/ssh/config\n"; + std::fs::write(&config, scoped).expect("scoped include"); + assert_eq!( + install_include(&config, include).expect("scoped copy"), + IncludeOutcome::Installed + ); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + format!("Include ~/.agent/ssh/config\n\n{scoped}") + ); + std::fs::write(&config, " include ~/.agent/ssh/config\nMatch all\n").expect("indented global"); + assert_eq!( + install_include(&config, "include ~/.agent/ssh/config").expect("indented global line counts"), + IncludeOutcome::AlreadyInstalled + ); + } + + #[cfg(unix)] + #[test] + fn include_install_follows_a_symlinked_user_config() { + let directory = tempfile::tempdir().expect("temporary directory"); + let dotfiles = directory.path().join("dotfiles").join("ssh_config"); + std::fs::create_dir_all(dotfiles.parent().expect("parent")).expect("dotfiles"); + std::fs::write(&dotfiles, "Host github.com\n User git\n").expect("managed config"); + let ssh = directory.path().join(".ssh"); + std::fs::create_dir_all(&ssh).expect(".ssh"); + let config = ssh.join("config"); + std::os::unix::fs::symlink(&dotfiles, &config).expect("symlink"); + + assert_eq!( + install_include(&config, "Include ~/.agent/ssh/config").expect("install"), + IncludeOutcome::Installed + ); + assert!( + std::fs::symlink_metadata(&config) + .expect("metadata") + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::read_to_string(&dotfiles).expect("managed config"), + "Include ~/.agent/ssh/config\n\nHost github.com\n User git\n" + ); + + // A link whose target does not exist yet: the target is created, the link kept. + let dangling_target = directory.path().join("dotfiles").join("later").join("ssh_config"); + let dangling = ssh.join("config-dangling"); + std::os::unix::fs::symlink(&dangling_target, &dangling).expect("dangling symlink"); + assert_eq!( + install_include(&dangling, "Include ~/.agent/ssh/config").expect("install through dangling link"), + IncludeOutcome::Installed + ); + assert!( + std::fs::symlink_metadata(&dangling) + .expect("metadata") + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::read_to_string(&dangling_target).expect("created target"), + "Include ~/.agent/ssh/config\n" + ); + } + + #[test] + fn a_resolved_alias_reaches_the_agent_only_through_its_own_proxy_command() { + let proxy = render_proxy_command(Path::new("/opt/my tools/100%/agentctl"), "worker", CommandShell::Posix) + .expect("proxy command"); + let resolved = format!("user agent\nproxycommand {proxy}\nhostkeyalias agent-1\n"); + + assert!(resolves_through_agentctl(&resolved, "worker")); + assert!(!resolves_through_agentctl(&resolved, "coworker")); + assert!(!resolves_through_agentctl( + &resolved.replace("/worker", "/coworker"), + "worker" + )); + assert!( + !resolves_through_agentctl("user me\nhostname agentctl-worker\n", "worker"), + "an alias OpenSSH does not know resolves to no proxy command" + ); + assert!( + !resolves_through_agentctl("proxycommand ssh -W %h:%p jump\n", "worker"), + "an earlier match of the user's own wins over the generated one" + ); + } +} diff --git a/agentctl/src/ssh/keys.rs b/agentctl/src/ssh/keys.rs new file mode 100644 index 0000000..77308ce --- /dev/null +++ b/agentctl/src/ssh/keys.rs @@ -0,0 +1,74 @@ +//! Ed25519 key pairs in OpenSSH encoding. + +use ssh_key::{Algorithm, LineEnding, PrivateKey, rand_core::OsRng}; +use zeroize::Zeroizing; + +use crate::Error; + +/// One freshly generated or decoded key pair in OpenSSH encoding. +pub struct KeyPair { + /// The private key in OpenSSH private key format, ending in a newline. + pub private: Zeroizing, + /// The public key as one `authorized_keys`/`known_hosts` entry without a trailing newline. + pub public: String, +} + +impl KeyPair { + /// Generates an Ed25519 key pair with the host's operating system randomness. + /// + /// # Errors + /// + /// Returns an error when the key cannot be generated or encoded. + pub fn generate_ed25519(comment: &str) -> Result { + let mut key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).map_err(|error| key_error(&error))?; + key.set_comment(comment); + Self::from_private(&key) + } + + /// Decodes an OpenSSH private key and derives its public entry. + /// + /// # Errors + /// + /// Returns an error when the bytes are not an unencrypted OpenSSH private key. + pub fn from_openssh(private: &[u8]) -> Result { + let key = PrivateKey::from_openssh(private).map_err(|error| key_error(&error))?; + Self::from_private(&key) + } + + fn from_private(key: &PrivateKey) -> Result { + let private = key.to_openssh(LineEnding::LF).map_err(|error| key_error(&error))?; + let public = key.public_key().to_openssh().map_err(|error| key_error(&error))?; + Ok(Self { + private: Zeroizing::new(private.to_string()), + public, + }) + } +} + +fn key_error(error: &ssh_key::Error) -> Error { + Error::Daemon(format!("SSH key operation failed: {error}")) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::KeyPair; + + #[test] + fn generated_keys_are_openssh_ed25519() { + let pair = KeyPair::generate_ed25519("agent-test").expect("key pair"); + assert!(pair.private.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----\n")); + assert!(pair.private.ends_with("-----END OPENSSH PRIVATE KEY-----\n")); + assert!(pair.public.starts_with("ssh-ed25519 AAAA")); + assert!(pair.public.ends_with(" agent-test")); + assert!(!pair.public.contains('\n')); + + let decoded = KeyPair::from_openssh(pair.private.as_bytes()).expect("decode"); + assert_eq!(decoded.public, pair.public); + assert_ne!( + KeyPair::generate_ed25519("other").expect("second pair").public, + pair.public + ); + } +} diff --git a/agentctl/src/ssh/memory.rs b/agentctl/src/ssh/memory.rs new file mode 100644 index 0000000..40c06d5 --- /dev/null +++ b/agentctl/src/ssh/memory.rs @@ -0,0 +1,50 @@ +//! In-memory SSH host key storage for tests and single-process use. + +use std::{cell::RefCell, collections::BTreeMap}; + +use ::sandbox::LocalFuture; +use zeroize::Zeroizing; + +use crate::{AgentId, Error}; + +use super::HostKeyStore; + +/// Keeps host keys in process memory. +#[derive(Default)] +pub struct InMemoryHostKeyStore { + keys: RefCell>>>, +} + +impl InMemoryHostKeyStore { + /// Creates an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns whether a host key is stored for the incarnation. + #[must_use] + pub fn contains(&self, id: AgentId) -> bool { + self.keys.borrow().contains_key(&id) + } +} + +impl HostKeyStore for InMemoryHostKeyStore { + fn load_host_key(&self, id: AgentId) -> LocalFuture<'_, Result>>, Error>> { + Box::pin(async move { Ok(self.keys.borrow().get(&id).cloned()) }) + } + + fn store_host_key(&self, id: AgentId, key: Zeroizing>) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.keys.borrow_mut().insert(id, key); + Ok(()) + }) + } + + fn delete_host_key(&self, id: AgentId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.keys.borrow_mut().remove(&id); + Ok(()) + }) + } +} diff --git a/agentctl/src/ssh/mod.rs b/agentctl/src/ssh/mod.rs new file mode 100644 index 0000000..8b629b7 --- /dev/null +++ b/agentctl/src/ssh/mod.rs @@ -0,0 +1,516 @@ +//! SSH access to Agents. +//! +//! An Agent declaring `spec.access: [{type: ssh}]` gets an OpenSSH server +//! inside its Sandbox, listening on the guest loopback only, and an OpenSSH +//! client configuration on the host that reaches it through +//! `agentctl ssh-proxy`. The platform owns every moving part: the guest user +//! is `agent`, the host key is generated per Agent incarnation and kept in +//! the owner-protected database, the client key pair lives under the +//! control-plane home and its private half never enters the guest. +//! +//! The image provides OpenSSH, systemd and a usable `agent` account; `agentd` +//! owns the server policy, unit and per-Agent state written at setup. Nothing +//! here is a security boundary beyond the Sandbox itself: the guest user has +//! passwordless `sudo`, so the server hardening is hygiene, and Sessions and +//! SSH logins share one trust boundary. + +mod client_config; +mod keys; +pub mod memory; + +use std::{ + path::{Path, PathBuf}, + rc::Rc, +}; + +use ::sandbox::{LocalFuture, SandboxHandle}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{ + AgentId, Error, + control_plane::{AgentRecord, AgentStore}, + local::home::ControlPlaneHome, + sandbox::platform, +}; + +pub use client_config::{ + CommandShell, HostEntry, IncludeOutcome, install_include, remove_known_host, render_config, render_include, + render_path, render_proxy_command, resolves_through_agentctl, upsert_known_host, +}; +pub use keys::KeyPair; + +/// Guest user every SSH login becomes: the platform-owned Sandbox user the Linux adapter defines. +pub const GUEST_USER: &str = platform::USER; +/// Guest loopback port the Agent's server listens on. +pub const GUEST_PORT: u16 = 2222; +/// The `type` value of an SSH access descriptor. +pub const ACCESS_TYPE: &str = "ssh"; + +/// Returns the OpenSSH `Host` alias for an Agent name. +#[must_use] +pub fn alias(agent: &str) -> String { + format!("agentctl-{agent}") +} + +/// Returns the `HostKeyAlias` keying `known_hosts` by the stable incarnation. +#[must_use] +pub fn host_key_alias(id: AgentId) -> String { + format!("agent-{id}") +} + +/// Host-side SSH paths below one control-plane home. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SshHome(PathBuf); + +impl SshHome { + /// Locates the SSH directory of a control-plane home. + #[must_use] + pub fn new(home: &ControlPlaneHome) -> Self { + Self(home.ssh_directory()) + } + + /// Returns the directory itself. + #[must_use] + pub fn root(&self) -> &Path { + &self.0 + } + + /// Returns the generated OpenSSH client configuration. + #[must_use] + pub fn config_path(&self) -> PathBuf { + self.0.join("config") + } + + /// Returns the `known_hosts` file pre-seeded with every Agent's host key. + #[must_use] + pub fn known_hosts_path(&self) -> PathBuf { + self.0.join("known_hosts") + } + + /// Returns the directory holding one Agent incarnation's client key pair. + #[must_use] + pub fn agent_directory(&self, id: AgentId) -> PathBuf { + self.0.join(id.to_string()) + } + + /// Returns one Agent incarnation's private client key. + #[must_use] + pub fn identity_path(&self, id: AgentId) -> PathBuf { + self.agent_directory(id).join("id_ed25519") + } + + /// Returns one Agent incarnation's public client key. + #[must_use] + pub fn public_identity_path(&self, id: AgentId) -> PathBuf { + self.agent_directory(id).join("id_ed25519.pub") + } +} + +/// The `Include` that makes the generated configuration apply to plain `ssh`, +/// `sftp` and editors, and the user's own OpenSSH configuration it belongs in. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserInclude { + /// The user's `~/.ssh/config`. + pub user_config: PathBuf, + /// The complete `Include` line. + pub line: String, +} + +impl UserInclude { + /// Locates the include for the generated configuration of `home`. + /// + /// # Errors + /// + /// Returns an error when the user's home directory is not set. + pub fn for_home(home: &ControlPlaneHome) -> Result { + let user_home = crate::local::home::user_home_directory() + .ok_or_else(|| Error::Invalid("the user home directory is not set (HOME or USERPROFILE)".into()))?; + Ok(Self { + user_config: user_home.join(".ssh").join("config"), + line: render_include(&SshHome::new(home).config_path(), Some(&user_home)), + }) + } + + /// Adds the line at the top of the user's configuration unless it is there. + /// + /// # Errors + /// + /// Returns an error when the configuration cannot be read or written. + pub fn install(&self) -> Result { + install_include(&self.user_config, &self.line) + } +} + +/// Stable machine-readable description of one Agent's SSH access. +/// +/// This is the seam an IDE integration consumes: everything needed to open a +/// connection without parsing the generated configuration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessInfo { + /// Access kind, always `ssh`. + #[serde(rename = "type")] + pub kind: String, + /// Agent name. + pub agent: String, + /// Agent incarnation identity the key material belongs to. + pub agent_id: AgentId, + /// OpenSSH `Host` alias in the generated configuration. + pub alias: String, + /// Guest user. + pub user: String, + /// Absolute private client key path. + pub identity_file: PathBuf, + /// Absolute `known_hosts` path holding the Agent's host key. + pub known_hosts_file: PathBuf, + /// Absolute path of the generated OpenSSH client configuration. + pub config_file: PathBuf, + /// Complete `ProxyCommand` value dialing the Agent through `agentctl`. + pub proxy_command: String, + /// Guest directory Sessions open in, which editors open as their folder. + pub working_directory: String, +} + +/// Persists each Agent incarnation's SSH host key. +pub trait HostKeyStore { + /// Loads the stored OpenSSH private host key, if one exists. + fn load_host_key(&self, id: AgentId) -> LocalFuture<'_, Result>>, Error>>; + + /// Stores the OpenSSH private host key. + fn store_host_key(&self, id: AgentId, key: Zeroizing>) -> LocalFuture<'_, Result<(), Error>>; + + /// Deletes the stored host key; a missing key is not an error. + fn delete_host_key(&self, id: AgentId) -> LocalFuture<'_, Result<(), Error>>; +} + +/// Files the guest server needs, produced on the host for one pass. +pub struct GuestMaterial { + /// OpenSSH private host key. + pub host_private_key: Zeroizing>, + /// Public host key entry, without a trailing newline. + pub host_public_key: String, + /// Complete `authorized_keys` content. + pub authorized_keys: String, +} + +/// Builds the message given when the Agent's image cannot serve SSH access. +/// +/// The image is immutable for the incarnation, so the message names what is +/// missing and the only fixes: an image that provides it, or withdrawing access. +#[must_use] +pub fn image_contract_missing(what: &str) -> String { + format!( + "the Agent's image cannot provide SSH access: {what}; re-apply the Agent with an image that provides \ + OpenSSH, runs systemd and has a usable `agent` account, or remove `ssh` from spec.access" + ) +} + +/// Reconciles SSH access for Agents: host key material, client configuration +/// and the in-guest server state. +pub struct Access { + home: SshHome, + agentctl: PathBuf, + user_home: Option, + keys: Rc, + agents: Rc, +} + +impl Access { + /// Creates the SSH access reconciler. + /// + /// `agentctl` is the absolute executable the generated `ProxyCommand` runs. + #[must_use] + pub fn new( + home: &ControlPlaneHome, + agentctl: PathBuf, + keys: Rc, + agents: Rc, + ) -> Self { + Self { + home: SshHome::new(home), + agentctl, + user_home: crate::local::home::user_home_directory(), + keys, + agents, + } + } + + /// Overrides the user home used to shorten paths to `~/...`; tests pin it. + #[must_use] + pub fn with_user_home(mut self, user_home: Option) -> Self { + self.user_home = user_home; + self + } + + /// Returns the host-side SSH paths. + #[must_use] + pub const fn home(&self) -> &SshHome { + &self.home + } + + /// Describes the SSH access of a named Agent. + /// + /// # Errors + /// + /// Returns `Error::NotFound` for an unknown Agent, `Error::Conflict` while + /// it is being deleted, and `Error::Invalid` when it declares no SSH access. + pub async fn describe(&self, name: &str) -> Result { + let record = self.agents.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if !record.agent.spec.ssh_access() { + return Err(Error::Invalid(format!( + "Agent {name:?} does not declare SSH access; add `access: [{{type: ssh}}]` to its spec and re-apply" + ))); + } + self.info(&record) + } + + /// Builds the descriptor for a record without consulting the store. + /// + /// # Errors + /// + /// Returns an error when the `agentctl` path cannot be rendered into a `ProxyCommand`. + pub fn info(&self, record: &AgentRecord) -> Result { + let name = &record.agent.metadata.name; + Ok(AccessInfo { + kind: ACCESS_TYPE.into(), + agent: name.clone(), + agent_id: record.id, + alias: alias(name), + user: GUEST_USER.into(), + identity_file: self.home.identity_path(record.id), + known_hosts_file: self.home.known_hosts_path(), + config_file: self.home.config_path(), + proxy_command: render_proxy_command(&self.agentctl, name, CommandShell::host())?, + working_directory: platform::WORKING_DIRECTORY.into(), + }) + } + + /// Converges SSH access for one Agent against its running Sandbox. + /// + /// Returns whether the Agent has SSH access after the pass. An Agent + /// without declared access has any earlier server state removed. + /// + /// # Errors + /// + /// Returns `Error::Invalid` when the image lacks an OpenSSH server or the + /// Sandbox operating system is unsupported, and transient errors when key + /// material or guest setup cannot be written. + pub async fn reconcile(&self, record: &AgentRecord, sandbox: &SandboxHandle) -> Result { + let os = sandbox.snapshot().image.platform.os.clone(); + if !record.agent.spec.ssh_access() { + // Guest first: while a stop can still fail, the host key and known_hosts must + // keep matching the server that may still be running. + remove_guest_state(&os, sandbox).await?; + self.remove_host_material(record).await?; + return Ok(false); + } + verify_guest_server(&os, sandbox).await?; + let material = self.ensure_host_material(record).await?; + install_guest_state(&os, sandbox, &material).await?; + Ok(true) + } + + /// Removes every host-side trace of a deleted Agent incarnation. + /// + /// # Errors + /// + /// Returns an error when key files or configuration cannot be rewritten. + pub async fn remove(&self, record: &AgentRecord) -> Result<(), Error> { + self.remove_host_material(record).await + } + + async fn ensure_host_material(&self, record: &AgentRecord) -> Result { + let id = record.id; + let name = record.agent.metadata.name.as_str(); + let host_key = if let Some(stored) = self.keys.load_host_key(id).await? { + KeyPair::from_openssh(&stored)? + } else { + let generated = KeyPair::generate_ed25519(&host_key_alias(id))?; + self.keys + .store_host_key(id, Zeroizing::new(generated.private.as_bytes().to_vec())) + .await?; + generated + }; + let home = self.home.clone(); + let client_key = tokio::task::spawn_blocking({ + let name = name.to_owned(); + move || ensure_client_key(&home, id, &name) + }) + .await + .map_err(|error| Error::Daemon(format!("SSH client key task failed: {error}")))??; + // `HostKeyAlias` keys the entry by the incarnation for OpenSSH; a second entry under the + // `Host` alias serves clients that parse the configuration but ignore `HostKeyAlias`, + // such as JetBrains IDEs, and is replaced when a re-applied name gets a new host key. + let known_hosts = self.home.known_hosts_path(); + upsert_known_host(&known_hosts, &host_key_alias(id), &host_key.public)?; + upsert_known_host(&known_hosts, &alias(name), &host_key.public)?; + self.rewrite_config().await?; + Ok(GuestMaterial { + host_private_key: Zeroizing::new(host_key.private.as_bytes().to_vec()), + host_public_key: host_key.public, + authorized_keys: format!("{client_key}\n"), + }) + } + + async fn remove_host_material(&self, record: &AgentRecord) -> Result<(), Error> { + let id = record.id; + self.keys.delete_host_key(id).await?; + let directory = self.home.agent_directory(id); + if directory.exists() { + std::fs::remove_dir_all(&directory)?; + } + let known_hosts = self.home.known_hosts_path(); + remove_known_host(&known_hosts, &host_key_alias(id))?; + // The name alias belongs to whichever incarnation currently owns the name. + let owned_by_another = matches!( + self.agents.get_by_name(&record.agent.metadata.name).await, + Ok(current) if current.id != id && current.agent.spec.ssh_access() + ); + if !owned_by_another { + remove_known_host(&known_hosts, &alias(&record.agent.metadata.name))?; + } + self.rewrite_config().await + } + + /// Rewrites the generated client configuration from every active Agent with SSH access. + async fn rewrite_config(&self) -> Result<(), Error> { + let mut entries = self + .agents + .list() + .await? + .into_iter() + .filter(|record| record.agent.metadata.deletion_timestamp.is_none() && record.agent.spec.ssh_access()) + .map(|record| { + Ok(HostEntry { + alias: alias(&record.agent.metadata.name), + user: GUEST_USER.into(), + proxy_command: render_proxy_command( + &self.agentctl, + &record.agent.metadata.name, + CommandShell::host(), + )?, + host_key_alias: host_key_alias(record.id), + identity_file: self.home.identity_path(record.id), + known_hosts_file: self.home.known_hosts_path(), + }) + }) + .collect::, Error>>()?; + entries.sort_by(|left, right| left.alias.cmp(&right.alias)); + prepare_directory(self.home.root())?; + let text = render_config(&entries, self.user_home.as_deref()); + client_config::write_private_file(&self.home.config_path(), text.as_bytes()) + } +} + +/// Chooses the `agentctl` path the generated `ProxyCommand` runs. +/// +/// `sibling` is `agentctl` beside the running `agentd`, which on an installed +/// release is a versioned directory that a later `agentctl self update` +/// replaces. When a `PATH` entry resolves to the same executable, that stable +/// spelling, such as `~/.local/bin/agentctl`, is preferred so the configuration +/// survives upgrades between reconciliation passes. +#[must_use] +pub fn stable_agentctl_path(sibling: &Path, path_variable: Option<&std::ffi::OsStr>) -> PathBuf { + let Ok(target) = std::fs::canonicalize(sibling) else { + return sibling.to_path_buf(); + }; + let file_name = sibling.file_name().map(std::ffi::OsStr::to_owned); + path_variable + .into_iter() + .flat_map(std::env::split_paths) + .filter(|directory| directory.is_absolute()) + .filter_map(|directory| Some(directory.join(file_name.as_ref()?))) + .find(|candidate| std::fs::canonicalize(candidate).is_ok_and(|resolved| resolved == target)) + .unwrap_or_else(|| sibling.to_path_buf()) +} + +/// Creates the client key pair for an incarnation when missing and returns its public entry. +fn ensure_client_key(home: &SshHome, id: AgentId, agent: &str) -> Result { + prepare_directory(home.root())?; + prepare_directory(&home.agent_directory(id))?; + let identity = home.identity_path(id); + let pair = match std::fs::read(&identity) { + Ok(private) => KeyPair::from_openssh(&private)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let generated = KeyPair::generate_ed25519(&format!("{}@{agent}", host_key_alias(id)))?; + client_config::write_private_file(&identity, generated.private.as_bytes())?; + generated + } + Err(error) => return Err(error.into()), + }; + let public_path = home.public_identity_path(id); + let public_line = format!("{}\n", pair.public); + if std::fs::read_to_string(&public_path).ok().as_deref() != Some(public_line.as_str()) { + client_config::write_private_file(&public_path, public_line.as_bytes())?; + } + Ok(pair.public) +} + +fn prepare_directory(path: &Path) -> Result<(), Error> { + if !path.is_dir() { + std::fs::create_dir_all(path)?; + } + crate::local::home::secure_directory(path) +} + +async fn verify_guest_server(os: &str, sandbox: &SandboxHandle) -> Result<(), Error> { + match os { + "linux" => platform::linux_ssh::verify_server(sandbox).await, + os => Err(Error::Invalid(format!( + "SSH access is not supported on Sandbox operating system {os:?}" + ))), + } +} + +async fn install_guest_state(os: &str, sandbox: &SandboxHandle, material: &GuestMaterial) -> Result<(), Error> { + match os { + "linux" => platform::linux_ssh::install_server_state(sandbox, material).await, + os => Err(Error::Invalid(format!( + "SSH access is not supported on Sandbox operating system {os:?}" + ))), + } +} + +async fn remove_guest_state(os: &str, sandbox: &SandboxHandle) -> Result<(), Error> { + match os { + "linux" => platform::linux_ssh::remove_server_state(sandbox).await, + // Nothing was ever installed on an unsupported operating system. + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::stable_agentctl_path; + + #[test] + fn proxy_command_prefers_a_path_entry_resolving_to_the_same_agentctl() { + let directory = tempfile::tempdir().expect("temporary directory"); + let releases = directory.path().join("releases").join("v1"); + let bin = directory.path().join("bin"); + std::fs::create_dir_all(&releases).expect("release directory"); + std::fs::create_dir_all(&bin).expect("bin directory"); + let sibling = releases.join("agentctl"); + std::fs::write(&sibling, "#!/bin/sh\n").expect("release agentctl"); + std::fs::write(bin.join("agentctl"), "#!/bin/sh\n").expect("unrelated agentctl"); + + let unrelated = std::env::join_paths([&bin]).expect("PATH"); + assert_eq!(stable_agentctl_path(&sibling, Some(&unrelated)), sibling); + assert_eq!(stable_agentctl_path(&sibling, None), sibling); + + #[cfg(unix)] + { + let stable = directory.path().join("stable"); + std::fs::create_dir_all(&stable).expect("stable directory"); + std::os::unix::fs::symlink(&sibling, stable.join("agentctl")).expect("symlink"); + let path = std::env::join_paths([&bin, &stable]).expect("PATH"); + assert_eq!(stable_agentctl_path(&sibling, Some(&path)), stable.join("agentctl")); + } + } +} diff --git a/agentctl/src/upgrade.rs b/agentctl/src/upgrade.rs new file mode 100644 index 0000000..e5373c4 --- /dev/null +++ b/agentctl/src/upgrade.rs @@ -0,0 +1,1128 @@ +//! Durable installation and state shared by an updater and the first daemon startup. + +use std::{ + collections::BTreeSet, + env, + fmt::Write as _, + fs::{self, File, OpenOptions}, + io::{Read as _, Write as _}, + path::{Component, Path, PathBuf}, + process::Command, + sync::OnceLock, + time::Duration, +}; + +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use crate::{Error, local::home::ControlPlaneHome, sessions}; + +const INSTALL_FORMAT: u32 = 1; +const JOURNAL_FORMAT: u32 = 1; +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_mins(1); +const INSTALL_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const BINARY_STEMS: [&str; 2] = ["agentctl", "agentd"]; + +/// Filesystem locations for one managed Agent installation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallPaths { + root: PathBuf, + bin: PathBuf, +} + +impl InstallPaths { + /// Resolves environment overrides and platform defaults. + /// + /// # Errors + /// + /// Returns an error when the platform's user directory cannot be resolved. + pub fn resolve() -> Result { + let root = match env::var_os("AGENT_INSTALL_ROOT").filter(|value| !value.is_empty()) { + Some(path) => absolute(Path::new(&path))?, + None => match inferred_install_root()? { + Some(path) => path, + None => default_install_root()?, + }, + }; + let bin = match env::var_os("AGENT_INSTALL_DIR").filter(|value| !value.is_empty()) { + Some(path) => absolute(Path::new(&path))?, + None if root.join("install.json").is_file() => InstallMetadata::read_from_root(&root)?.bin_directory, + #[cfg(unix)] + None => default_bin_directory()?, + #[cfg(windows)] + None => root.join("bin"), + }; + Ok(Self { root, bin }) + } + + /// Constructs explicit locations for installer handoff and tests. + /// + /// # Errors + /// + /// Returns an error unless both paths are absolute. + pub fn new(root: PathBuf, bin: PathBuf) -> Result { + if !root.is_absolute() || !bin.is_absolute() { + return Err(Error::Invalid("installation paths must be absolute".into())); + } + Ok(Self { root, bin }) + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + #[must_use] + pub fn bin(&self) -> &Path { + &self.bin + } + + #[must_use] + pub fn releases(&self) -> PathBuf { + self.root.join("releases") + } + + #[must_use] + pub fn current(&self) -> PathBuf { + self.root.join("current") + } + + #[must_use] + pub fn journal(&self) -> PathBuf { + self.root.join("update.json") + } + + #[must_use] + pub fn metadata(&self) -> PathBuf { + self.root.join("install.json") + } + + /// Serializes installers and updaters. + /// + /// # Errors + /// + /// Returns an error when the lock cannot be created or acquired. + pub async fn lock(&self) -> Result { + fs::create_dir_all(&self.root)?; + crate::local::home::secure_directory(&self.root)?; + let path = self.root.join("install.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path)?; + crate::local::home::secure_file(&path)?; + let deadline = tokio::time::Instant::now() + INSTALL_LOCK_TIMEOUT; + loop { + match file.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(std::fs::TryLockError::WouldBlock) => { + return Err(Error::Daemon("another Agent update is already running".into())); + } + Err(std::fs::TryLockError::Error(error)) => return Err(Error::Io(error)), + } + } + Ok(InstallLock { _file: file }) + } +} + +#[derive(Debug)] +pub struct InstallLock { + _file: File, +} + +/// Non-secret facts about a managed installation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct InstallMetadata { + format_version: u32, + repository: String, + target: String, + bin_directory: PathBuf, +} + +impl InstallMetadata { + #[must_use] + pub fn new(repository: String, bin_directory: PathBuf) -> Self { + Self { + format_version: INSTALL_FORMAT, + repository, + target: package_target().into(), + bin_directory, + } + } + + #[must_use] + pub fn repository(&self) -> &str { + &self.repository + } + + /// Writes the metadata atomically. + /// + /// # Errors + /// + /// Returns an error when serialization or durable replacement fails. + pub fn write(&self, paths: &InstallPaths) -> Result<(), Error> { + atomic_json(&paths.metadata(), self) + } + + /// Reads and validates the managed installation metadata. + /// + /// # Errors + /// + /// Returns an error for invalid, incompatible, or unreadable metadata. + pub fn read(paths: &InstallPaths) -> Result { + Self::read_from_root(paths.root()) + } + + fn read_from_root(root: &Path) -> Result { + let metadata: Self = serde_json::from_slice(&fs::read(root.join("install.json"))?)?; + if metadata.format_version != INSTALL_FORMAT + || metadata.target != package_target() + || !metadata.bin_directory.is_absolute() + { + return Err(Error::Invalid( + "managed Agent installation has an incompatible format or target".into(), + )); + } + Ok(metadata) + } +} + +/// One resolved release package. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Release { + pub version: String, + pub repository: String, + pub local_archive: Option, + pub local_checksum: Option, +} + +impl Release { + /// Resolves an explicit release, a local package, or the latest GitHub release. + /// + /// # Errors + /// + /// Returns an error when release resolution or version validation fails. + pub async fn resolve(version: Option<&str>, repository: String) -> Result { + let local_archive = env::var_os("AGENT_LOCAL_ARCHIVE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let version = match version { + Some(version) => normalize_version(version)?, + None if local_archive.is_some() => normalize_version( + &env::var("AGENT_VERSION") + .map_err(|_| Error::Invalid("AGENT_VERSION is required with AGENT_LOCAL_ARCHIVE".into()))?, + )?, + None => latest_release(&repository).await?, + }; + let local_checksum = local_archive.as_ref().map(|archive| { + env::var_os("AGENT_LOCAL_ARCHIVE_SHA256") + .map_or_else(|| PathBuf::from(format!("{}.sha256", archive.display())), PathBuf::from) + }); + Ok(Self { + version, + repository, + local_archive, + local_checksum, + }) + } + + #[must_use] + pub fn directory_name(&self) -> String { + format!("{}-{}", self.version, package_target()) + } + + fn archive_name() -> String { + format!("agent-{}.tar.gz", package_target()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StagedRelease { + pub release: Release, + pub path: PathBuf, +} + +/// Downloads, verifies, extracts, and validates a release without changing `current`. +/// +/// # Errors +/// +/// Returns an error when download, checksum, archive, or binary validation fails. +pub async fn stage_release(paths: &InstallPaths, release: Release) -> Result { + let final_path = paths.releases().join(release.directory_name()); + { + let _lock = paths.lock().await?; + if final_path.exists() { + validate_release_directory(&final_path, &release.version)?; + return Ok(StagedRelease { + release, + path: final_path, + }); + } + } + let temporary = tempfile::Builder::new().prefix("agent-release-").tempdir()?; + let archive = temporary.path().join(Release::archive_name()); + let checksum = temporary.path().join(format!("{}.sha256", Release::archive_name())); + if let Some(local) = &release.local_archive { + fs::copy(local, &archive)?; + fs::copy( + release + .local_checksum + .as_ref() + .ok_or_else(|| Error::Invalid("local package checksum is missing".into()))?, + &checksum, + )?; + } else { + let base = format!( + "https://github.com/{}/releases/download/experimental-agent/{}", + release.repository, release.version + ); + download(&format!("{base}/{}", Release::archive_name()), &archive).await?; + download(&format!("{base}/{}.sha256", Release::archive_name()), &checksum).await?; + } + verify_checksum(&archive, &checksum)?; + let extracted = temporary.path().join("release"); + fs::create_dir(&extracted)?; + extract_archive(&archive, &extracted)?; + let final_path = publish_release(paths, &extracted, &release.version).await?; + Ok(StagedRelease { + release, + path: final_path, + }) +} + +/// Validates and publishes an extracted package while serializing release ownership. +/// +/// # Errors +/// +/// Returns an error when the package is invalid or another update holds the install lock. +pub async fn publish_release(paths: &InstallPaths, source: &Path, version: &str) -> Result { + validate_release_directory(source, version)?; + let _lock = paths.lock().await?; + fs::create_dir_all(paths.releases())?; + crate::local::home::secure_directory(&paths.releases())?; + let final_path = paths.releases().join(format!("{version}-{}", package_target())); + if final_path.exists() { + validate_release_directory(&final_path, version)?; + return Ok(final_path); + } + let staging = tempfile::Builder::new() + .prefix(".staging-") + .tempdir_in(paths.releases())?; + for binary in binary_names() { + fs::copy(source.join(&binary), staging.path().join(binary))?; + } + validate_release_directory(staging.path(), version)?; + fs::rename(staging.path(), &final_path)?; + #[cfg(unix)] + sync_directory(&paths.releases())?; + Ok(final_path) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum UpdatePhase { + Prepared, + Migrated, + Activated, + Complete, +} + +/// Target-owned durable update journal. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct UpdateJournal { + format_version: u32, + pub previous_release: Option, + pub target_release: PathBuf, + pub target_version: String, + pub phase: UpdatePhase, +} + +impl UpdateJournal { + #[must_use] + pub const fn new(previous_release: Option, target_release: PathBuf, target_version: String) -> Self { + Self { + format_version: JOURNAL_FORMAT, + previous_release, + target_release, + target_version, + phase: UpdatePhase::Prepared, + } + } + + /// Reads the journal when one exists. + /// + /// # Errors + /// + /// Returns an error for invalid, unsupported, or unsafe journal data. + pub fn read(paths: &InstallPaths) -> Result, Error> { + let bytes = match fs::read(paths.journal()) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let journal: Self = serde_json::from_slice(&bytes)?; + if journal.format_version != JOURNAL_FORMAT { + return Err(Error::Invalid("unsupported Agent update journal format".into())); + } + journal.validate(paths)?; + Ok(Some(journal)) + } + + /// Durably advances the state machine. + /// + /// # Errors + /// + /// Returns an error for backward movement or a failed write. + pub fn advance(&mut self, paths: &InstallPaths, phase: UpdatePhase) -> Result<(), Error> { + if phase < self.phase { + return Err(Error::Invalid("Agent update journal cannot move backwards".into())); + } + self.phase = phase; + atomic_json(&paths.journal(), self) + } + + /// Removes a journal before migration has committed. + /// + /// # Errors + /// + /// Returns an error after migration or when the journal cannot be removed durably. + pub fn discard_before_migration(&self, paths: &InstallPaths) -> Result<(), Error> { + if self.phase != UpdatePhase::Prepared { + return Err(Error::Invalid( + "an Agent update journal can only be discarded before migration".into(), + )); + } + fs::remove_file(paths.journal())?; + #[cfg(unix)] + sync_directory(paths.root())?; + Ok(()) + } + + fn validate(&self, paths: &InstallPaths) -> Result<(), Error> { + let releases = canonical_or_absolute(&paths.releases())?; + for path in self + .previous_release + .iter() + .chain(std::iter::once(&self.target_release)) + { + if !path.is_absolute() { + return Err(Error::Invalid( + "Agent update journal names a release outside the install root".into(), + )); + } + let resolved = fs::canonicalize(path) + .map_err(|_| Error::Invalid("Agent update journal names a release that no longer exists".into()))?; + if resolved.parent() != Some(releases.as_path()) { + return Err(Error::Invalid( + "Agent update journal names a release outside the install root".into(), + )); + } + } + Ok(()) + } +} + +/// Reads the active release pointer. +/// +/// # Errors +/// +/// Returns an error when the pointer cannot be read. +pub fn current_release(paths: &InstallPaths) -> Result, Error> { + read_current(paths) +} + +#[cfg(unix)] +fn read_current(paths: &InstallPaths) -> Result, Error> { + match fs::read_link(paths.current()) { + Ok(target) => Ok(Some(if target.is_absolute() { + target + } else { + paths.root.join(target) + })), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +#[cfg(windows)] +fn read_current(paths: &InstallPaths) -> Result, Error> { + match fs::read_to_string(paths.current()) { + Ok(target) => Ok(Some(PathBuf::from(target.trim()))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +/// Atomically selects a release and refreshes the user-visible launch paths. +/// +/// # Errors +/// +/// Returns an error for an unsafe target or a failed filesystem operation. +pub fn activate_release(paths: &InstallPaths, target: &Path) -> Result<(), Error> { + let releases = canonical_or_absolute(&paths.releases())?; + let target = fs::canonicalize(target)?; + if !target.is_absolute() || target.parent() != Some(releases.as_path()) { + return Err(Error::Invalid( + "target release is outside the managed releases directory".into(), + )); + } + activate_links(paths, &target)?; + #[cfg(unix)] + sync_directory(paths.root())?; + Ok(()) +} + +#[cfg(unix)] +fn activate_links(paths: &InstallPaths, target: &Path) -> Result<(), Error> { + replace_directory_link(&paths.current(), target)?; + fs::create_dir_all(paths.bin())?; + for binary in binary_names() { + replace_file_link(&paths.bin.join(&binary), &paths.current().join(binary))?; + } + Ok(()) +} + +#[cfg(windows)] +fn activate_links(paths: &InstallPaths, target: &Path) -> Result<(), Error> { + replace_windows_pointer(&paths.current(), target)?; + fs::create_dir_all(paths.bin())?; + for binary in BINARY_STEMS { + let legacy = paths.bin().join(format!("{binary}.exe")); + if legacy.exists() { + fs::remove_file(legacy)?; + } + let root = windows_command_path(paths.root()).replace('%', "%%"); + let script = format!( + "@echo off\r\nsetlocal\r\nset /p AGENT_CURRENT=<\"{root}\\current\"\r\n\"%AGENT_CURRENT%\\{binary}.exe\" %*\r\n" + ); + let launcher = paths.bin().join(format!("{binary}.cmd")); + // cmd.exe reads batch files lazily, so replacing the active launcher can corrupt its next command. + if !launcher.try_exists()? { + replace_windows_file(&launcher, script.as_bytes())?; + } + } + Ok(()) +} + +/// Durably requests one target-daemon Session relaunch pass. +/// +/// # Errors +/// +/// Returns an error when the marker cannot be written securely. +pub fn create_session_relaunch_marker(home: &ControlPlaneHome, build_version: &str) -> Result<(), Error> { + home.prepare()?; + atomic_json( + &home.pending_session_relaunch_path(), + &SessionRelaunchMarker { + build_version: build_version.into(), + }, + ) +} + +/// Keeps current and previous releases plus anything needed by an unfinished update. +/// +/// # Errors +/// +/// Returns an error when releases cannot be enumerated or removed. +pub fn prune_releases(paths: &InstallPaths, previous: Option<&Path>) -> Result<(), Error> { + let mut keep = BTreeSet::new(); + keep.extend(current_release(paths)?); + keep.extend(previous.map(Path::to_path_buf)); + if let Some(journal) = UpdateJournal::read(paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) { + keep.insert(journal.target_release); + keep.extend(journal.previous_release); + } + let keep = keep + .into_iter() + .map(fs::canonicalize) + .collect::, _>>()?; + for entry in fs::read_dir(paths.releases())? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() && !keep.contains(&fs::canonicalize(&path)?) { + fs::remove_dir_all(path)?; + } + } + Ok(()) +} + +#[must_use] +pub const fn package_target() -> &'static str { + if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + "linux-x86_64" + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + "linux-aarch64" + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "macos-aarch64" + } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + "windows-x86_64" + } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) { + "windows-aarch64" + } else { + "unsupported" + } +} + +/// Runs and acknowledges a pending post-upgrade Session relaunch pass. +/// +/// # Errors +/// +/// Returns an error while retaining the marker when the pass cannot complete safely. +pub async fn consume_pending_session_relaunch( + home: &ControlPlaneHome, + service: &sessions::Service, +) -> Result<(), Error> { + let path = home.pending_session_relaunch_path(); + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + crate::local::home::secure_file(&path)?; + let marker: SessionRelaunchMarker = serde_json::from_slice(&bytes)?; + if marker.build_version != crate::build_version() { + return Err(Error::Daemon(format!( + "Session relaunch marker targets build {:?}, but this agentd is {:?}", + marker.build_version, + crate::build_version() + ))); + } + service.relaunch_after_upgrade().await?; + tokio::fs::remove_file(path).await?; + #[cfg(unix)] + sync_directory(home.path())?; + Ok(()) +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SessionRelaunchMarker { + build_version: String, +} + +async fn latest_release(repository: &str) -> Result { + #[derive(Deserialize)] + struct GithubRelease { + tag_name: String, + } + let client = http_client()?; + for page in 1_u32.. { + let releases = client + .get(format!( + "https://api.github.com/repos/{repository}/releases?per_page=100&page={page}" + )) + .header(reqwest::header::USER_AGENT, "agentctl-updater") + .send() + .await + .map_err(|error| Error::Daemon(format!("resolve Agent release: {error}")))? + .error_for_status() + .map_err(|error| Error::Daemon(format!("resolve Agent release: {error}")))? + .json::>() + .await + .map_err(|error| Error::Daemon(format!("decode Agent releases: {error}")))?; + if let Some(version) = releases + .iter() + .find_map(|release| release.tag_name.strip_prefix("experimental-agent/")) + { + return normalize_version(version); + } + if releases.len() < 100 { + break; + } + } + Err(Error::Invalid("GitHub has no experimental Agent release".into())) +} + +async fn download(url: &str, path: &Path) -> Result<(), Error> { + let bytes = http_client()? + .get(url) + .header(reqwest::header::USER_AGENT, "agentctl-updater") + .send() + .await + .map_err(|error| Error::Daemon(format!("download Agent package: {error}")))? + .error_for_status() + .map_err(|error| Error::Daemon(format!("download Agent package: {error}")))? + .bytes() + .await + .map_err(|error| Error::Daemon(format!("read Agent package: {error}")))?; + fs::write(path, bytes)?; + Ok(()) +} + +fn http_client() -> Result<&'static reqwest::Client, Error> { + static CLIENT: OnceLock> = OnceLock::new(); + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) + .build() + .map_err(|error| error.to_string()) + }) + .as_ref() + .map_err(|error| Error::Daemon(format!("create Agent update HTTP client: {error}"))) +} + +fn normalize_version(version: &str) -> Result { + let trimmed = version.trim(); + let bare = trimmed.strip_prefix('v').unwrap_or(trimmed); + semver::Version::parse(bare).map_err(|error| Error::Invalid(format!("invalid Agent release version: {error}")))?; + Ok(format!("v{bare}")) +} + +fn verify_checksum(archive: &Path, checksum: &Path) -> Result<(), Error> { + let expected = fs::read_to_string(checksum)? + .split_whitespace() + .next() + .ok_or_else(|| Error::Invalid("Agent checksum file is empty".into()))? + .to_ascii_lowercase(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::Invalid("Agent checksum is not SHA-256".into())); + } + let mut input = File::open(archive)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let read = input.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let actual = digest + .finalize() + .iter() + .fold(String::with_capacity(64), |mut output, byte| { + let _ = write!(output, "{byte:02x}"); + output + }); + if actual != expected { + return Err(Error::Invalid("Agent archive checksum mismatch".into())); + } + Ok(()) +} + +fn extract_archive(archive: &Path, destination: &Path) -> Result<(), Error> { + let mut package = tar::Archive::new(GzDecoder::new(File::open(archive)?)); + let expected = binary_names().into_iter().collect::>(); + let mut found = BTreeSet::new(); + for entry in package + .entries() + .map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))? + { + let mut entry = entry.map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))?; + let path = entry + .path() + .map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))?; + let mut components = path.components(); + let Some(Component::Normal(name)) = components.next() else { + return Err(Error::Invalid("Agent archive contains an invalid path".into())); + }; + if components.next().is_some() { + return Err(Error::Invalid( + "Agent archive must contain only top-level binaries".into(), + )); + } + let name = name + .to_str() + .ok_or_else(|| Error::Invalid("Agent archive contains a non-UTF-8 path".into()))?; + if !expected.contains(name) || !found.insert(name.to_owned()) || !entry.header().entry_type().is_file() { + return Err(Error::Invalid(format!("unexpected Agent archive entry {name:?}"))); + } + entry + .unpack(destination.join(name)) + .map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))?; + } + if found != expected { + return Err(Error::Invalid( + "Agent archive does not contain one matched agentctl and agentd".into(), + )); + } + Ok(()) +} + +/// Validates the exact package contents and both embedded build versions. +/// +/// # Errors +/// +/// Returns an error when the directory is not one matched release. +pub fn validate_release_directory(path: &Path, version: &str) -> Result<(), Error> { + let entries = fs::read_dir(path)? + .collect::, _>>()? + .into_iter() + .map(|entry| entry.file_name()) + .collect::>(); + let expected = binary_names().into_iter().map(Into::into).collect::>(); + if entries != expected { + return Err(Error::Invalid( + "Agent release directory must contain only agentctl and agentd".into(), + )); + } + for binary in binary_names() { + let executable = path.join(&binary); + require_executable(&executable)?; + let output = Command::new(&executable).arg("--version").output()?; + let actual = String::from_utf8_lossy(&output.stdout); + let name = binary.trim_end_matches(std::env::consts::EXE_SUFFIX); + if !output.status.success() || actual.trim() != format!("{name} {version}") { + return Err(Error::Invalid(format!( + "{} reports {:?}; expected {name} {version:?}", + executable.display(), + actual.trim() + ))); + } + } + Ok(()) +} + +/// Reads and validates the version encoded by one managed release directory. +/// +/// # Errors +/// +/// Returns an error when the directory name or package contents are invalid. +pub fn managed_release_version(path: &Path) -> Result { + let name = path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| Error::Invalid("managed Agent release has an invalid directory name".into()))?; + let version = name + .strip_suffix(&format!("-{}", package_target())) + .ok_or_else(|| Error::Invalid("managed Agent release does not match this platform".into()))?; + let version = normalize_version(version)?; + validate_release_directory(path, &version)?; + Ok(version) +} + +#[cfg(unix)] +fn require_executable(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt as _; + if fs::metadata(path)?.permissions().mode() & 0o111 == 0 { + return Err(Error::Invalid(format!("{} is not executable", path.display()))); + } + Ok(()) +} + +#[cfg(windows)] +fn require_executable(path: &Path) -> Result<(), Error> { + if !path.is_file() { + return Err(Error::Invalid(format!("{} is not a file", path.display()))); + } + Ok(()) +} + +fn binary_names() -> [String; 2] { + BINARY_STEMS.map(|name| format!("{name}{}", std::env::consts::EXE_SUFFIX)) +} + +fn atomic_json(path: &Path, value: &impl Serialize) -> Result<(), Error> { + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("state path has no parent directory".into()))?; + fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + let temporary = path.with_extension(format!("tmp-{}", std::process::id())); + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&temporary)?; + crate::local::home::secure_file(&temporary)?; + file.write_all(&serde_json::to_vec_pretty(value)?)?; + file.write_all(b"\n")?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, path)?; + #[cfg(unix)] + sync_directory(parent)?; + Ok(()) +} + +fn canonical_or_absolute(path: &Path) -> Result { + if path.exists() { + Ok(fs::canonicalize(path)?) + } else if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Err(Error::Invalid("managed installation path must be absolute".into())) + } +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(env::current_dir()?.join(path)) + } +} + +fn inferred_install_root() -> Result, Error> { + let executable = fs::canonicalize(env::current_exe()?)?; + Ok(install_root_for_executable(&executable) + .filter(|root| root.join("install.json").is_file() || root.join("update.json").is_file())) +} + +fn install_root_for_executable(executable: &Path) -> Option { + let releases = executable.parent()?.parent()?; + if releases.file_name()? != "releases" { + return None; + } + releases.parent().map(Path::to_path_buf) +} + +#[cfg(unix)] +fn default_install_root() -> Result { + if let Some(path) = env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path).join("agent")); + } + env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join(".local/share/agent")) + .ok_or_else(|| Error::Invalid("HOME is not set".into())) +} + +#[cfg(unix)] +fn default_bin_directory() -> Result { + env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join(".local/bin")) + .ok_or_else(|| Error::Invalid("HOME is not set".into())) +} + +#[cfg(windows)] +fn default_install_root() -> Result { + env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Agent")) + .ok_or_else(|| Error::Invalid("LOCALAPPDATA is not set".into())) +} + +#[cfg(unix)] +fn replace_directory_link(link: &Path, target: &Path) -> Result<(), Error> { + use std::os::unix::fs::symlink; + replace_symlink(link, |temporary| symlink(target, temporary)) +} + +#[cfg(unix)] +fn replace_file_link(link: &Path, target: &Path) -> Result<(), Error> { + use std::os::unix::fs::symlink; + replace_symlink(link, |temporary| symlink(target, temporary)) +} + +#[cfg(unix)] +fn replace_symlink(link: &Path, create: impl FnOnce(&Path) -> std::io::Result<()>) -> Result<(), Error> { + let temporary = link.with_extension(format!("next-{}", std::process::id())); + let _ = fs::remove_file(&temporary); + create(&temporary)?; + fs::rename(temporary, link)?; + Ok(()) +} + +#[cfg(windows)] +fn replace_windows_pointer(path: &Path, target: &Path) -> Result<(), Error> { + let target = windows_command_path(target); + replace_windows_file(path, format!("{target}\r\n").as_bytes()) +} + +#[cfg(windows)] +fn windows_command_path(path: &Path) -> String { + let path = path.to_string_lossy(); + if let Some(path) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{path}"); + } + if let Some(path) = path.strip_prefix(r"\\?\") { + return path.to_owned(); + } + path.into_owned() +} + +#[cfg(windows)] +fn replace_windows_file(path: &Path, contents: &[u8]) -> Result<(), Error> { + let temporary = path.with_extension(format!("next-{}", std::process::id())); + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&temporary)?; + file.write_all(contents)?; + file.sync_all()?; + drop(file); + fs::rename(temporary, path)?; + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), Error> { + File::open(path)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(windows)] + #[test] + fn command_paths_do_not_use_the_windows_verbatim_prefix() { + assert_eq!( + windows_command_path(Path::new(r"\\?\C:\Agent\releases\v2")), + r"C:\Agent\releases\v2" + ); + assert_eq!( + windows_command_path(Path::new(r"\\?\UNC\server\share\Agent")), + r"\\server\share\Agent" + ); + } + + #[test] + fn journal_rejects_release_outside_install_root() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let root = temporary.path().join("root"); + let paths = InstallPaths::new(root, temporary.path().join("bin")).expect("paths"); + fs::create_dir_all(paths.releases()).expect("releases"); + let journal = UpdateJournal::new(None, temporary.path().join("elsewhere"), "v2.0.0".into()); + assert!(journal.validate(&paths).is_err()); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn install_lock_reports_a_concurrent_update() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("paths"); + let _first = paths.lock().await.expect("first lock"); + + let error = paths.lock().await.expect_err("second lock"); + + assert!(error.to_string().contains("another Agent update is already running")); + } + + #[test] + fn managed_executable_identifies_its_install_root() { + let root = Path::new("managed/agent"); + let executable = root.join("releases/v2.0.0-linux-x86_64/agentctl"); + + assert_eq!(install_root_for_executable(&executable), Some(root.to_path_buf())); + assert_eq!(install_root_for_executable(Path::new("agentctl")), None); + } + + #[test] + fn install_metadata_preserves_the_visible_bin_directory() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = + InstallPaths::new(temporary.path().join("install"), temporary.path().join("custom-bin")).expect("paths"); + InstallMetadata::new("example/repository".into(), paths.bin().to_path_buf()) + .write(&paths) + .expect("metadata"); + + let metadata = InstallMetadata::read(&paths).expect("read metadata"); + + assert_eq!(metadata.repository(), "example/repository"); + assert_eq!(metadata.bin_directory, paths.bin()); + } + + #[test] + fn relaunch_marker_is_owner_only() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let home = ControlPlaneHome::resolve(Some(temporary.path())).expect("home"); + create_session_relaunch_marker(&home, "v2").expect("marker"); + let marker: SessionRelaunchMarker = + serde_json::from_slice(&fs::read(home.pending_session_relaunch_path()).expect("read marker")) + .expect("decode marker"); + assert_eq!(marker.build_version, "v2"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(home.pending_session_relaunch_path()) + .expect("metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[tokio::test(flavor = "local")] + async fn checksum_failure_does_not_create_a_release_or_current_pointer() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let archive = temporary.path().join("broken.tar.gz"); + let checksum = temporary.path().join("broken.tar.gz.sha256"); + fs::write(&archive, b"not an archive").expect("archive"); + fs::write(&checksum, format!("{} broken.tar.gz\n", "0".repeat(64))).expect("checksum"); + let paths = InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("paths"); + let release = Release { + version: "v1.0.0".into(), + repository: "example/repository".into(), + local_archive: Some(archive), + local_checksum: Some(checksum), + }; + + let error = stage_release(&paths, release).await.expect_err("checksum mismatch"); + + assert!(error.to_string().contains("checksum mismatch")); + assert!(!paths.current().exists()); + assert!(!paths.releases().exists()); + } + + #[cfg(unix)] + #[test] + fn activation_and_pruning_use_canonical_release_paths() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("paths"); + let release = paths.releases().join("v1.0.0-linux-x86_64"); + fs::create_dir_all(&release).expect("release"); + for binary in binary_names() { + fs::write(release.join(binary), []).expect("binary"); + } + + activate_release(&paths, &release).expect("activation"); + + assert_eq!( + current_release(&paths).expect("current"), + Some(fs::canonicalize(&release).expect("canonical release")) + ); + for binary in binary_names() { + assert_eq!( + fs::read_link(paths.bin().join(&binary)).expect("visible link"), + paths.current().join(binary) + ); + } + let stale = paths.releases().join("v0.9.0-linux-x86_64"); + fs::create_dir(&stale).expect("stale release"); + prune_releases(&paths, None).expect("prune releases"); + assert!(release.exists()); + assert!(!stale.exists()); + } + + #[cfg(unix)] + #[test] + fn managed_release_version_comes_from_the_active_package() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let release = temporary.path().join(format!("v2.3.4-preview.1-{}", package_target())); + fs::create_dir(&release).expect("release"); + // A child process writes the executables. A descriptor this process + // opened for writing leaks into any child another test is forking + // until that child execs, and executing the file meanwhile fails with + // ETXTBSY ("Text file busy"). + let written = Command::new("/bin/sh") + .arg("-c") + .arg(r#"for binary in agentctl agentd; do printf '#!/bin/sh\necho "%s v2.3.4-preview.1"\n' "$binary" > "$binary" && chmod 755 "$binary" || exit; done"#) + .current_dir(&release) + .status() + .expect("write binaries"); + assert!(written.success()); + + assert_eq!( + managed_release_version(&release).expect("managed version"), + "v2.3.4-preview.1" + ); + } +} diff --git a/agentctl/src/vnc/mod.rs b/agentctl/src/vnc/mod.rs new file mode 100644 index 0000000..ed70a79 --- /dev/null +++ b/agentctl/src/vnc/mod.rs @@ -0,0 +1,190 @@ +//! VNC access to Agents. +//! +//! The image runs the desktop and ships the units that bridge it to guest ports, disabled, and +//! lists them in `/etc/agent-access.d/vnc.conf`. When an Agent declares `access: [{type: vnc}]` +//! this module enables those units, and disables them when it stops. Nothing here names a socket, +//! viewer or URL, so an image with a different viewer needs no change here. +//! +//! This decides what the platform offers; it is not an isolation boundary. The Agent has `sudo` +//! and could enable the units itself. The Sandbox boundary, and forwarding that only the host can +//! start, protect the desktop, as they do every guest loopback port. + +use std::{cell::RefCell, collections::BTreeMap, path::PathBuf, rc::Rc}; + +use ::sandbox::SandboxHandle; +use serde::{Deserialize, Serialize}; + +use crate::{ + AgentId, Error, + control_plane::{AgentRecord, AgentStore}, + sandbox::platform, +}; + +/// Guest loopback port carrying the RFB stream. The image declares the same ports, and a mismatch +/// is refused when reconciling. +pub const GUEST_PORT: u16 = 5900; +/// Guest loopback port serving the image's browser-based viewer over HTTP. +pub const WEB_GUEST_PORT: u16 = 6080; +/// The `type` value of a VNC access descriptor. +pub const ACCESS_TYPE: &str = "vnc"; + +/// Machine-readable description of one Agent's VNC access. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessInfo { + /// Access kind, always `vnc`. + #[serde(rename = "type")] + pub kind: String, + /// Agent name. + pub agent: String, + /// Agent incarnation identity. + pub agent_id: AgentId, + /// Guest loopback port carrying the RFB stream. + pub guest_port: u16, + /// Guest loopback port serving the browser viewer, whose root the caller opens. Absent when + /// the image serves none, and until a pass has seen what the image declares. + pub web_guest_port: Option, + /// Complete command forwarding the RFB port to the caller's machine. + pub forward_command: String, +} + +/// Builds the message given when the Agent's image cannot serve VNC access. The image cannot +/// change for the incarnation, so it names the only fixes. +#[must_use] +pub fn image_contract_missing(what: &str) -> String { + format!( + "the Agent's image cannot provide VNC access: {what}; re-apply the Agent with an image that declares its \ + access units in /etc/agent-access.d/vnc.conf, such as the published desktop Agent image, or remove `vnc` \ + from spec.access" + ) +} + +/// Reconciles VNC access for Agents by enabling or disabling the units their image declares. +pub struct Access { + agentctl: PathBuf, + agents: Rc, + /// What the last successful pass left in each Agent's guest, so that describing an Agent needs + /// no call into its Sandbox and a resync can skip a withdrawal that already succeeded. Not + /// persisted: after a restart it is unknown until the next pass. + applied: RefCell>, +} + +/// What one successful reconciliation pass left in an Agent's guest. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Applied { + /// The image's units are enabled, with the browser viewer port it declared. + Granted { web_port: Option }, + /// The units are disabled, or the image declares none. + Withdrawn, +} + +impl Access { + /// Creates the VNC access reconciler; `agentctl` is the executable the forwarding command names. + #[must_use] + pub fn new(agentctl: PathBuf, agents: Rc) -> Self { + Self { + agentctl, + agents, + applied: RefCell::new(BTreeMap::new()), + } + } + + /// Describes the VNC access of a named Agent. + /// + /// # Errors + /// + /// Returns `Error::NotFound` for an unknown Agent, `Error::Conflict` while + /// it is being deleted, and `Error::Invalid` when it declares no VNC access. + pub async fn describe(&self, name: &str) -> Result { + let record = self.agents.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if !record.agent.spec.vnc_access() { + return Err(Error::Invalid(format!( + "Agent {name:?} does not declare VNC access; add `access: [{{type: vnc}}]` to its spec and re-apply" + ))); + } + Ok(self.info(&record)) + } + + /// Builds the descriptor for a record without consulting the store. + #[must_use] + pub fn info(&self, record: &AgentRecord) -> AccessInfo { + let name = &record.agent.metadata.name; + AccessInfo { + kind: ACCESS_TYPE.into(), + agent: name.clone(), + agent_id: record.id, + guest_port: GUEST_PORT, + web_guest_port: match self.applied.borrow().get(&record.id) { + Some(Applied::Granted { web_port }) => *web_port, + Some(Applied::Withdrawn) | None => None, + }, + // `:PORT` binds a free local port, as `agentctl vnc` does: 5900 is often taken locally. + forward_command: format!("{} port-forward agent/{name} :{GUEST_PORT}", self.agentctl.display()), + } + } + + /// Converges VNC access for one Agent and returns whether it has access afterwards. Without + /// declared access the units are disabled, leaving the desktop running with nothing + /// listening; a withdrawal that succeeded is not repeated for the same incarnation. + /// + /// # Errors + /// + /// Returns `Error::Invalid` when the image runs no desktop or the Sandbox + /// operating system is unsupported, and transient errors when the guest + /// setup cannot be applied. + pub async fn reconcile(&self, record: &AgentRecord, sandbox: &SandboxHandle) -> Result { + if !record.agent.spec.vnc_access() && self.applied.borrow().get(&record.id) == Some(&Applied::Withdrawn) { + return Ok(false); + } + match self.apply(record, sandbox).await { + Ok(applied) => { + self.applied.borrow_mut().insert(record.id, applied); + Ok(applied != Applied::Withdrawn) + } + Err(error) => { + // A failed pass can leave the guest between states, so nothing is known until a + // pass succeeds again. + self.applied.borrow_mut().remove(&record.id); + Err(error) + } + } + } + + async fn apply(&self, record: &AgentRecord, sandbox: &SandboxHandle) -> Result { + let os = sandbox.snapshot().image.platform.os.clone(); + if record.agent.spec.vnc_access() { + let capability = grant_guest_access(&os, sandbox).await?; + Ok(Applied::Granted { + web_port: capability.web_port, + }) + } else { + remove_guest_state(&os, sandbox).await?; + Ok(Applied::Withdrawn) + } + } + + /// Forgets what was applied to a deleted Agent incarnation. + pub fn forget(&self, id: AgentId) { + self.applied.borrow_mut().remove(&id); + } +} + +async fn grant_guest_access(os: &str, sandbox: &SandboxHandle) -> Result { + match os { + "linux" => platform::linux_vnc::grant(sandbox).await, + os => Err(Error::Invalid(format!( + "VNC access is not supported on Sandbox operating system {os:?}" + ))), + } +} + +async fn remove_guest_state(os: &str, sandbox: &SandboxHandle) -> Result<(), Error> { + match os { + "linux" => platform::linux_vnc::withdraw(sandbox).await, + // Nothing was ever granted on an unsupported operating system. + _ => Ok(()), + } +} diff --git a/agentctl/tests/agent_manifests.rs b/agentctl/tests/agent_manifests.rs new file mode 100644 index 0000000..57d7030 --- /dev/null +++ b/agentctl/tests/agent_manifests.rs @@ -0,0 +1,269 @@ +#![allow(clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +use agent::{Agent, Harness, MountSpec, manifest}; +use sandbox::image::ImageSource; + +fn repository_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") +} + +fn resolved(path: &Path) -> Agent { + manifest::resolve(path).expect("manifest should resolve").agent +} + +fn assert_inputs_exist(agent: &Agent, directory: &Path) { + // Naming the path matters: CI checks these out sparsely, so a source outside the + // checkout fails here and nowhere else, and the path is the whole diagnosis. + let home = directory.join(&agent.spec.home.source); + assert!(home.is_dir(), "home source {} is not a directory", home.display()); + for instruction in &agent.spec.instructions { + let source = directory.join(&instruction.source); + assert!( + source.is_file(), + "instruction source {} is not a file", + source.display() + ); + } + for skill in &agent.spec.skills { + let source = directory.join(&skill.source); + assert!( + source.join("SKILL.md").is_file(), + "skill source {} has no SKILL.md", + source.display() + ); + } + if let ImageSource::Build { + context, dockerfile, .. + } = &agent.spec.sandbox.image + { + let context = directory.join(context); + assert!(context.is_dir(), "image build context exists"); + assert!( + context.join(dockerfile).is_file(), + "Dockerfile is inside its build context" + ); + } +} + +#[test] +fn self_development_variants_are_local_independent_builds() { + let directory = repository_root().join("src/experimental/agent/examples/self-dev"); + let default = resolved(&directory.join("agent.yaml")); + let nested = resolved(&directory.join("agent.nested.yaml")); + let worktree = resolved(&directory.join("agent.worktree.yaml")); + + for agent in [&default, &nested, &worktree] { + assert_eq!( + agent.spec.sandbox.image, + ImageSource::Build { + context: PathBuf::from("."), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + } + ); + assert_inputs_exist(agent, &directory); + } + assert!(nested.spec.sandbox.resources.cpu() < default.spec.sandbox.resources.cpu()); + assert!(nested.spec.sandbox.resources.memory() < default.spec.sandbox.resources.memory()); + assert!( + nested.spec.sandbox.resources.root_filesystem().capacity() + < default.spec.sandbox.resources.root_filesystem().capacity() + ); + assert!(matches!( + &worktree.spec.sandbox.mounts[..], + [MountSpec::Bind { source, target, read_only: false }, MountSpec::Tmpfs { .. }] + if source == Path::new("../../../../..") + && target.as_str() == "/home/agent/code/altinn-studio" + )); + let dockerfile = std::fs::read_to_string(directory.join("Dockerfile")).expect("self-dev Dockerfile"); + assert!(!dockerfile.contains("ghcr.io/altinn/altinn-studio/agent")); + for manifest in ["agent.yaml", "agent.nested.yaml", "agent.worktree.yaml"] { + let text = std::fs::read_to_string(directory.join(manifest)).expect(manifest); + assert!(!text.contains("agents/"), "{manifest} does not consume the Altinn tree"); + } +} + +#[test] +fn altinn_variants_inherit_agent_policy_and_select_expected_images() { + for (agent, target) in [("full", "full"), ("minimal", "minimal"), ("desktop", "desktop")] { + let directory = repository_root().join("agents").join(agent); + let default = resolved(&directory.join("agent.yaml")); + let nested = resolved(&directory.join("agent.nested.yaml")); + let nested_build = resolved(&directory.join("agent.nested-build.yaml")); + let worktree = resolved(&directory.join("agent.worktree.yaml")); + + let published = ImageSource::Reference { + reference: format!("ghcr.io/altinn/altinn-studio/agent-{agent}:latest"), + }; + assert_eq!(default.spec.sandbox.image, published); + assert_eq!(nested.spec.sandbox.image, published); + assert_eq!(worktree.spec.sandbox.image, published); + assert_eq!( + nested_build.spec.sandbox.image, + ImageSource::Build { + context: PathBuf::from(".."), + dockerfile: PathBuf::from("Dockerfile"), + target: Some(target.into()), + } + ); + assert_inputs_exist(&nested_build, &directory); + assert_published_skills(&default, agent); + // The image owns the harness version, here as much as in the examples: a published + // manifest that named one would have to be edited for every image bump. + for variant in [&default, &nested, &worktree, &nested_build] { + for harness in &variant.spec.harnesses { + assert_eq!( + harness.version, None, + "{} pins a version for {:?}; the image owns it", + variant.metadata.name, harness.kind + ); + } + } + + assert_published_harnesses(&default); + + assert_eq!(default.spec.secrets.len(), 5); + let azure_devops_pat = default + .spec + .secrets + .iter() + .find(|secret| secret.environment == "AZURE_DEVOPS_PAT") + .expect("Azure DevOps PAT is declared as a mediated secret"); + assert_eq!(azure_devops_pat.source(), "AZURE_DEVOPS_PAT"); + assert!(azure_devops_pat.optional); + assert_eq!(azure_devops_pat.allowed_hosts, ["dev.azure.com"]); + assert_eq!(azure_devops_pat.inert_value(), "$AGENT_SECRET_AZURE_DEVOPS_PAT"); + for (environment, host) in [ + ("STUDIO_PROD_API_KEY", "altinn.studio"), + ("STUDIO_STAGING_API_KEY", "staging.altinn.studio"), + ("STUDIO_DEV_API_KEY", "dev.altinn.studio"), + ] { + let secret = default + .spec + .secrets + .iter() + .find(|secret| secret.environment == environment) + .expect("Studio API key is declared as a mediated secret"); + assert_eq!(secret.source(), environment); + assert!(secret.optional); + assert_eq!(secret.allowed_hosts, [host]); + assert_eq!(secret.inert_value(), format!("$AGENT_SECRET_{environment}")); + } + + let mut comparable_build = nested_build.clone(); + comparable_build.metadata.name = nested.metadata.name.clone(); + comparable_build.spec.sandbox.image = nested.spec.sandbox.image.clone(); + assert_eq!(comparable_build, nested, "nested-build changes only name and image"); + + assert!(nested.spec.sandbox.resources.cpu() < default.spec.sandbox.resources.cpu()); + assert!(nested.spec.sandbox.resources.memory() < default.spec.sandbox.resources.memory()); + assert!( + nested.spec.sandbox.resources.root_filesystem().capacity() + < default.spec.sandbox.resources.root_filesystem().capacity() + ); + assert!(matches!( + &worktree.spec.sandbox.mounts[..], + [MountSpec::Bind { source, target, read_only: false }, MountSpec::Tmpfs { .. }] + if source == Path::new("../..") && target.as_str() == "/home/agent/code/altinn-studio" + )); + for inherited in [&nested, &nested_build, &worktree] { + assert_eq!(inherited.spec.harnesses, default.spec.harnesses); + assert_eq!(inherited.spec.instructions, default.spec.instructions); + assert_eq!(inherited.spec.skills, default.spec.skills); + assert_eq!(inherited.spec.access, default.spec.access); + assert_eq!(inherited.spec.secrets, default.spec.secrets); + assert_eq!(inherited.spec.network, default.spec.network); + } + } + let dockerfile = std::fs::read_to_string(repository_root().join("agents/Dockerfile")).expect("Altinn Dockerfile"); + assert!(!dockerfile.contains("AGENT_VERSION")); + assert!(!dockerfile.contains("src/experimental/agent/install.sh")); + assert!(!dockerfile.contains("agentctl --version")); + assert!(dockerfile.contains("FROM base AS minimal")); + assert!(dockerfile.contains("FROM base AS full")); + assert!(dockerfile.contains("FROM full AS desktop")); + assert!(!dockerfile.contains("cargo build")); +} + +#[test] +fn agent_images_install_the_pinned_gh_stack_extension() { + let root = repository_root(); + for dockerfile in [ + root.join("agents/Dockerfile"), + root.join("src/experimental/agent/examples/minimal/Dockerfile"), + root.join("src/experimental/agent/examples/self-dev/Dockerfile"), + ] { + let text = std::fs::read_to_string(&dockerfile).expect("Agent Dockerfile"); + assert!( + text.contains("ARG GH_STACK_VERSION="), + "{} pins gh-stack", + dockerfile.display() + ); + assert!( + text.contains("github/gh-stack/releases/download/v${GH_STACK_VERSION}"), + "{} downloads gh-stack from its official releases", + dockerfile.display() + ); + assert!( + text.contains("/home/agent/.local/share/gh/extensions/gh-stack/gh-stack"), + "{} installs gh-stack for the agent user", + dockerfile.display() + ); + } +} + +#[test] +fn every_agent_ignores_local_variants() { + let root = repository_root(); + for directory in [ + root.join("agents/full"), + root.join("agents/minimal"), + root.join("agents/desktop"), + root.join("src/experimental/agent/examples/self-dev"), + ] { + let ignore = std::fs::read_to_string(directory.join(".gitignore")).expect("Agent .gitignore"); + assert!(ignore.lines().any(|line| line == "agent.*.yaml")); + } +} + +/// Claude Code is required and the default; Codex is optional, so an Agent is created without it +/// on a host that has no Codex login rather than refusing to be created at all. +fn assert_published_harnesses(agent: &Agent) { + let claude = agent + .spec + .harness(Harness::ClaudeCode) + .expect("published manifests install Claude Code"); + assert!(!claude.optional); + assert!(claude.default); + let codex = agent + .spec + .harness(Harness::Codex) + .expect("published manifests install Codex"); + assert!(codex.optional); + assert!(!codex.default); +} + +/// Every repository-wide Skill is installed for every published Agent, and for every harness. +/// +/// They live in `.claude/skills/`, where Claude Code discovers them in a plain checkout, and the +/// manifests reach across so an Agent installs them for every harness as well. A Skill added there +/// and not added here reaches a local checkout only, which is the failure this guards. The desktop +/// Agent adds the Skill for driving its screen. +fn assert_published_skills(agent: &Agent, directory: &str) { + let mut expected = vec!["altinn-studio-app-development", "pr-evidence"]; + if directory == "desktop" { + expected.push("computer-use"); + } + expected.extend(["changelog", "tekstforfatter-docs", "text-content-review"]); + assert_eq!( + agent + .spec + .skills + .iter() + .filter_map(|skill| skill.name()) + .collect::>(), + expected + ); +} diff --git a/agentctl/tests/architecture.rs b/agentctl/tests/architecture.rs new file mode 100644 index 0000000..0666fd1 --- /dev/null +++ b/agentctl/tests/architecture.rs @@ -0,0 +1,169 @@ +#![allow(clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +#[test] +fn harness_internals_are_contained_by_the_harness_adapter() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let harness = source.join("harness"); + let binaries = source.join("bin"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + // The harness adapter owns harness internals. The `bin/` composition + // roots legitimately name a harness to dispatch on the closed enum + // (like `agentd` naming a Sandbox Provider), but must still not carry + // harness implementation details — asserted separately below. + if path.starts_with(&harness) || path.starts_with(&binaries) { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + let lowercase = contents.to_ascii_lowercase(); + for implementation_name in ["claude", "anthropic", "sk-ant", "codex", "openai"] { + assert!( + !lowercase.contains(implementation_name), + "harness-specific name {implementation_name:?} leaked into {}", + path.display() + ); + } + } + + let mut binary_files = Vec::new(); + rust_files(&binaries, &mut binary_files); + for path in binary_files { + let contents = std::fs::read_to_string(&path).expect("binary source should be readable"); + let lowercase = contents.to_ascii_lowercase(); + for implementation_detail in [ + "anthropic", + "sk-ant", + "setup-token", + "oauth", + ".credentials.json", + "api.openai", + "auth.json", + "sk-agent-mediated", + ] { + assert!( + !lowercase.contains(implementation_detail), + "harness implementation detail {implementation_detail:?} leaked into {}", + path.display() + ); + } + } + + let dispatch = std::fs::read_to_string(harness.join("mod.rs")).expect("harness dispatch should be readable"); + let lowercase = dispatch.to_ascii_lowercase(); + for implementation_detail in [ + "api.anthropic", + "sk-ant", + "/home/agent/.claude", + "oauth", + "access-token", + "refresh-token", + "api.openai", + "auth.json", + "sk-agent-mediated", + ] { + assert!( + !lowercase.contains(implementation_detail), + "harness implementation detail {implementation_detail:?} leaked into the generic dispatch" + ); + } +} + +#[test] +fn microsandbox_internals_are_contained_by_the_microsandbox_adapter() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let adapter = source.join("sandbox").join("microsandbox"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + if path.starts_with(&adapter) { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + assert!( + !contents.contains("sandbox_microsandbox"), + "Microsandbox implementation leaked into {}", + path.display() + ); + } +} + +#[test] +fn sandbox_operating_system_details_are_contained_by_platform_and_harness_adapters() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + if path.starts_with(source.join("sandbox").join("platform")) + || path.starts_with(source.join("harness")) + || path.starts_with(source.join("sandbox").join("microsandbox")) + || path == source.join("sessions").join("runtime").join("tmux.rs") + { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + for platform_detail in ["/home/agent", "/usr/bin/"] { + assert!( + !contents.contains(platform_detail), + "Sandbox-platform detail {platform_detail:?} leaked into {}", + path.display() + ); + } + } +} + +#[test] +fn tmux_implementation_details_are_contained_by_its_session_runtime() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let runtime = source.join("sessions").join("runtime").join("tmux.rs"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + if path == runtime { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + for implementation_detail in ["/usr/bin/tmux", "has-session", "new-session", "attach-session"] { + assert!( + !contents.contains(implementation_detail), + "tmux implementation detail {implementation_detail:?} leaked into {}", + path.display() + ); + } + } +} + +#[test] +fn control_plane_core_does_not_reference_concrete_sandbox_or_harness_implementations() { + let control_plane = Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join("control_plane"); + let mut files = Vec::new(); + rust_files(&control_plane, &mut files); + + for path in files { + let contents = std::fs::read_to_string(&path).expect("control-plane source should be readable"); + for concrete in ["microsandbox", "claude", "Linux", "AgentPreparation", "AgentBootstrap"] { + assert!( + !contents.contains(concrete), + "concrete runtime detail {concrete:?} leaked into {}", + path.display() + ); + } + } +} + +fn rust_files(directory: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(directory).expect("Agent source directory should be readable") { + let path = entry.expect("Agent source entry should be readable").path(); + if path.is_dir() { + rust_files(&path, files); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } +} diff --git a/agentctl/tests/control_api.rs b/agentctl/tests/control_api.rs new file mode 100644 index 0000000..5cef5e4 --- /dev/null +++ b/agentctl/tests/control_api.rs @@ -0,0 +1,1221 @@ +#![allow(clippy::expect_used, clippy::panic)] + +mod support; + +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + time::Duration, +}; + +use agent::{ + Error, + control_api::{ + AuthenticationApi, Client, Connection, Connector, ConvergenceApi, ExecutionApi, Server, SessionApi, + SshAccessApi, VncAccessApi, + }, + control_plane::WaitPolicy, + control_plane::{ApplyRequest, ControlPlane, memory::InMemoryAgentStore}, + harness::ImportedAuthentication, + resources::Changes, +}; +use sandbox::LocalFuture; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + sync::Notify, +}; + +use support::{IgnoreNotifications, agent}; + +struct FakeAuthentication; +/// Converges an Agent at once, except `stuck`, which never converges. +struct FakeConvergence; +struct FakeSshAccess; +struct FakeVncAccess; + +impl SshAccessApi for FakeSshAccess { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + if name != "worker" { + return Err(Error::NotFound); + } + Ok(agent::ssh::AccessInfo { + kind: "ssh".into(), + agent: name.into(), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + alias: "agentctl-worker".into(), + user: "agent".into(), + identity_file: "/home/me/.agent/ssh/38f41de4-6ff7-4679-ae46-678bc61e4dcb/id_ed25519".into(), + known_hosts_file: "/home/me/.agent/ssh/known_hosts".into(), + config_file: "/home/me/.agent/ssh/config".into(), + proxy_command: "/usr/local/bin/agentctl ssh-proxy agent/worker".into(), + working_directory: "/home/agent/code".into(), + }) + }) + } +} + +impl VncAccessApi for FakeVncAccess { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + if name != "worker" { + return Err(Error::NotFound); + } + Ok(agent::vnc::AccessInfo { + kind: "vnc".into(), + agent: name.into(), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + guest_port: 5900, + web_guest_port: Some(6080), + forward_command: "/usr/local/bin/agentctl port-forward agent/worker :5900".into(), + }) + }) + } +} +/// Executions of Agent `worker`; an ensure for `stuck` waits until dropped. +#[derive(Default)] +struct FakeExecutions { + waiting: Rc>, +} + +/// Counts one waiting ensure for as long as it lives. +struct Waiting(Rc>); + +impl Drop for Waiting { + fn drop(&mut self) { + self.0.set(self.0.get() - 1); + } +} +/// One `sessions.v1.prompt` as the fake saw it: prompt, wait flag, timeout. +type SentMessage = (String, bool, Option); + +#[derive(Default)] +struct UpgradeGates { + prompt: RefCell>>, + prompt_started: Notify, + readiness: RefCell>>, + readiness_started: Notify, +} + +struct FakeSessions { + ensured: Rc>>, + sent: Rc>>, + deleted: Rc>>, + archived: Rc>>, + upgrade_blockers: Rc>>, + upgrade_warnings: Rc>>, + upgrade_gates: Rc, +} + +fn answered_turn(prompt: &str, answer: &str) -> agent::sessions::Turn { + agent::sessions::Turn { + messages: vec![ + agent::sessions::Message { + role: agent::sessions::Role::User, + parts: vec![agent::sessions::Part::Text { text: prompt.into() }], + }, + agent::sessions::Message { + role: agent::sessions::Role::Assistant, + parts: vec![ + agent::sessions::Part::ToolCall { + name: "Bash".into(), + failed: true, + }, + agent::sessions::Part::Text { text: answer.into() }, + ], + }, + ], + } +} + +impl AuthenticationApi for FakeAuthentication { + fn login<'a>( + &'a self, + _harness: agent::Harness, + _token: &'a str, + _imported: bool, + ) -> LocalFuture<'a, Result> { + Box::pin(async { + Ok(ImportedAuthentication { + provider: "claude".into(), + ready: true, + }) + }) + } +} + +impl SessionApi for FakeSessions { + fn ensure<'a>( + &'a self, + _agent: &'a str, + _name: &'a agent::sessions::SessionName, + request: agent::sessions::SessionRequest, + _wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + self.ensured.borrow_mut().push(request); + Box::pin(async { Err(Error::NotFound) }) + } + + fn get<'a>( + &'a self, + _agent: &'a str, + _name: &'a agent::sessions::SessionName, + ) -> LocalFuture<'a, Result> { + Box::pin(async { Err(Error::NotFound) }) + } + + fn list<'a>(&'a self, _agent: Option<&'a str>) -> LocalFuture<'a, Result, Error>> { + Box::pin(async { Ok(Vec::new()) }) + } + + fn prompt<'a>( + &'a self, + agent: &'a str, + _name: &'a agent::sessions::SessionName, + prompt: &'a str, + wait: bool, + timeout: Option, + ) -> LocalFuture<'a, Result<(), Error>> { + self.sent.borrow_mut().push((prompt.to_owned(), wait, timeout)); + let gate = self.upgrade_gates.prompt.borrow().clone(); + let upgrade_gates = self.upgrade_gates.clone(); + let blockers = self.upgrade_blockers.clone(); + Box::pin(async move { + if agent != "worker" { + return Err(Error::NotFound); + } + if let Some(gate) = gate { + upgrade_gates.prompt_started.notify_one(); + gate.notified().await; + blockers.borrow_mut().push("session/worker/s1 (working)".into()); + } + Ok(()) + }) + } + + fn turns<'a>( + &'a self, + agent: &'a str, + _name: &'a agent::sessions::SessionName, + last: Option, + ) -> LocalFuture<'a, Result, Error>> { + Box::pin(async move { + if agent != "worker" { + return Err(Error::NotFound); + } + let mut turns = vec![answered_turn("one", "1"), answered_turn("two", "2")]; + if let Some(last) = last { + turns.drain(0..turns.len().saturating_sub(last)); + } + Ok(turns) + }) + } + + fn set_archived<'a>( + &'a self, + agent: &'a str, + name: &'a agent::sessions::SessionName, + archived: bool, + ) -> LocalFuture<'a, Result> { + self.archived + .borrow_mut() + .push((agent.to_owned(), name.clone(), archived)); + Box::pin(async move { + if agent != "worker" { + return Err(Error::NotFound); + } + let session = serde_json::json!({ + "id": "00000000-0000-4000-8000-000000000001", + "agentId": "00000000-0000-4000-8000-000000000002", + "agent": agent, + "name": name, + "harness": "claudeCode", + "createdAt": "2026-09-25T00:00:00Z", + "archivedAt": archived.then_some("2026-09-25T00:00:01Z"), + }); + Ok(serde_json::from_value(session).expect("archived Session")) + }) + } + + fn delete<'a>( + &'a self, + agent: &'a str, + name: &'a agent::sessions::SessionName, + ) -> LocalFuture<'a, Result<(), Error>> { + self.deleted.borrow_mut().push((agent.to_owned(), name.clone())); + Box::pin(async move { + if agent == "worker" { + Ok(()) + } else { + Err(Error::NotFound) + } + }) + } + + fn upgrade_readiness(&self) -> LocalFuture<'_, Result> { + let blockers = self.upgrade_blockers.borrow().clone(); + let warnings = self.upgrade_warnings.borrow().clone(); + let gate = self.upgrade_gates.readiness.borrow().clone(); + let upgrade_gates = self.upgrade_gates.clone(); + Box::pin(async move { + if let Some(gate) = gate { + upgrade_gates.readiness_started.notify_one(); + gate.notified().await; + } + Ok(agent::sessions::UpgradeReadiness { blockers, warnings }) + }) + } +} + +impl ConvergenceApi for FakeConvergence { + fn converge<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + if name == "stuck" { + std::future::pending::<()>().await; + } + Ok(()) + }) + } +} + +impl ExecutionApi for FakeExecutions { + fn ensure<'a>( + &'a self, + name: &'a str, + _wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + if name == "stuck" { + self.waiting.set(self.waiting.get() + 1); + let _waiting = Waiting(self.waiting.clone()); + std::future::pending::<()>().await; + } + if name == "stopped" { + return Err(Error::Stopped(name.into())); + } + if name != "worker" { + return Err(Error::NotFound); + } + Ok(agent::sandbox::ExecutionTarget { + sandbox: agent::sandbox::Assignment::Materialized { + provider: agent::sandbox::ProviderId::new("memory")?, + id: "ca4e2f21-91d9-43f1-97c6-13f0f350fbe7" + .parse() + .map_err(|error| Error::Invalid(format!("invalid test Sandbox ID: {error}")))?, + harnesses: Vec::new(), + }, + operating_system: "linux".into(), + }) + }) + } +} + +struct InProcessConnector { + server: Rc, +} + +struct ScriptedConnector { + frames: &'static str, +} + +struct ApiFixture { + server: Rc, + client: Client, + /// Execution ensures still waiting in the server. + waiting: Rc>, + ensured: Rc>>, + sent: Rc>>, + changes: Changes, + deleted: Rc>>, + archived: Rc>>, + upgrade_blockers: Rc>>, + upgrade_warnings: Rc>>, + upgrade_gates: Rc, +} + +impl Connector for InProcessConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let (client, server) = tokio::io::duplex(64 * 1024); + let api = self.server.clone(); + tokio::task::spawn_local(async move { + let _ignored = api.serve_connection(server).await; + }); + Ok(Box::new(client) as Box) + }) + } +} + +impl Connector for ScriptedConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let (client, server) = tokio::io::duplex(16 * 1024); + let frames = self.frames; + tokio::task::spawn_local(async move { + let mut server = BufReader::new(server); + let mut request = String::new(); + server.read_line(&mut request).await.expect("request"); + server.get_mut().write_all(frames.as_bytes()).await.expect("responses"); + }); + Ok(Box::new(client) as Box) + }) + } +} + +fn api() -> ApiFixture { + let control_plane = Rc::new(ControlPlane::new( + Rc::new(InMemoryAgentStore::new()), + Rc::new(IgnoreNotifications), + )); + let ensured = Rc::new(RefCell::new(Vec::new())); + let sent = Rc::new(RefCell::new(Vec::new())); + let deleted = Rc::new(RefCell::new(Vec::new())); + let archived = Rc::new(RefCell::new(Vec::new())); + let observed_errors = Rc::new(RefCell::new(Vec::new())); + let changes = Changes::new(); + let upgrade_blockers = Rc::new(RefCell::new(Vec::new())); + let upgrade_warnings = Rc::new(RefCell::new(Vec::new())); + let upgrade_gates = Rc::new(UpgradeGates::default()); + let executions = Rc::new(FakeExecutions::default()); + let waiting = executions.waiting.clone(); + let server = Rc::new(Server::new( + control_plane, + Rc::new(FakeAuthentication), + Rc::new(FakeConvergence), + executions, + Rc::new(FakeSessions { + ensured: ensured.clone(), + sent: sent.clone(), + deleted: deleted.clone(), + archived: archived.clone(), + upgrade_blockers: upgrade_blockers.clone(), + upgrade_warnings: upgrade_warnings.clone(), + upgrade_gates: upgrade_gates.clone(), + }), + Rc::new(FakeSshAccess), + Rc::new(FakeVncAccess), + changes.clone(), + Rc::new(move |error| observed_errors.borrow_mut().push(error.to_string())), + )); + let client = Client::new(Rc::new(InProcessConnector { server: server.clone() })); + ApiFixture { + server, + client, + waiting, + ensured, + sent, + changes, + deleted, + archived, + upgrade_blockers, + upgrade_warnings, + upgrade_gates, + } +} + +struct DelayedConnector { + inner: InProcessConnector, +} + +impl Connector for DelayedConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + self.inner.connect().await + }) + } +} + +#[tokio::test(flavor = "local")] +async fn prompt_completion_timeout_is_unchanged_by_transit() { + let fixture = api(); + let client = Client::new(Rc::new(DelayedConnector { + inner: InProcessConnector { + server: fixture.server.clone(), + }, + })); + client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "go".into(), + true, + Some(Duration::from_millis(20)), + ) + .await + .expect("delivered"); + assert_eq!( + fixture.sent.borrow().as_slice(), + [("go".into(), true, Some(Duration::from_millis(20)))] + ); +} + +#[tokio::test(flavor = "local")] +async fn session_send_and_turns_round_trip_with_their_parameters() { + let fixture = api(); + let name = agent::sessions::SessionName::new("s1").expect("name"); + + fixture + .client + .prompt_session( + "worker", + name.clone(), + "do it".into(), + true, + Some(std::time::Duration::from_secs(90)), + ) + .await + .expect("send with wait"); + fixture + .client + .prompt_session("worker", name.clone(), "fire and forget".into(), false, None) + .await + .expect("send without wait"); + { + let sent = fixture.sent.borrow(); + assert_eq!(sent.len(), 2); + assert_eq!((&sent[0].0, sent[0].1), (&"do it".to_owned(), true)); + assert_eq!(sent[0].2, Some(Duration::from_secs(90))); + assert_eq!(sent[1], ("fire and forget".to_owned(), false, None)); + } + + let last = fixture + .client + .session_turns("worker", name.clone(), Some(1)) + .await + .expect("turns"); + assert_eq!(last.len(), 1); + assert_eq!(last[0], answered_turn("two", "2")); + assert_eq!( + fixture + .client + .session_turns("worker", name.clone(), None) + .await + .expect("turns") + .len(), + 2 + ); + let missing = fixture + .client + .prompt_session("ghost", name, "hello".into(), false, None) + .await + .expect_err("unknown Agent"); + match missing { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn session_archive_and_unarchive_round_trip_and_report_a_missing_session() { + let fixture = api(); + let name = agent::sessions::SessionName::new("s1").expect("name"); + + let archived = fixture + .client + .set_session_archived("worker", name.clone(), true) + .await + .expect("archive Session"); + assert!(archived.is_archived()); + let unarchived = fixture + .client + .set_session_archived("worker", name.clone(), false) + .await + .expect("unarchive Session"); + assert!(!unarchived.is_archived()); + assert_eq!( + fixture.archived.borrow().as_slice(), + [ + ("worker".to_owned(), name.clone(), true), + ("worker".to_owned(), name.clone(), false) + ] + ); + + let missing = fixture + .client + .set_session_archived("ghost", name, true) + .await + .expect_err("unknown Agent"); + match missing { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn session_deletion_round_trips_and_reports_a_missing_session() { + let fixture = api(); + let name = agent::sessions::SessionName::new("s1").expect("name"); + + fixture + .client + .delete_session("worker", name.clone()) + .await + .expect("delete Session"); + assert_eq!( + fixture.deleted.borrow().as_slice(), + [("worker".to_owned(), name.clone())] + ); + + let missing = fixture + .client + .delete_session("ghost", name) + .await + .expect_err("unknown Agent"); + match missing { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn a_wait_ends_when_its_client_goes_away() { + let fixture = api(); + let interrupted = tokio::time::timeout( + Duration::from_millis(100), + fixture.client.ensure_execution("stuck", WaitPolicy::UntilConverged), + ) + .await; + assert!(interrupted.is_err(), "the wait never ends on its own"); + + tokio::time::timeout(Duration::from_secs(1), async { + while fixture.waiting.get() > 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("the server stops waiting once its client has gone"); +} + +#[tokio::test(flavor = "local")] +async fn an_interrupted_prompt_is_still_delivered() { + let fixture = api(); + let gate = Rc::new(Notify::new()); + fixture.upgrade_gates.prompt.replace(Some(gate.clone())); + let interrupted = tokio::time::timeout(Duration::from_millis(100), async { + let prompting = fixture.client.prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "go".into(), + false, + None, + ); + tokio::select! { + result = prompting => result, + () = async { + fixture.upgrade_gates.prompt_started.notified().await; + std::future::pending::<()>().await; + } => unreachable!(), + } + }) + .await; + assert!(interrupted.is_err(), "the delivery is still in progress"); + + gate.notify_one(); + tokio::time::timeout(Duration::from_secs(1), async { + while fixture.upgrade_blockers.borrow().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the delivery runs to completion without its client"); +} + +#[tokio::test(flavor = "local")] +async fn login_returns_only_non_secret_readiness() { + let fixture = api(); + let imported = fixture + .client + .auth_login(agent::Harness::ClaudeCode, "sk-ant-oat01-canary".into(), false) + .await + .expect("login"); + assert_eq!(imported.provider, "claude"); + assert!(imported.ready); +} + +#[tokio::test(flavor = "local")] +async fn health_reports_a_compatible_daemon() { + let fixture = api(); + let daemon = fixture.client.require_compatible_daemon().await.expect("health check"); + assert_eq!(daemon.protocol_version.as_deref(), Some("v4")); + assert_eq!(daemon.build_version.as_deref(), Some(agent::build_version())); +} + +#[test] +fn daemon_identity_rejects_preview_1_and_mixed_builds() { + let extended: agent::control_api::DaemonInfo = serde_json::from_value(serde_json::json!({ + "protocolVersion": "v4", + "buildVersion": agent::build_version(), + "futureCapability": true + })) + .expect("extended health response"); + extended.require_compatible().expect("compatible extended response"); + + for daemon in [ + agent::control_api::DaemonInfo { + protocol_version: Some("v1".into()), + build_version: None, + }, + agent::control_api::DaemonInfo { + protocol_version: Some("v4".into()), + build_version: Some("another-build".into()), + }, + ] { + let error = daemon.require_compatible().expect_err("incompatible daemon"); + assert!(error.to_string().contains("running agentd is incompatible")); + } +} + +#[tokio::test(flavor = "local")] +async fn shutdown_reports_blocking_sessions_without_draining() { + let fixture = api(); + fixture + .upgrade_blockers + .borrow_mut() + .push("session/worker/busy (working)".into()); + let error = fixture + .client + .shutdown_for_upgrade() + .await + .expect_err("working Session blocks shutdown"); + assert!(matches!(error, Error::Rpc(error) if error.is_invalid_params())); + fixture.client.health().await.expect("daemon remains available"); +} + +#[tokio::test(flavor = "local")] +async fn shutdown_returns_nonblocking_session_warnings() { + let fixture = api(); + fixture + .upgrade_warnings + .borrow_mut() + .push("session/worker/fresh will start a new conversation".into()); + + assert_eq!( + fixture.client.shutdown_for_upgrade().await.expect("shutdown"), + ["session/worker/fresh will start a new conversation"] + ); +} + +#[tokio::test(flavor = "local")] +async fn shutdown_waits_for_admitted_mutations_before_checking_sessions() { + let fixture = api(); + let gate = Rc::new(Notify::new()); + *fixture.upgrade_gates.prompt.borrow_mut() = Some(gate.clone()); + let prompt_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let shutdown_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let started = fixture.upgrade_gates.prompt_started.notified(); + let prompt = tokio::task::spawn_local(async move { + prompt_client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "start work".into(), + false, + None, + ) + .await + }); + started.await; + + let shutdown = tokio::task::spawn_local(async move { shutdown_client.shutdown_for_upgrade().await }); + tokio::task::yield_now().await; + assert!(!shutdown.is_finished(), "shutdown passed the pending prompt"); + + gate.notify_one(); + prompt.await.expect("prompt task").expect("prompt response"); + let error = shutdown + .await + .expect("shutdown task") + .expect_err("new work blocks shutdown"); + assert!(matches!(error, Error::Rpc(error) if error.is_invalid_params())); + fixture + .client + .health() + .await + .expect("rejected shutdown restores admission"); +} + +#[tokio::test(flavor = "local")] +async fn shutdown_rejects_reported_work_before_waiting_for_admitted_mutations() { + let fixture = api(); + let gate = Rc::new(Notify::new()); + *fixture.upgrade_gates.prompt.borrow_mut() = Some(gate.clone()); + let prompt_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let shutdown_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let started = fixture.upgrade_gates.prompt_started.notified(); + let prompt = tokio::task::spawn_local(async move { + prompt_client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "continue work".into(), + true, + Some(Duration::from_secs(10)), + ) + .await + }); + started.await; + fixture + .upgrade_blockers + .borrow_mut() + .push("session/worker/s1 (working)".into()); + + let error = tokio::time::timeout(Duration::from_secs(1), shutdown_client.shutdown_for_upgrade()) + .await + .expect("shutdown should inspect reported work without draining the prompt") + .expect_err("reported work blocks shutdown"); + assert!(matches!(error, Error::Rpc(error) if error.is_invalid_params())); + assert!( + !prompt.is_finished(), + "rejected shutdown must not wait for the active prompt" + ); + fixture.client.health().await.expect("daemon remains available"); + + gate.notify_one(); + prompt.await.expect("prompt task").expect("prompt response"); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn shutdown_preparation_has_one_deadline_and_restores_admission() { + let fixture = api(); + let prompt_gate = Rc::new(Notify::new()); + let readiness_gate = Rc::new(Notify::new()); + *fixture.upgrade_gates.prompt.borrow_mut() = Some(prompt_gate.clone()); + *fixture.upgrade_gates.readiness.borrow_mut() = Some(readiness_gate); + let prompt_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let shutdown_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let prompt_started = fixture.upgrade_gates.prompt_started.notified(); + let prompt = tokio::task::spawn_local(async move { + prompt_client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "start work".into(), + false, + None, + ) + .await + }); + prompt_started.await; + + let shutdown = tokio::task::spawn_local(async move { shutdown_client.shutdown_for_upgrade().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(59)).await; + prompt_gate.notify_one(); + prompt.await.expect("prompt task").expect("prompt response"); + fixture.upgrade_gates.readiness_started.notified().await; + tokio::time::advance(Duration::from_secs(2)).await; + + let error = shutdown + .await + .expect("shutdown task") + .expect_err("preparation exceeds its shared deadline"); + assert!(error.to_string().contains("did not finish preparing")); + *fixture.upgrade_gates.prompt.borrow_mut() = None; + fixture + .client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s2").expect("name"), + "still admitted".into(), + false, + None, + ) + .await + .expect("timed out shutdown restores admission"); +} + +fn request(name: &str) -> ApplyRequest { + ApplyRequest { + source_directory: std::env::temp_dir().join("agent-platform-source"), + manifest_path: None, + env_file: None, + create_only: false, + agent: agent(name), + } +} + +#[tokio::test(flavor = "local")] +async fn client_and_server_exchange_versioned_agent_operations() { + let fixture = api(); + let client = &fixture.client; + let applied = client.apply(request("worker")).await.expect("apply"); + let fetched = client.get("worker").await.expect("get"); + assert_eq!(applied, fetched); + assert_eq!(client.list_agents().await.expect("list"), vec![applied.clone()]); + assert_eq!( + client + .resolve_agent(request("worker").source_directory.join("nested")) + .await + .expect("resolve source"), + applied + ); + let execution = client + .ensure_execution("worker", WaitPolicy::FirstPass) + .await + .expect("execution target"); + assert_eq!(execution.operating_system, "linux"); + assert_eq!(execution.sandbox.provider().as_str(), "memory"); + assert!(client.list_sessions(None).await.expect("list all Sessions").is_empty()); + let request = agent::sessions::SessionRequest { + harness: Some(agent::Harness::ClaudeCode), + model_selection: agent::ModelSelection { + model: Some(agent::Model::new("claude-fable-5").expect("model")), + effort: Some(agent::Effort::new("xhigh").expect("effort")), + }, + initial_prompt: None, + }; + let ensure_error = client + .ensure_session( + "worker", + agent::sessions::SessionName::new("s1").expect("Session name"), + request.clone(), + WaitPolicy::FirstPass, + ) + .await + .expect_err("fake Session ensure should fail after decoding parameters"); + assert!(matches!(ensure_error, Error::Rpc(error) if error.code == -32004)); + let omitted = client + .ensure_session( + "worker", + agent::sessions::SessionName::new("s2").expect("Session name"), + agent::sessions::SessionRequest::default(), + WaitPolicy::FirstPass, + ) + .await + .expect_err("fake Session ensure should fail after decoding parameters"); + assert!(matches!(omitted, Error::Rpc(error) if error.code == -32004)); + assert_eq!( + fixture.ensured.borrow().as_slice(), + &[request, agent::sessions::SessionRequest::default()], + "model and effort travel as opaque values and stay absent when omitted" + ); + let session_error = client + .get_session("worker", agent::sessions::SessionName::new("s1").expect("Session name")) + .await + .expect_err("missing Session"); + assert!(matches!(session_error, Error::Rpc(error) if error.code == -32004)); + + client.delete("worker").await.expect("delete request"); + let deleting = client.get("worker").await.expect("marked resource"); + assert!(deleting.metadata.deletion_timestamp.is_some()); +} + +#[tokio::test(flavor = "local")] +async fn resource_watch_returns_current_state_then_waits_for_the_next_change() { + let fixture = api(); + let initial = fixture.client.watch_resources(None).await.expect("initial state"); + assert!(initial.agents.is_empty()); + assert!(initial.sessions.is_empty()); + + let watcher = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let revision = initial.revision; + let watch = tokio::task::spawn_local(async move { watcher.watch_resources(Some(revision)).await }); + tokio::task::yield_now().await; + assert!(!watch.is_finished(), "a current revision waits for a change"); + + let applied = fixture.client.apply(request("worker")).await.expect("apply"); + fixture.changes.bump(); + let changed = watch.await.expect("watch task").expect("changed state"); + assert_ne!(changed.revision, revision); + assert_eq!(changed.agents, vec![applied]); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn resource_watch_replies_unchanged_after_the_keepalive() { + let fixture = api(); + let current = fixture.client.watch_resources(None).await.expect("initial state"); + let started = tokio::time::Instant::now(); + let unchanged = fixture + .client + .watch_resources(Some(current.revision)) + .await + .expect("keepalive state"); + assert_eq!(unchanged, current); + assert_eq!(started.elapsed(), Duration::from_secs(30)); +} + +#[tokio::test(flavor = "local")] +async fn resource_watch_neither_holds_nor_outlives_an_upgrade_drain() { + let fixture = api(); + let current = fixture.client.watch_resources(None).await.expect("initial state"); + let watcher = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let watch = tokio::task::spawn_local(async move { watcher.watch_resources(Some(current.revision)).await }); + tokio::task::yield_now().await; + + fixture + .client + .shutdown_for_upgrade() + .await + .expect("a pending watch is not an admitted mutation"); + let released = watch.await.expect("watch task").expect("state on drain"); + assert_eq!(released.revision, current.revision); +} + +#[tokio::test(flavor = "local")] +async fn a_converge_wait_ends_with_an_upgrade_drain() { + let fixture = api(); + let waiter = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let wait = tokio::task::spawn_local(async move { waiter.converge("stuck").await }); + tokio::task::yield_now().await; + + fixture + .client + .shutdown_for_upgrade() + .await + .expect("a pending wait is not an admitted mutation"); + let error = wait.await.expect("wait task").expect_err("the drain ends the wait"); + assert!( + matches!(&error, Error::Rpc(error) if error.message.contains("run the command again")), + "{error:?}" + ); +} + +#[tokio::test(flavor = "local")] +async fn agent_progress_returns_the_status_then_waits_for_the_next_change() { + let fixture = api(); + fixture.client.apply(request("worker")).await.expect("apply"); + let current = fixture + .client + .agent_progress("worker", None, None) + .await + .expect("current progress"); + assert!(current.provisioning.is_none(), "no pass has run"); + assert!(current.status.conditions.is_empty()); + + let follower = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let revision = current.revision; + let follow = tokio::task::spawn_local(async move { follower.agent_progress("worker", Some(revision), None).await }); + tokio::task::yield_now().await; + assert!(!follow.is_finished(), "a current revision waits for a change"); + fixture.changes.bump(); + let changed = follow.await.expect("follow task").expect("changed progress"); + assert_ne!(changed.revision, revision); + + let missing = fixture + .client + .agent_progress("missing", None, None) + .await + .expect_err("unknown Agent"); + assert!(matches!(missing, Error::Rpc(error) if error.is_not_found())); +} + +#[tokio::test(flavor = "local")] +async fn a_frame_that_is_not_the_response_fails_the_call() { + let notification = ScriptedConnector { + frames: concat!(r#"{"jsonrpc":"2.0","method":"progress.v1.event","params":{}}"#, "\n"), + }; + let client = Client::new(Rc::new(notification)); + let error = client + .ensure_execution("worker", WaitPolicy::UntilConverged) + .await + .expect_err("a notification is not a response"); + assert!(matches!(error, Error::Json(_)), "unexpected error: {error}"); +} + +#[tokio::test(flavor = "local")] +async fn stop_and_start_record_the_run_state_as_a_new_generation() { + let fixture = api(); + let client = &fixture.client; + let applied = client.apply(request("worker")).await.expect("apply"); + + let stopped = client + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + assert_eq!(stopped.spec.run_state, Some(agent::RunState::Stopped)); + assert_eq!(stopped.metadata.generation, applied.metadata.generation + 1); + assert_eq!(client.get("worker").await.expect("get"), stopped); + let again = client + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("repeated stop"); + assert_eq!(again.metadata.generation, stopped.metadata.generation); + assert_eq!(client.converge("worker").await.expect("converge"), stopped); + + let started = client + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + assert_eq!(started.spec.run_state, None); + assert_eq!(started.metadata.generation, stopped.metadata.generation + 1); + + let error = client + .set_run_state("missing", agent::RunState::Stopped) + .await + .expect_err("missing Agent"); + assert!(matches!(error, Error::Rpc(error) if error.is_not_found())); +} + +#[tokio::test(flavor = "local")] +async fn work_in_a_stopped_agent_is_refused_with_how_to_start_it() { + let fixture = api(); + let error = fixture + .client + .ensure_execution("stopped", WaitPolicy::UntilConverged) + .await + .expect_err("a stopped Agent runs nothing"); + match error { + Error::Rpc(error) => { + assert!(error.is_invalid_params(), "agentctl prints it as the whole story"); + assert_eq!( + error.message, + "Agent \"stopped\" is stopped; run `agentctl start agent/stopped`" + ); + } + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn application_errors_keep_stable_protocol_codes() { + let fixture = api(); + let error = fixture + .client + .get("missing") + .await + .expect_err("missing Agent should fail"); + + match error { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn malformed_and_idle_connections_do_not_block_other_clients() { + let fixture = api(); + let server = fixture.server; + let client = fixture.client; + client.apply(request("worker")).await.expect("apply"); + + let (mut malformed_client, malformed_server) = tokio::io::duplex(1024); + let malformed_api = server.clone(); + tokio::task::spawn_local(async move { + let _ignored = malformed_api.serve_connection(malformed_server).await; + }); + malformed_client + .write_all(b"{not-json}\n") + .await + .expect("write malformed request"); + let mut response = String::new(); + BufReader::new(&mut malformed_client) + .read_line(&mut response) + .await + .expect("read parse error"); + assert!(response.contains("-32700")); + + let (_idle_client, idle_server) = tokio::io::duplex(1024); + let idle_api = server; + tokio::task::spawn_local(async move { + let _ignored = idle_api.serve_connection(idle_server).await; + }); + let fetched = tokio::time::timeout(Duration::from_secs(1), client.get("worker")) + .await + .expect("active client should not wait for idle connection") + .expect("get"); + assert_eq!(fetched.metadata.name, "worker"); +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_rejects_invalid_selections_before_reaching_the_service() { + let fixture = api(); + let (mut raw_client, raw_server) = tokio::io::duplex(4096); + let api = fixture.server.clone(); + tokio::task::spawn_local(async move { + let _ignored = api.serve_connection(raw_server).await; + }); + let mut reader = BufReader::new(&mut raw_client); + for (id, params) in [ + (1, r#"{"agent":"worker","name":"s1","model_selection":{"model":""}}"#), + ( + 2, + r#"{"agent":"worker","name":"s1","model_selection":{"effort":"very high"}}"#, + ), + ] { + let request = format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"sessions.v1.ensure","params":{params}}}"#); + reader + .get_mut() + .write_all(format!("{request}\n").as_bytes()) + .await + .expect("write request"); + let mut response = String::new(); + reader.read_line(&mut response).await.expect("read response"); + let response: serde_json::Value = serde_json::from_str(&response).expect("JSON-RPC response"); + assert_eq!(response["error"]["code"], -32602, "{response}"); + let message = response["error"]["message"].as_str().expect("message"); + assert!( + message.contains("must be 1-128 ASCII letters"), + "the validation failure names the rule: {message}" + ); + } + assert!(fixture.ensured.borrow().is_empty()); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn unix_socket_transport_is_private_and_usable() { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::Builder::new() + .prefix("agent-api-") + .tempdir() + .expect("temporary API directory"); + let socket_path = temporary.path().join("p").join("agentd.sock"); + let fixture = api(); + let server = fixture.server; + let served_path = socket_path.clone(); + let mut server_task = tokio::task::spawn_local(async move { server.serve_path(&served_path).await }); + let wait_for_socket = tokio::time::timeout(Duration::from_secs(1), async { + while !socket_path.exists() { + tokio::task::yield_now().await; + } + }); + tokio::select! { + result = &mut server_task => panic!("server stopped before creating its socket: {result:?}"), + result = wait_for_socket => result.expect("socket should be created"), + } + + let client = Client::for_path(socket_path.clone()); + let applied = client.apply(request("worker")).await.expect("apply over Unix socket"); + assert_eq!(applied.metadata.name, "worker"); + + let directory_mode = std::fs::metadata(socket_path.parent().expect("socket parent")) + .expect("directory metadata") + .permissions() + .mode() + & 0o777; + let socket_mode = std::fs::metadata(&socket_path) + .expect("socket metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(socket_mode, 0o600); + client.shutdown_for_upgrade().await.expect("graceful shutdown"); + tokio::time::timeout(Duration::from_secs(1), &mut server_task) + .await + .expect("server should stop") + .expect("server task") + .expect("server result"); +} diff --git a/agentctl/tests/control_plane.rs b/agentctl/tests/control_plane.rs new file mode 100644 index 0000000..0cce899 --- /dev/null +++ b/agentctl/tests/control_plane.rs @@ -0,0 +1,2871 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + path::PathBuf, + rc::Rc, + time::Duration, +}; + +use agent::{ + AgentId, ConditionStatus, EnvironmentSpec, Error, FailureKind, MountSpec, SecretSpec, Status, + control_plane::{ + AgentRecord, AgentStore, ControlPlane, Controller, Convergence, Notifier, Reconciler, WaitPolicy, memory, + }, + progress::{OutputPosition, ProvisioningState, SandboxObserver}, + resources::Changes, + sandbox::{ + ExecutionService, PlatformAdapter, Provider, ProviderEnsureOutcome, ProviderId, Service, UNRESPONSIVE_AFTER, + }, +}; +use sandbox::{ + EnsureSandboxRequest, GuestHeartbeat, LocalFuture, Platform, RetentionPolicy, RootFilesystem, SandboxHandle, + SandboxName, SandboxPath, SandboxResources, SandboxService, + backend::SandboxBackend as _, + init::InitSystem, + memory as sandbox_memory, + network::{NetworkEndpointSelection, PacketMedium}, +}; +use tokio::sync::Notify; + +use support::{TempDirectory, agent}; + +#[derive(Default)] +struct NotificationCounter(Cell); + +impl Notifier for NotificationCounter { + fn notify(&self, _id: AgentId) { + self.0.set(self.0.get() + 1); + } +} + +#[derive(Default)] +struct SessionNotificationCounter(Cell); + +impl agent::control_plane::SessionNotifier for SessionNotificationCounter { + fn notify(&self, _id: AgentId) { + self.0.set(self.0.get() + 1); + } + + fn settle(&self, id: AgentId) -> LocalFuture<'_, ()> { + self.notify(id); + Box::pin(async {}) + } +} + +struct NoopPlatform; + +impl PlatformAdapter for NoopPlatform { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + _record: &'a AgentRecord, + _sandbox: &'a SandboxHandle, + _harnesses: &'a [agent::Harness], + _steps: &'a sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Ok(()) }) + } +} + +struct Blocking { + agent: AgentId, + calls: Rc>, + started: Rc, + release: Rc, +} + +struct MemoryProvider { + id: ProviderId, + service: SandboxService, + default_architecture: String, + blocking: Option, + report_runtime_restart: Rc>, + /// Stops that fail before stopping anything, as a runtime that cannot be reached does. + failing_stops: Rc>, +} + +impl MemoryProvider { + fn new(backend: Rc) -> Self { + Self { + id: ProviderId::new("memory").expect("Provider ID"), + service: SandboxService::new(backend).with_network_backend(Rc::new( + sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ), + )), + default_architecture: Platform::native("linux").architecture, + blocking: None, + report_runtime_restart: Rc::new(Cell::new(false)), + failing_stops: Rc::new(Cell::new(0)), + } + } + + fn with_blocking(mut self, blocking: Blocking) -> Self { + self.blocking = Some(blocking); + self + } +} + +impl Provider for MemoryProvider { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async move { Ok(record.agent.spec.sandbox.platform.os == "linux") }) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + _progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + if let Some(blocking) = &self.blocking + && record.id == blocking.agent + { + let call = blocking.calls.get() + 1; + blocking.calls.set(call); + if call == 1 { + blocking.started.notify_one(); + blocking.release.notified().await; + } + } + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &self.default_architecture); + // Like Microsandbox, starting a stopped Sandbox restarts its runtime. + let was_stopped = self + .service + .inspect(&record.sandbox_name()?) + .await + .is_ok_and(|sandbox| sandbox.state == sandbox::SandboxState::Stopped); + let sandbox = self + .service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name()?, spec) + .with_hostname(record.sandbox_hostname()?) + .with_mounts(record.agent.spec.sandbox.resolved_mounts()) + .with_environment(environment), + ) + .await + .map_err(Error::from)?; + Ok(ProviderEnsureOutcome { + sandbox, + runtime_restarted: self.report_runtime_restart.replace(false) || was_stopped, + harnesses: record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }) + }) + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.service + .open(id, record.agent.spec.sandbox.resolved_retention_policy()) + .await + .map_err(Error::from) + }) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + if let Some(remaining) = self.failing_stops.get().checked_sub(1) { + self.failing_stops.set(remaining); + return Err(Error::Sandbox(sandbox::Error::Backend("runtime unreachable".into()))); + } + self.service.stop(&record.sandbox_name()?).await.map_err(Error::from) + }) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.service + .release( + &record.sandbox_name()?, + record.agent.spec.sandbox.resolved_retention_policy(), + ) + .await + .map_err(Error::from) + }) + } +} + +struct UnsupportedProvider { + id: ProviderId, +} + +/// A planned Sandbox ensure failure. +/// +/// A permanent failure fails every pass, as agentd's Providers do until the +/// manifest or `.env` changes. A transient failure fails only the pass that +/// takes it. +#[derive(Clone)] +enum PlannedFailure { + Invalid(String), + /// The Sandbox Provider rejects the request itself (an SDK `InvalidRequest`). + Rejected, + /// Floods telemetry past the lossy channel's capacity, then fails as invalid. + InvalidAfterFlood(String), + Transient(String), + /// Fails transiently on every pass until `ended` is set. + Outage { + message: String, + ended: Rc>, + }, +} + +const TELEMETRY_FLOOD: usize = 4_096; + +struct PlannedProvider { + inner: MemoryProvider, + failures: RefCell>, +} + +impl PlannedProvider { + fn new(backend: Rc, failures: impl IntoIterator) -> Self { + Self { + inner: MemoryProvider::new(backend), + failures: RefCell::new(failures.into_iter().collect()), + } + } +} + +impl Provider for PlannedProvider { + fn id(&self) -> &ProviderId { + self.inner.id() + } + + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result> { + self.inner.supports(record) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + let planned = { + let mut failures = self.failures.borrow_mut(); + if matches!(failures.front(), Some(PlannedFailure::Outage { ended, .. }) if ended.get()) { + failures.pop_front(); + } + if matches!(failures.front(), Some(PlannedFailure::Transient(_))) { + failures.pop_front() + } else { + failures.front().cloned() + } + }; + match planned { + Some(PlannedFailure::Invalid(message)) => Box::pin(async move { Err(Error::Invalid(message)) }), + Some(PlannedFailure::Rejected) => Box::pin(async move { + Err(Error::Sandbox(sandbox::Error::Invalid { + field: "spec.resources.cpu", + reason: "fractional CPUs are not supported", + })) + }), + Some(PlannedFailure::InvalidAfterFlood(message)) => Box::pin(async move { + let _phase = progress.start_phase(sandbox::SandboxPhase::ImagePrepare).await; + let step = progress + .steps() + .start_measured_step("Pull layer", sandbox::ProgressUnit::Bytes, None) + .await; + for completed in 0..TELEMETRY_FLOOD { + step.report(completed as u64, None).await; + } + Err(Error::Invalid(message)) + }), + Some(PlannedFailure::Transient(message) | PlannedFailure::Outage { message, .. }) => Box::pin(async move { + let _phase = progress.start_phase(sandbox::SandboxPhase::SandboxStart).await; + Err(Error::Sandbox(sandbox::Error::Backend(message))) + }), + None => self.inner.ensure(record, environment, progress), + } + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + self.inner.open(record, id) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + self.inner.stop(record) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + self.inner.release(record) + } +} + +impl UnsupportedProvider { + fn new() -> Self { + Self { + id: ProviderId::new("unsupported").expect("Provider ID"), + } + } +} + +impl Provider for UnsupportedProvider { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async { Ok(false) }) + } + + fn ensure<'a>( + &'a self, + _record: &'a AgentRecord, + _environment: std::collections::BTreeMap, + _progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } + + fn open<'a>( + &'a self, + _record: &'a AgentRecord, + _id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } + + fn stop<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } + + fn release<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } +} + +fn sandbox_service(provider: Rc) -> Rc { + Rc::new(Service::new([provider], [Rc::new(NoopPlatform) as Rc]).expect("Sandbox service")) +} + +fn reconciler(store: Rc, provider: Rc) -> Reconciler { + Reconciler::new(store, sandbox_service(provider), ProvisioningState::default()) +} + +struct Fixture { + store: Rc, + backend: Rc, + control_plane: ControlPlane, + reconciler: Reconciler, +} + +fn fixture() -> Fixture { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + Fixture { + control_plane: ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())), + reconciler: reconciler(store.clone(), provider), + store, + backend, + } +} + +fn apply_request(name: &str) -> agent::control_plane::ApplyRequest { + apply_request_in(name, std::env::temp_dir().join("agent-platform-source")) +} + +fn apply_request_in(name: &str, source_directory: PathBuf) -> agent::control_plane::ApplyRequest { + agent::control_plane::ApplyRequest { + manifest_path: Some(source_directory.join("agent.yaml")), + env_file: None, + source_directory, + create_only: false, + agent: agent(name), + } +} + +fn sandbox_name(record: &AgentRecord) -> SandboxName { + SandboxName::new(format!("agent-{}", record.id)).expect("Agent ID should form a valid Sandbox name") +} + +async fn stored(fixture: &Fixture, name: &str) -> AgentRecord { + fixture.store.get_by_name(name).await.expect("stored Agent") +} + +async fn reconcile(fixture: &Fixture, name: &str) { + let id = stored(fixture, name).await.id; + fixture.reconciler.reconcile(id).await.expect("reconcile"); +} + +#[tokio::test(flavor = "local")] +async fn apply_stores_desired_state_without_running_inline() { + let fixture = fixture(); + let applied = fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + + assert_eq!(applied.metadata.generation, 1); + assert_eq!( + applied.spec.sandbox.platform.architecture, + Some(Platform::native("linux").architecture) + ); + assert_eq!(fixture.backend.count(), 0); + assert!(applied.status.conditions.is_empty()); +} + +#[tokio::test(flavor = "local")] +async fn lists_agents_and_resolves_the_nearest_unique_source_directory() { + let fixture = fixture(); + let root = std::env::temp_dir().join("agent-platform-sources"); + let outer = apply_request_in("outer", root.clone()); + fixture.control_plane.apply(outer).await.expect("outer Agent"); + let inner = apply_request_in("inner", root.join("nested")); + fixture.control_plane.apply(inner).await.expect("inner Agent"); + + let listed = fixture.control_plane.list().await.expect("list Agents"); + assert_eq!( + listed + .iter() + .map(|agent| agent.metadata.name.as_str()) + .collect::>(), + vec!["inner", "outer"] + ); + let resolved = fixture + .control_plane + .resolve_directory(&root.join("nested/worktree")) + .await + .expect("nearest Agent source"); + assert_eq!(resolved.metadata.name, "inner"); +} + +#[tokio::test(flavor = "local")] +async fn directory_resolution_selects_leaf_variants_and_prefers_the_default_manifest() { + let fixture = fixture(); + let root = std::env::temp_dir().join("agent-platform-variant-sources"); + let default = apply_request_in("default", root.clone()); + fixture.control_plane.apply(default).await.expect("default Agent"); + + let mut nested = apply_request_in("nested", root.clone()); + nested.manifest_path = Some(root.join("agent.nested.yaml")); + fixture.control_plane.apply(nested).await.expect("nested Agent"); + + assert_eq!( + fixture + .control_plane + .resolve_directory(&root) + .await + .expect("default preference") + .metadata + .name, + "default" + ); + assert_eq!( + fixture + .control_plane + .resolve_directory_variant(&root, Some(&agent::AgentVariantName::new("nested").expect("variant"))) + .await + .expect("variant selection") + .metadata + .name, + "nested" + ); + + let mut local = apply_request_in("local", root.clone()); + local.manifest_path = Some(root.join("agent.mine.yaml")); + fixture.control_plane.apply(local).await.expect("local Agent"); + assert_eq!( + fixture + .control_plane + .resolve_directory_variant(&root, Some(&agent::AgentVariantName::new("mine").expect("variant"))) + .await + .expect("multi-level local variant selection") + .metadata + .name, + "local" + ); + assert!(matches!( + fixture + .control_plane + .resolve_directory_variant(&root, Some(&agent::AgentVariantName::new("missing").expect("variant"))) + .await, + Err(Error::Invalid(message)) if message.contains("agent.missing.yaml") + )); +} + +#[tokio::test(flavor = "local")] +async fn directory_resolution_remains_ambiguous_without_one_default_manifest() { + let fixture = fixture(); + let root = std::env::temp_dir().join("agent-platform-ambiguous-variant-sources"); + for (name, variant) in [("nested", "nested"), ("worktree", "worktree")] { + let mut request = apply_request_in(name, root.clone()); + request.manifest_path = Some(root.join(format!("agent.{variant}.yaml"))); + fixture.control_plane.apply(request).await.expect("variant Agent"); + } + let error = fixture + .control_plane + .resolve_directory(&root) + .await + .expect_err("ambiguous variants"); + assert!(matches!(error, Error::Invalid(message) if message.contains("--agent or --variant"))); +} + +#[tokio::test(flavor = "local")] +async fn bind_mounts_resolve_from_the_manifest_and_drive_directory_inference_and_materialization() { + let fixture = fixture(); + let temporary = TempDirectory::new("bind-mount"); + let physical_root = temporary.path().join("physical"); + std::fs::create_dir_all(&physical_root).expect("physical workspace directory"); + #[cfg(unix)] + let root = { + let alias = temporary.path().join("alias"); + std::os::unix::fs::symlink(&physical_root, &alias).expect("workspace alias"); + alias + }; + #[cfg(not(unix))] + let root = physical_root.clone(); + let manifest = root.join("agents/worktree"); + let nested = root.join("src/feature"); + std::fs::create_dir_all(&manifest).expect("manifest directory"); + std::fs::create_dir_all(&nested).expect("nested workspace directory"); + let mut request = apply_request_in("worker", manifest); + request.agent.spec.sandbox.mounts.push(MountSpec::Bind { + source: PathBuf::from("../.."), + target: SandboxPath::new("/home/agent/code/altinn-studio"), + read_only: false, + }); + + let applied = fixture.control_plane.apply(request).await.expect("apply"); + let MountSpec::Bind { source, .. } = &applied.spec.sandbox.mounts[0] else { + panic!("expected bind Mount"); + }; + assert_eq!(source, &std::fs::canonicalize(&root).expect("canonical workspace")); + assert_eq!( + fixture + .control_plane + .resolve_directory(&nested) + .await + .expect("infer Agent") + .metadata + .name, + "worker" + ); + + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + let materialized = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("materialized Sandbox"); + assert_eq!(materialized.mounts, record.agent.spec.sandbox.resolved_mounts()); +} + +#[tokio::test(flavor = "local")] +async fn api_responses_carry_provenance_without_persisting_it() { + let fixture = fixture(); + let request = apply_request("worker"); + let expected = agent::Provenance { + source_directory: request.source_directory.clone(), + manifest_path: request.manifest_path.clone(), + env_file: None, + }; + + let applied = fixture.control_plane.apply(request.clone()).await.expect("apply"); + assert_eq!(applied.status.provenance.as_ref(), Some(&expected)); + + let unchanged = fixture.control_plane.apply(request).await.expect("unchanged apply"); + assert_eq!(unchanged.status.provenance.as_ref(), Some(&expected)); + + let fetched = fixture.control_plane.get("worker").await.expect("get"); + assert_eq!(fetched.status.provenance.as_ref(), Some(&expected)); + + let listed = fixture.control_plane.list().await.expect("list"); + assert_eq!(listed[0].status.provenance.as_ref(), Some(&expected)); + + let resolved = fixture + .control_plane + .resolve_directory(&expected.source_directory) + .await + .expect("resolve directory"); + assert_eq!(resolved.status.provenance.as_ref(), Some(&expected)); + + let record = stored(&fixture, "worker").await; + assert_eq!(record.agent.status.provenance, None); + assert_eq!(record.manifest_path, expected.manifest_path); + + reconcile(&fixture, "worker").await; + let reconciled = fixture.control_plane.get("worker").await.expect("get after reconcile"); + assert_eq!(reconciled.status.provenance.as_ref(), Some(&expected)); + assert!(!reconciled.status.conditions.is_empty()); + let record = stored(&fixture, "worker").await; + assert_eq!(record.agent.status.provenance, None); +} + +#[tokio::test(flavor = "local")] +async fn create_only_applies_reject_existing_names() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.create_only = true; + fixture + .control_plane + .apply(request.clone()) + .await + .expect("initial create"); + + let error = fixture + .control_plane + .apply(request.clone()) + .await + .expect_err("repeated create must fail"); + assert!(matches!(error, Error::Invalid(message) if message.contains("already exists"))); + + request.create_only = false; + fixture.control_plane.apply(request).await.expect("upsert still works"); +} + +#[tokio::test(flavor = "local")] +async fn applies_keep_the_recorded_manifest_path_unless_a_new_one_is_reported() { + let fixture = fixture(); + let request = apply_request("worker"); + let recorded = request.manifest_path.clone(); + fixture.control_plane.apply(request.clone()).await.expect("apply"); + + let mut pathless = request.clone(); + pathless.manifest_path = None; + fixture.control_plane.apply(pathless).await.expect("pathless apply"); + assert_eq!(stored(&fixture, "worker").await.manifest_path, recorded); + + let mut renamed = request.clone(); + renamed.manifest_path = Some(request.source_directory.join("worker.yml")); + let applied = fixture + .control_plane + .apply(renamed.clone()) + .await + .expect("renamed apply"); + assert_eq!(stored(&fixture, "worker").await.manifest_path, renamed.manifest_path); + assert_eq!( + applied + .status + .provenance + .and_then(|provenance| provenance.manifest_path), + renamed.manifest_path + ); + + let mut foreign = request; + foreign.manifest_path = Some(PathBuf::from("/elsewhere/agent.yaml")); + let error = fixture + .control_plane + .apply(foreign) + .await + .expect_err("manifest outside sourceDirectory must fail"); + assert!(matches!(error, Error::Invalid(message) if message.contains("manifestPath"))); +} + +#[tokio::test(flavor = "local")] +async fn changing_the_source_directory_is_rejected_by_name() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + + let mut moved = apply_request("worker"); + moved.source_directory = std::env::temp_dir().join("agent-platform-elsewhere"); + moved.manifest_path = Some(moved.source_directory.join("agent.yaml")); + let error = fixture + .control_plane + .apply(moved) + .await + .expect_err("directory change must fail"); + assert!(matches!(error, Error::Immutable("sourceDirectory"))); +} + +#[tokio::test(flavor = "local")] +async fn changing_a_mount_is_rejected_for_an_existing_agent() { + let fixture = fixture(); + let root = TempDirectory::new("immutable-mount"); + let mut request = apply_request_in("worker", root.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(MountSpec::Bind { + source: PathBuf::from("."), + target: SandboxPath::new("/home/agent/code/first"), + read_only: false, + }); + fixture + .control_plane + .apply(request.clone()) + .await + .expect("initial apply"); + request.agent.spec.sandbox.mounts[0] = MountSpec::Bind { + source: PathBuf::from("."), + target: SandboxPath::new("/home/agent/code/second"), + read_only: false, + }; + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("Mounts are immutable"); + + assert!(matches!(error, Error::Immutable("spec.sandbox.mounts"))); +} + +#[tokio::test(flavor = "local")] +async fn directory_resolution_rejects_shared_sources_instead_of_guessing() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("first")) + .await + .expect("first Agent"); + fixture + .control_plane + .apply(apply_request("second")) + .await + .expect("second Agent"); + + let error = fixture + .control_plane + .resolve_directory(&std::env::temp_dir().join("agent-platform-source/worktree")) + .await + .expect_err("shared source must be ambiguous"); + assert!(matches!(error, Error::Invalid(message) if message.contains("multiple Agents"))); +} + +#[tokio::test(flavor = "local")] +async fn reconcile_resolves_an_omitted_architecture_without_changing_desired_state() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.agent.spec.sandbox.platform.architecture = None; + fixture.control_plane.apply(request).await.expect("apply"); + + reconcile(&fixture, "worker").await; + + let desired = fixture.control_plane.get("worker").await.expect("get"); + assert_eq!(desired.spec.sandbox.platform.architecture, None); + let sandbox = fixture + .backend + .find(&sandbox_name(&stored(&fixture, "worker").await)) + .await + .expect("sandbox"); + assert_eq!(sandbox.image.platform, Platform::native("linux")); +} + +#[tokio::test(flavor = "local")] +async fn reconcile_resolves_sources_and_reports_sandbox_ready() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + reconcile(&fixture, "worker").await; + + let observed = fixture.control_plane.get("worker").await.expect("get"); + let materialized_name = sandbox_name(&stored(&fixture, "worker").await); + let sandbox_id = observed + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .expect("sandbox id"); + assert_eq!(observed.status.observed_generation, 1); + assert_eq!( + observed + .status + .conditions + .iter() + .map(|condition| (condition.kind.as_str(), condition.status)) + .collect::>(), + [ + (agent::Condition::SANDBOX_READY, ConditionStatus::True), + // The memory backend reports no guest heartbeat. + (agent::Condition::SANDBOX_RESPONSIVE, ConditionStatus::Unknown), + (agent::Condition::READY, ConditionStatus::True), + ] + ); + let sandbox = fixture.backend.find(&materialized_name).await.expect("sandbox"); + assert_eq!(&sandbox.id, sandbox_id); + assert_eq!( + sandbox.image.source, + sandbox::image::ImageSource::Build { + context: std::env::temp_dir().join("agent-platform-source").join("image"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + } + ); +} + +#[tokio::test(flavor = "local")] +async fn reconciliation_resolves_provider_capabilities_and_persists_the_assignment() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let providers: [Rc; 2] = [ + Rc::new(UnsupportedProvider::new()), + Rc::new(MemoryProvider::new(backend.clone())), + ]; + let sandboxes = + Rc::new(Service::new(providers, [Rc::new(NoopPlatform) as Rc]).expect("Sandbox service")); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + let reconciler = Reconciler::new(store.clone(), sandboxes, ProvisioningState::default()); + control_plane.apply(apply_request("worker")).await.expect("apply"); + + reconciler + .reconcile(store.get_by_name("worker").await.expect("record").id) + .await + .expect("reconcile"); + + let assignment = store + .get_by_name("worker") + .await + .expect("record") + .agent + .status + .sandbox + .expect("assignment"); + assert_eq!(assignment.provider().as_str(), "memory"); + assert!(assignment.id().is_some()); + assert_eq!(backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn repeated_reconciliation_reuses_the_same_sandbox() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + + reconcile(&fixture, "worker").await; + let first = fixture.control_plane.get("worker").await.expect("first status"); + reconcile(&fixture, "worker").await; + let second = fixture.control_plane.get("worker").await.expect("second status"); + + assert_eq!(fixture.backend.count(), 1); + assert_eq!(first.status.sandbox, second.status.sandbox); +} + +#[tokio::test(flavor = "local")] +async fn agent_transitions_notify_sessions_without_repeated_ready_noise() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend)); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = reconciler(store.clone(), provider).with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + + reconciler.reconcile(id).await.expect("materialize"); + assert_eq!(notifications.0.get(), 1); + reconciler.reconcile(id).await.expect("steady ready pass"); + assert_eq!(notifications.0.get(), 1); + control_plane.delete("worker").await.expect("delete"); + reconciler.reconcile(id).await.expect("release"); + assert_eq!(notifications.0.get(), 2); +} + +#[tokio::test(flavor = "local")] +async fn sandbox_runtime_restart_notifies_sessions_without_an_identity_change() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider = MemoryProvider::new(backend); + let restart = provider.report_runtime_restart.clone(); + let provider: Rc = Rc::new(provider); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = reconciler(store.clone(), provider).with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + + reconciler.reconcile(id).await.expect("materialize"); + assert_eq!(notifications.0.get(), 1); + restart.set(true); + reconciler.reconcile(id).await.expect("restart-backed reconcile"); + + assert_eq!(notifications.0.get(), 2); +} + +#[tokio::test(flavor = "local")] +async fn repeated_apply_is_idempotent_and_immutable_fields_are_rejected() { + let fixture = fixture(); + let request = apply_request("worker"); + let first = fixture.control_plane.apply(request.clone()).await.expect("first apply"); + let second = fixture + .control_plane + .apply(request.clone()) + .await + .expect("second apply"); + assert_eq!(first.metadata.generation, second.metadata.generation); + + let mut mutable_change = request.clone(); + mutable_change.agent.spec.sandbox.retention_policy = Some(RetentionPolicy::Delete); + mutable_change.agent.spec.harnesses[0].version = Some("2.1.240".into()); + mutable_change.agent.spec.harnesses[0].default = true; + let updated_request = mutable_change.clone(); + let updated = fixture + .control_plane + .apply(mutable_change) + .await + .expect("mutable update"); + assert_eq!(updated.metadata.generation, 2); + assert_eq!(updated.spec.harnesses[0].version.as_deref(), Some("2.1.240")); + assert!(updated.spec.harnesses[0].default); + + let mut kind_set_change = updated_request.clone(); + kind_set_change.agent.spec.harnesses[0].default = true; + kind_set_change.agent.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: Some("0.149.1".into()), + auth: agent::HarnessAuthMode::Mediated, + optional: false, + default: false, + defaults: agent::ModelSelection::default(), + }); + let error = fixture + .control_plane + .apply(kind_set_change) + .await + .expect_err("harness kind set should be immutable"); + assert!(matches!(error, Error::Immutable("spec.harnesses.type"))); + + let mut immutable_change = updated_request.clone(); + immutable_change.agent.spec.sandbox.platform.architecture = Some( + if Platform::native("linux").architecture == "amd64" { + "arm64" + } else { + "amd64" + } + .into(), + ); + let error = fixture + .control_plane + .apply(immutable_change) + .await + .expect_err("Sandbox Platform should be immutable"); + assert!(matches!(error, Error::Immutable("spec.sandbox.platform"))); + + let mut init_system_change = updated_request.clone(); + init_system_change.agent.spec.sandbox.init_system = InitSystem::Image; + let error = fixture + .control_plane + .apply(init_system_change) + .await + .expect_err("Sandbox init system should be immutable"); + assert!(matches!(error, Error::Immutable("spec.sandbox.initSystem"))); + + let mut root_mode_change = updated_request; + let resources = root_mode_change.agent.spec.sandbox.resources; + root_mode_change.agent.spec.sandbox.resources = SandboxResources::new( + resources.cpu(), + resources.memory(), + RootFilesystem::direct(resources.root_filesystem().capacity()), + ); + let error = fixture + .control_plane + .apply(root_mode_change) + .await + .expect_err("Sandbox root filesystem mode should be immutable"); + assert!(matches!( + error, + Error::Immutable("spec.sandbox.resources.rootFilesystem.mode") + )); +} + +#[tokio::test(flavor = "local")] +async fn secret_binding_definitions_are_mutable_desired_state() { + let fixture = fixture(); + let request = apply_request("worker"); + fixture + .control_plane + .apply(request.clone()) + .await + .expect("initial apply"); + + let mut changed = request; + changed.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: Some("GH_PAT".into()), + }); + let applied = fixture + .control_plane + .apply(changed) + .await + .expect("secret binding update"); + + assert_eq!(applied.metadata.generation, 2); + assert_eq!(applied.spec.secrets.len(), 1); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn directory_resolution_survives_a_symlinked_parent_of_a_missing_source() { + // macOS and Windows temp directories canonicalize to a different spelling; a source + // directory that does not exist on disk must still resolve by its literal path. + let fixture = fixture(); + let real = tempfile::tempdir().expect("real directory"); + let link = tempfile::tempdir().expect("link holder"); + let alias = link.path().join("alias"); + std::os::unix::fs::symlink(real.path(), &alias).expect("symlink"); + let source_directory = alias.join("missing-source"); + let request = apply_request_in("worker", source_directory.clone()); + let applied = fixture.control_plane.apply(request).await.expect("apply"); + + let resolved = fixture + .control_plane + .resolve_directory(&source_directory.join("nested")) + .await + .expect("a subdirectory of the literal source path resolves"); + + assert_eq!(resolved.metadata.name, applied.metadata.name); +} + +#[tokio::test(flavor = "local")] +async fn selected_secret_file_inside_a_bind_mount_is_rejected() { + let fixture = fixture(); + let root = tempfile::tempdir().expect("temporary checkout"); + let source_directory = root.path().join("examples/worktree"); + std::fs::create_dir_all(&source_directory).expect("source directory"); + let mut request = apply_request_in("worker", source_directory.clone()); + request.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: root.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + let initial = fixture + .control_plane + .apply(request.clone()) + .await + .expect("an absent default .env does not make the mount unsafe"); + + let outside = tempfile::tempdir().expect("secret directory outside the checkout"); + request.env_file = Some(outside.path().join("worker.env")); + let applied = fixture + .control_plane + .apply(request.clone()) + .await + .expect("a secret file outside every mount is accepted"); + assert_eq!( + applied.status.provenance.expect("provenance").env_file, + request.env_file + ); + assert_eq!( + stored(&fixture, "worker").await.env_file_path(), + outside.path().join("worker.env") + ); + assert!(applied.metadata.generation > initial.metadata.generation); + + std::fs::write(root.path().join(".env"), "GITHUB_TOKEN=checkout-token\n").expect("environment file"); + let error = fixture + .control_plane + .apply(request.clone()) + .await + .expect_err("a .env anywhere in the mount is rejected despite the external override"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("contains .env")), + "{error}" + ); + std::fs::remove_file(root.path().join(".env")).expect("remove environment file"); + + let mut unchanged = request.clone(); + unchanged.env_file = None; + let reapplied = fixture + .control_plane + .apply(unchanged) + .await + .expect("omitting envFile keeps the recorded path"); + assert_eq!(reapplied.metadata.generation, applied.metadata.generation); + + let mut inside = request; + inside.env_file = Some(root.path().join("secrets.env")); + let error = fixture + .control_plane + .apply(inside) + .await + .expect_err("an explicit secret file inside the mount is still rejected"); + assert!(matches!(error, Error::Invalid(_))); +} + +#[tokio::test(flavor = "local")] +async fn git_ignored_nested_dot_env_is_rejected_case_insensitively_without_declared_secrets() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + let relative_env = PathBuf::from("ignored").join("nested").join(".EnV"); + let ignored = checkout + .path() + .join(relative_env.parent().expect("environment file parent")); + std::fs::create_dir_all(&ignored).expect("ignored directory"); + std::fs::write(checkout.path().join(".gitignore"), "ignored/\n").expect("ignore file"); + std::fs::write(checkout.path().join(&relative_env), "PRIVATE=value\n").expect("nested environment file"); + let source = tempfile::tempdir().expect("manifest directory"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("ignored directories are still inspected case-insensitively for .env files"); + let expected_path = relative_env.display().to_string(); + assert!( + matches!(&error, Error::Invalid(message) + if message.contains("spec.sandbox.mounts[0]") && message.contains(&expected_path)), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_directory_named_dot_env_is_allowed() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + std::fs::create_dir(checkout.path().join(".ENV")).expect("directory named .ENV"); + let source = tempfile::tempdir().expect("manifest directory"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + fixture + .control_plane + .apply(request) + .await + .expect("a directory named .env is not an environment file"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn a_dot_env_symlink_is_rejected() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + std::fs::write(checkout.path().join("credentials"), "PRIVATE=value\n").expect("target file"); + std::os::unix::fs::symlink("credentials", checkout.path().join(".ENV")).expect("environment symlink"); + let source = tempfile::tempdir().expect("manifest directory"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + fixture + .control_plane + .apply(request) + .await + .expect_err("a case-variant .env symlink still exposes a file"); +} + +#[tokio::test(flavor = "local")] +async fn existing_default_env_outside_bind_mount_is_allowed() { + let fixture = fixture(); + let source = tempfile::tempdir().expect("manifest directory"); + let checkout = tempfile::tempdir().expect("mounted checkout"); + let external = tempfile::tempdir().expect("external environment directory"); + std::fs::write(source.path().join(".env"), "GITHUB_TOKEN=unmounted-token\n") + .expect("unmounted default environment file"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.env_file = Some(external.path().join("worker.env")); + request.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + fixture + .control_plane + .apply(request) + .await + .expect("an unmounted default .env is not exposed"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn secret_file_reached_through_a_symlinked_ancestor_is_still_rejected() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + let link_holder = tempfile::tempdir().expect("link holder"); + let alias = link_holder.path().join("alias"); + std::os::unix::fs::symlink(checkout.path(), &alias).expect("symlink"); + let source_directory = checkout.path().join("examples/worktree"); + std::fs::create_dir_all(&source_directory).expect("source directory"); + let mut request = apply_request_in("worker", source_directory); + request.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + // Neither the file nor its two parent directories exist yet, and the path enters the + // mounted checkout through a symlink. + request.env_file = Some(alias.join("secrets/not-yet/worker.env")); + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("the secret file would land inside the mounted checkout"); + assert!(matches!(error, Error::Invalid(_)), "{error}"); +} + +#[tokio::test(flavor = "local")] +async fn bind_mount_exposing_another_agents_secret_file_is_rejected() { + let fixture = fixture(); + let root = tempfile::tempdir().expect("temporary checkout"); + let with_secrets = root.path().join("agents/full"); + std::fs::create_dir_all(&with_secrets).expect("secret Agent source directory"); + let mut secret_agent = apply_request_in("full", with_secrets); + let selected_secret_file = root.path().join("credentials.txt"); + std::fs::write(&selected_secret_file, "GITHUB_TOKEN=private\n").expect("selected environment file"); + secret_agent.env_file = Some(selected_secret_file); + secret_agent.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + fixture.control_plane.apply(secret_agent).await.expect("secret Agent"); + + let mounted = root.path().join("agents/worktree"); + std::fs::create_dir_all(&mounted).expect("mounted Agent source directory"); + let mut worktree = apply_request_in("worktree", mounted); + worktree.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: root.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + let error = fixture + .control_plane + .apply(worktree) + .await + .expect_err("the mount would expose the other Agent's selected environment file"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("Agent \"full\"")), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn unchanged_apply_still_requests_immediate_reconciliation() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let notifications = Rc::new(NotificationCounter::default()); + let control_plane = ControlPlane::new(store, notifications.clone()); + let request = apply_request("worker"); + + control_plane.apply(request.clone()).await.expect("first apply"); + control_plane.apply(request).await.expect("unchanged apply"); + + assert_eq!(notifications.0.get(), 2); +} + +#[tokio::test(flavor = "local")] +async fn selected_environment_converges_from_the_env_file_without_exporting_other_values() { + let fixture = fixture(); + let source = tempfile::tempdir().expect("Agent source"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.environment = vec![ + EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: Some("HOST_GIT_NAME".into()), + }, + EnvironmentSpec { + name: "GIT_USER_EMAIL".into(), + source: None, + }, + ]; + std::fs::write( + source.path().join(".env"), + "HOST_GIT_NAME=First User\nGIT_USER_EMAIL=first@example.com\nUNSELECTED=private\n", + ) + .expect("first environment file"); + fixture.control_plane.apply(request.clone()).await.expect("apply"); + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + let first = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("Sandbox after first apply"); + assert_eq!( + first.environment.get("GIT_USER_NAME").map(String::as_str), + Some("First User") + ); + assert_eq!( + first.environment.get("GIT_USER_EMAIL").map(String::as_str), + Some("first@example.com") + ); + assert!(!first.environment.contains_key("UNSELECTED")); + + std::fs::write( + source.path().join(".env"), + "HOST_GIT_NAME=Second User\nGIT_USER_EMAIL=second@example.com\nUNSELECTED=still-private\n", + ) + .expect("updated environment file"); + let reapplied = fixture.control_plane.apply(request).await.expect("unchanged reapply"); + assert_eq!(reapplied.metadata.generation, 1); + reconcile(&fixture, "worker").await; + let second = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("Sandbox after environment update"); + assert_eq!( + second.environment.get("GIT_USER_NAME").map(String::as_str), + Some("Second User") + ); + assert_eq!( + second.environment.get("GIT_USER_EMAIL").map(String::as_str), + Some("second@example.com") + ); + assert!(!second.environment.contains_key("UNSELECTED")); +} + +#[tokio::test(flavor = "local")] +async fn selected_environment_requires_present_non_empty_values() { + let fixture = fixture(); + let source = tempfile::tempdir().expect("Agent source"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.environment = vec![ + EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: None, + }, + EnvironmentSpec { + name: "GIT_USER_EMAIL".into(), + source: None, + }, + ]; + std::fs::write(source.path().join(".env"), "GIT_USER_NAME=\n").expect("incomplete environment file"); + fixture.control_plane.apply(request).await.expect("apply"); + let id = stored(&fixture, "worker").await.id; + + let error = fixture + .reconciler + .reconcile(id) + .await + .expect_err("empty selected value must fail"); + assert!(matches!(error, Error::Invalid(message) if message.contains("GIT_USER_NAME") && message.contains("empty"))); + + std::fs::write(source.path().join(".env"), "GIT_USER_NAME=Ready\n").expect("missing environment value"); + let error = fixture + .reconciler + .reconcile(id) + .await + .expect_err("missing selected value must fail"); + assert!( + matches!(error, Error::Invalid(message) if message.contains("GIT_USER_EMAIL") && message.contains("does not define")) + ); +} + +#[tokio::test(flavor = "local")] +async fn apply_requires_an_absolute_source_directory() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.source_directory = PathBuf::from("relative"); + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("relative source should fail"); + assert!(matches!(error, Error::Invalid(_))); +} + +#[tokio::test(flavor = "local")] +async fn retained_sandbox_is_not_inherited_by_a_reused_agent_name() { + let fixture = fixture(); + let request = apply_request("worker"); + fixture.control_plane.apply(request.clone()).await.expect("apply"); + let first_record = stored(&fixture, "worker").await; + reconcile(&fixture, "worker").await; + let first_sandbox_name = sandbox_name(&first_record); + let original_id = fixture.backend.find(&first_sandbox_name).await.expect("sandbox").id; + + fixture.control_plane.delete("worker").await.expect("delete request"); + fixture.reconciler.reconcile(first_record.id).await.expect("release"); + assert!(matches!( + fixture.control_plane.get("worker").await, + Err(Error::NotFound) + )); + + fixture.control_plane.apply(request.clone()).await.expect("re-apply"); + let second_record = stored(&fixture, "worker").await; + assert_ne!(first_record.id, second_record.id); + reconcile(&fixture, "worker").await; + let second_id = fixture + .backend + .find(&sandbox_name(&second_record)) + .await + .expect("new sandbox") + .id; + assert_ne!(second_id, original_id); + assert_eq!(fixture.backend.count(), 2); + + let mut delete_request = request; + delete_request.agent.spec.sandbox.retention_policy = Some(RetentionPolicy::Delete); + fixture + .control_plane + .apply(delete_request) + .await + .expect("update retention"); + fixture.control_plane.delete("worker").await.expect("delete request"); + reconcile(&fixture, "worker").await; + assert_eq!(fixture.backend.count(), 1); + assert_eq!( + fixture + .backend + .find(&first_sandbox_name) + .await + .expect("retained sandbox") + .id, + original_id + ); +} + +#[tokio::test(flavor = "local")] +async fn omitted_retention_deletes_the_sandbox() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.agent.spec.sandbox.retention_policy = None; + let applied = fixture.control_plane.apply(request).await.expect("apply"); + assert_eq!(applied.spec.sandbox.retention_policy, None); + reconcile(&fixture, "worker").await; + let id = stored(&fixture, "worker").await.id; + fixture.control_plane.delete("worker").await.expect("delete request"); + fixture.reconciler.reconcile(id).await.expect("delete sandbox"); + assert_eq!(fixture.backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn controller_reconciles_after_a_wakeup() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let reconciler = Rc::new(reconciler(store.clone(), provider)); + let (controller, wakeup) = Controller::new(store.clone(), reconciler, Duration::from_mins(1), Rc::new(|_, _| {})); + let control_plane = ControlPlane::new(store, Rc::new(wakeup)); + let task = tokio::task::spawn_local(controller.run()); + + control_plane.apply(apply_request("worker")).await.expect("apply"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if backend.count() == 1 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("controller should reconcile"); + task.abort(); +} + +/// An Agent `worker` whose Sandbox ensures fail as planned, reconciled by a +/// running controller, with a waiter over the same change history. +struct Waiting { + store: Rc, + backend: Rc, + provisioning: ProvisioningState, + execution: Rc, + wakeup: agent::control_plane::Wakeup, + task: tokio::task::JoinHandle<()>, +} + +/// A controller interval short enough for a test to wait through background retries. +const BACKGROUND_RETRIES: Duration = Duration::from_millis(20); +/// A controller interval long enough that only a test's own wakeups reconcile. +const NO_BACKGROUND_PASSES: Duration = Duration::from_mins(1); + +async fn waiting(failures: impl IntoIterator, interval: Duration) -> Waiting { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(PlannedProvider::new(backend.clone(), failures)); + let provisioning = ProvisioningState::new(changes.clone()); + let reconciler = Rc::new(Reconciler::new( + store.clone(), + sandbox_service(provider), + provisioning.clone(), + )); + let (controller, wakeup) = Controller::new(store.clone(), reconciler, interval, Rc::new(|_, _| {})); + let execution = Rc::new(ExecutionService::new( + store.clone(), + Convergence::new(wakeup.clone(), store.clone(), changes), + )); + let task = tokio::task::spawn_local(controller.run()); + tokio::task::yield_now().await; + control_plane.apply(apply_request("worker")).await.expect("apply"); + Waiting { + store, + backend, + provisioning, + execution, + wakeup, + task, + } +} + +impl Waiting { + async fn id(&self) -> AgentId { + self.store.get_by_name("worker").await.expect("stored Agent").id + } +} + +#[tokio::test(flavor = "local")] +async fn execution_target_waits_for_agent_convergence() { + let fixture = waiting([], NO_BACKGROUND_PASSES).await; + let target = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::FirstPass), + ) + .await + .expect("execution target should not wait for the periodic scan") + .expect("ready execution target"); + + assert_eq!(target.operating_system, "linux"); + assert_eq!(target.sandbox.provider().as_str(), "memory"); + assert!(target.sandbox.id().is_some()); + assert_eq!(fixture.backend.count(), 1); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn an_invalid_failure_fails_the_wait_immediately() { + let fixture = waiting( + [PlannedFailure::Invalid( + ".env does not define required variable \"GITHUB_TOKEN\"".into(), + )], + NO_BACKGROUND_PASSES, + ) + .await; + let error = fixture + .execution + .ensure("worker", WaitPolicy::UntilConverged) + .await + .expect_err("invalid preparation must fail fast"); + + assert!(matches!(error, Error::Invalid(message) if message.contains("GITHUB_TOKEN"))); + let stored = fixture.store.get(fixture.id().await).await.expect("stored Agent"); + assert_eq!(stored.agent.status.failure, Some(FailureKind::Invalid)); + let provisioning = fixture.provisioning.get(stored.id).expect("failed pass"); + assert!(matches!( + provisioning.progress.status(), + sandbox::progress::OperationStatus::Failed { .. } + )); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_provider_rejection_is_permanent_and_fails_the_wait_immediately() { + let fixture = waiting([PlannedFailure::Rejected], NO_BACKGROUND_PASSES).await; + let error = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a permanent rejection must not be waited through") + .expect_err("rejected request fails"); + + assert!(matches!(error, Error::Invalid(message) if message.contains("fractional CPUs"))); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_flood_of_progress_does_not_stall_the_wait() { + let fixture = waiting( + [PlannedFailure::InvalidAfterFlood( + ".env does not define required variable \"GITHUB_TOKEN\"".into(), + )], + NO_BACKGROUND_PASSES, + ) + .await; + let error = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("progress volume must not stall the request") + .expect_err("invalid preparation must fail"); + + assert!(matches!(error, Error::Invalid(message) if message.contains("GITHUB_TOKEN"))); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn waiting_follows_background_retries_after_transient_failures() { + let fixture = waiting( + [ + PlannedFailure::Transient("temporary runtime failure".into()), + PlannedFailure::Transient("temporary runtime failure".into()), + ], + BACKGROUND_RETRIES, + ) + .await; + let target = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("background retry should complete") + .expect("eventual execution target"); + + assert!(target.sandbox.id().is_some()); + let provisioning = fixture.provisioning.get(fixture.id().await).expect("latest pass"); + assert_eq!( + provisioning.progress.status(), + &sandbox::progress::OperationStatus::Succeeded, + "the latest pass is the retry that succeeded" + ); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn provisioning_is_projected_but_not_stored_and_omitted_after_success() { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(PlannedProvider::new( + backend, + [PlannedFailure::Transient("temporary runtime failure".into())], + )); + let provisioning = ProvisioningState::new(changes.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())) + .with_provisioning(provisioning.clone()); + let reconciler = Reconciler::new(store.clone(), sandbox_service(provider), provisioning.clone()); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("stored Agent").id; + + reconciler.reconcile(id).await.expect_err("planned transient failure"); + let failed = control_plane.get("worker").await.expect("failed Agent"); + assert_eq!(failed.status.failure, Some(FailureKind::Transient)); + let summary = failed.status.progress.expect("failed provisioning"); + assert!(matches!( + summary.progress.status(), + sandbox::progress::OperationStatus::Failed { detail } if detail.contains("temporary runtime failure") + )); + assert_eq!( + store.get(id).await.expect("stored Agent").agent.status.progress, + None, + "provisioning is projected, never stored" + ); + + reconciler.reconcile(id).await.expect("retry succeeds"); + let ready = control_plane.get("worker").await.expect("ready Agent"); + assert!(ready.status.is_ready()); + assert_eq!(ready.status.failure, None); + assert_eq!(ready.status.progress, None, "a succeeded pass is not listed"); + let finished = provisioning.get(id).expect("finished pass"); + assert_ne!(finished.pass, summary.pass, "each retry is a new pass"); + assert_eq!( + finished.progress.status(), + &sandbox::progress::OperationStatus::Succeeded + ); + assert!( + finished + .progress + .finished() + .iter() + .any(|phase| phase.phase == agent::progress::SETUP && phase.outcome == sandbox::Outcome::Completed), + "Agent setup is reported as a phase of the pass" + ); + + let revision = changes.revision(); + reconciler.reconcile(id).await.expect("resync of a Ready Agent"); + assert_eq!( + provisioning.get(id), + Some(finished), + "a resync that succeeds leaves the pass that provisioned the Agent" + ); + let resynced = control_plane.get("worker").await.expect("resynced Agent"); + assert_eq!(resynced.status.progress, None); + assert!(resynced.status.is_ready()); + assert_eq!( + changes.revision(), + revision, + "a resync that changes nothing wakes no watcher" + ); +} + +#[tokio::test(flavor = "local")] +async fn progress_trims_only_the_output_of_the_pass_the_follower_has_seen() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let provisioning = ProvisioningState::default(); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())) + .with_provisioning(provisioning.clone()); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("stored Agent").id; + let texts = |provisioning: &agent::progress::Provisioning| { + provisioning + .progress + .output() + .lines() + .map(|line| line.text.clone()) + .collect::>() + }; + + let first = SandboxObserver::new(id, provisioning.clone()); + let phase = first.reporter().start_phase(agent::progress::SETUP).await; + let step = first.reporter().steps().start_step("Sync home").await; + step.output(sandbox::OutputStream::Stdout, "one\ntwo\n").await; + let (_, full) = control_plane.progress("worker", None).await.expect("progress"); + let full = full.expect("first pass"); + assert_eq!(texts(&full), ["one", "two"]); + + let seen = OutputPosition { + pass: full.pass, + sequence: 1, + }; + let (_, trimmed) = control_plane.progress("worker", Some(seen)).await.expect("progress"); + assert_eq!( + texts(&trimmed.expect("first pass")), + ["two"], + "output already seen is left out" + ); + drop((step, phase, first)); + + let second = SandboxObserver::new(id, provisioning.clone()); + let _phase = second.reporter().start_phase(agent::progress::SETUP).await; + let step = second.reporter().steps().start_step("Sync home").await; + step.output(sandbox::OutputStream::Stdout, "three\n").await; + let (_, next) = control_plane.progress("worker", Some(seen)).await.expect("progress"); + let next = next.expect("second pass"); + assert_ne!(next.pass, full.pass); + assert_eq!(texts(&next), ["three"], "a position in another pass trims nothing"); +} + +#[tokio::test(flavor = "local")] +async fn a_first_pass_wait_returns_its_failure_and_until_ready_waits_through_retries() { + let fixture = waiting( + [ + PlannedFailure::Transient("temporary runtime failure".into()), + PlannedFailure::Transient("temporary runtime failure".into()), + ], + BACKGROUND_RETRIES, + ) + .await; + let error = fixture + .execution + .ensure("worker", WaitPolicy::FirstPass) + .await + .expect_err("first pass fails"); + assert!(matches!(error, Error::Daemon(message) if message.contains("temporary runtime failure"))); + + let target = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("background retry should complete") + .expect("eventual execution target"); + assert!(target.sandbox.id().is_some()); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn dropping_a_wait_does_not_stop_background_reconciliation() { + // Every pass fails until the wait is dropped, so the failure stays visible + // and only a pass that starts after the drop can succeed. + let ended = Rc::new(Cell::new(false)); + let fixture = waiting( + [PlannedFailure::Outage { + message: "temporary runtime failure".into(), + ended: ended.clone(), + }], + BACKGROUND_RETRIES, + ) + .await; + let id = fixture.id().await; + let waiting = fixture.execution.clone(); + let wait = tokio::task::spawn_local(async move { waiting.ensure("worker", WaitPolicy::UntilConverged).await }); + tokio::time::timeout(Duration::from_secs(1), async { + while !fixture.provisioning.get(id).is_some_and(|pass| { + matches!( + pass.progress.status(), + sandbox::progress::OperationStatus::Failed { .. } + ) + }) { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .expect("transient failure recorded"); + wait.abort(); + ended.set(true); + + tokio::time::timeout(Duration::from_secs(1), async { + while fixture.backend.count() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("background controller should keep reconciling"); + let _ = fixture.wakeup; + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn controller_runs_agents_concurrently_and_serializes_reruns_per_id() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("slow")).await.expect("slow Agent"); + control_plane.apply(apply_request("fast")).await.expect("fast Agent"); + let slow = store.get_by_name("slow").await.expect("slow record").id; + + let backend = Rc::new(sandbox_memory::Provider::new()); + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let slow_calls = Rc::new(Cell::new(0)); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone()).with_blocking(Blocking { + agent: slow, + calls: slow_calls.clone(), + started: started.clone(), + release: release.clone(), + })); + let reconciler = Rc::new(reconciler(store.clone(), provider)); + let (controller, wakeup) = Controller::new(store.clone(), reconciler, Duration::from_mins(1), Rc::new(|_, _| {})); + let task = tokio::task::spawn_local(controller.run()); + + tokio::time::timeout(Duration::from_secs(1), started.notified()) + .await + .expect("slow reconciliation should start"); + let selected = store.get(slow).await.expect("selected slow Agent"); + assert!(matches!( + selected.agent.status.sandbox, + Some(agent::sandbox::Assignment::Selected { .. }) + )); + wakeup.notify(slow); + wakeup.notify(slow); + wakeup.notify(slow); + tokio::time::timeout(Duration::from_secs(1), async { + while backend.count() != 1 { + tokio::task::yield_now().await; + } + }) + .await + .expect("fast Agent should finish while the slow Agent is blocked"); + + release.notify_one(); + tokio::time::timeout(Duration::from_secs(1), async { + while backend.count() != 2 || slow_calls.get() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("queued notifications should coalesce into one serialized rerun"); + task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn stale_status_write_is_rejected() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + let mut changed = apply_request("worker"); + changed.agent.spec.sandbox.retention_policy = Some(RetentionPolicy::Delete); + fixture.control_plane.apply(changed).await.expect("second generation"); + + let error = fixture + .store + .update_status(stored(&fixture, "worker").await.id, 1, Status::default()) + .await + .expect_err("stale status should fail"); + assert!(matches!(error, Error::Conflict)); +} + +fn is_ssh_server_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + sandbox::execution::Program::Command { executable, args } + if executable.as_str() == "/usr/bin/test" && args == &["-x", "/usr/sbin/sshd"] + ) +} + +fn is_ssh_policy_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + sandbox::execution::Program::Command { executable, args } + if executable.as_str() == "/usr/bin/sudo" + && args == &[ + "-n", + "/usr/sbin/sshd", + "-T", + "-f", + "/var/lib/agent/ssh/sshd_config", + "-C", + "user=agent,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=2222", + ] + ) +} + +fn exited(code: i32) -> Vec { + vec![ + sandbox::execution::ExecutionEvent::Started { process_id: None }, + sandbox::execution::ExecutionEvent::Exited(sandbox::execution::ExitStatus { code }), + ] +} + +#[tokio::test(flavor = "local")] +async fn ssh_access_is_reported_underneath_ready_and_cleaned_up_on_deletion() { + let temporary = TempDirectory::new("ssh-access"); + let home = agent::local::home::ControlPlaneHome::resolve(Some(&temporary.path().join("home"))).expect("home"); + home.prepare().expect("prepare home"); + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let keys = Rc::new(agent::ssh::memory::InMemoryHostKeyStore::new()); + let ssh = Rc::new( + agent::ssh::Access::new( + &home, + PathBuf::from("/usr/local/bin/agentctl"), + keys.clone(), + store.clone(), + ) + .with_user_home(None), + ); + let reconciler = + Reconciler::new(store.clone(), sandbox_service(provider), ProvisioningState::default()).with_ssh_access(ssh); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + let mut request = apply_request("worker"); + request.agent.spec.access = vec![agent::AccessSpec::Ssh {}]; + control_plane.apply(request).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("stored").id; + + // The image lacks a server: the Agent is not Ready and the failure is permanent. + backend.queue_execution_events_matching(is_ssh_server_check, exited(1)); + let error = reconciler + .reconcile(id) + .await + .expect_err("missing server fails the pass"); + assert_eq!(agent::ReconcileFailure::classify(&error).kind, FailureKind::Invalid); + let status = store.get(id).await.expect("record").agent.status; + let ready = status.ready_condition().expect("Ready condition"); + assert_eq!(ready.status, ConditionStatus::False); + assert_eq!(ready.reason, "SshAccessFailed"); + assert!(ready.message.contains("cannot provide SSH access")); + let ssh_ready = status + .conditions + .iter() + .find(|condition| condition.kind == agent::Condition::SSH_READY) + .expect("SshReady condition"); + assert_eq!(ssh_ready.status, ConditionStatus::False); + assert!( + status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .is_some() + ); + assert!(!keys.contains(id)); + + // A server is present: the Agent is Ready and SshReady is reported alongside SandboxReady. + backend.queue_execution_events_matching(is_ssh_server_check, exited(0)); + backend.queue_execution_events_matching( + is_ssh_policy_check, + vec![ + sandbox::execution::ExecutionEvent::Started { process_id: None }, + sandbox::execution::ExecutionEvent::Stdout(b"permituserenvironment yes\nusepam no\n".as_slice().into()), + sandbox::execution::ExecutionEvent::Exited(sandbox::execution::ExitStatus { code: 0 }), + ], + ); + reconciler.reconcile(id).await.expect("reconcile with a server"); + let status = store.get(id).await.expect("record").agent.status; + assert!(status.is_ready()); + assert_eq!( + status + .conditions + .iter() + .map(|condition| (condition.kind.as_str(), condition.status)) + .collect::>(), + [ + (agent::Condition::SANDBOX_READY, ConditionStatus::True), + (agent::Condition::SANDBOX_RESPONSIVE, ConditionStatus::Unknown), + (agent::Condition::SSH_READY, ConditionStatus::True), + (agent::Condition::READY, ConditionStatus::True), + ] + ); + assert!(keys.contains(id)); + let ssh_home = agent::ssh::SshHome::new(&home); + assert!(ssh_home.identity_path(id).is_file()); + let known_hosts = std::fs::read_to_string(ssh_home.known_hosts_path()).expect("known_hosts"); + assert!(known_hosts.starts_with(&format!("agent-{id} ssh-ed25519 "))); + assert!(known_hosts.contains("\nagentctl-worker ssh-ed25519 ")); + assert!( + std::fs::read_to_string(ssh_home.config_path()) + .expect("config") + .contains("Host agentctl-worker\n") + ); + + control_plane.delete("worker").await.expect("delete request"); + reconciler.reconcile(id).await.expect("delete"); + assert!(!keys.contains(id)); + assert!(!ssh_home.agent_directory(id).exists()); + assert_eq!( + std::fs::read_to_string(ssh_home.known_hosts_path()).expect("known_hosts"), + "" + ); + assert!( + !std::fs::read_to_string(ssh_home.config_path()) + .expect("config") + .contains("Host ") + ); +} + +/// A Linux platform whose setup can be made to wait forever, as setup does +/// when its guest stops answering Executions. +#[derive(Default)] +struct StallingPlatform { + stall: Cell, + /// Setup fails at once, as a command timing out inside the guest does. + fail: Cell, + setups: Cell, + started: Notify, +} + +impl PlatformAdapter for StallingPlatform { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + _record: &'a AgentRecord, + _sandbox: &'a SandboxHandle, + _harnesses: &'a [agent::Harness], + _steps: &'a sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.setups.set(self.setups.get() + 1); + if self.fail.get() { + return Err(Error::SandboxSetup("`codex --version` did not finish within 5s".into())); + } + if self.stall.get() { + self.started.notify_one(); + std::future::pending::<()>().await; + } + Ok(()) + }) + } +} + +/// An Agent `worker` whose guest heartbeat and setup the test controls. +struct Stalling { + store: Rc, + backend: Rc, + platform: Rc, + reconciler: Rc, + id: AgentId, +} + +async fn stalling(changes: Changes) -> Stalling { + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes)); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let platform = Rc::new(StallingPlatform::default()); + let sandboxes = + Rc::new(Service::new([provider], [platform.clone() as Rc]).expect("Sandbox service")); + let reconciler = Rc::new(Reconciler::new(store.clone(), sandboxes, ProvisioningState::default())); + ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())) + .apply(apply_request("worker")) + .await + .expect("apply"); + let id = store.get_by_name("worker").await.expect("stored Agent").id; + Stalling { + store, + backend, + platform, + reconciler, + id, + } +} + +impl Stalling { + async fn record(&self) -> AgentRecord { + self.store.get(self.id).await.expect("stored Agent") + } + + /// Reports `sequence` as the guest's heartbeat. + async fn beat(&self, sequence: u64) { + let record = self.record().await; + let sandbox = record + .agent + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .expect("materialized Sandbox"); + self.backend + .set_guest_heartbeat(sandbox, Some(GuestHeartbeat::new(sequence))) + .expect("heartbeat should be set"); + } +} + +fn condition<'a>(status: &'a Status, kind: &str) -> &'a agent::Condition { + status + .conditions + .iter() + .find(|condition| condition.kind == kind) + .expect("the condition should be recorded") +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_stalled_guest_ends_setup_and_its_agent_reports_it_unresponsive() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + let status = fixture.record().await.agent.status; + assert!(status.is_ready()); + assert_eq!( + condition(&status, agent::Condition::SANDBOX_RESPONSIVE).reason, + "HeartbeatNotObserved" + ); + fixture.beat(1).await; + tokio::time::advance(Duration::from_secs(1)).await; + fixture.beat(2).await; + + fixture.platform.stall.set(true); + let pass = tokio::task::spawn_local({ + let reconciler = fixture.reconciler.clone(); + let id = fixture.id; + async move { reconciler.reconcile(id).await } + }); + fixture.platform.started.notified().await; + + // The heartbeat stays at 2, so the pass's own inspections find the stall. + let result = pass.await.expect("the pass should not panic"); + assert!( + matches!(result, Err(Error::SandboxUnresponsive(_))), + "the stalled pass should end as unresponsive, got {result:?}" + ); + let status = fixture.record().await.agent.status; + let ready = condition(&status, agent::Condition::READY); + assert_eq!( + (ready.status, ready.reason.as_str()), + (ConditionStatus::False, "SandboxUnresponsive") + ); + let responsive = condition(&status, agent::Condition::SANDBOX_RESPONSIVE); + assert_eq!( + (responsive.status, responsive.reason.as_str()), + (ConditionStatus::False, "HeartbeatStale") + ); + assert_eq!( + condition(&status, agent::Condition::SANDBOX_READY).status, + ConditionStatus::True, + "the Sandbox lifecycle is unchanged" + ); + assert_eq!(status.failure, Some(FailureKind::Transient)); + + // While the guest stays stalled, a pass does not reach into it. + let setups = fixture.platform.setups.get(); + let result = fixture.reconciler.reconcile(fixture.id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + assert_eq!(fixture.platform.setups.get(), setups); + + // A heartbeat that advances again makes the Agent Ready. + fixture.platform.stall.set(false); + fixture.beat(3).await; + fixture.reconciler.reconcile(fixture.id).await.expect("recovered pass"); + let status = fixture.record().await.agent.status; + assert!(status.is_ready()); + let responsive = condition(&status, agent::Condition::SANDBOX_RESPONSIVE); + assert_eq!( + (responsive.status, responsive.reason.as_str()), + (ConditionStatus::True, "HeartbeatAdvancing") + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_failure_from_a_guest_that_stopped_beating_is_reported_as_the_stall() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.fail.set(true); + + let result = fixture.reconciler.reconcile(fixture.id).await; + + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + let status = fixture.record().await.agent.status; + assert_eq!( + condition(&status, agent::Condition::READY).reason, + "SandboxUnresponsive" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_failure_from_a_guest_that_still_beats_stands() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.fail.set(true); + let beating = tokio::task::spawn_local({ + let fixture = Rc::new(fixture); + let pass = fixture.clone(); + let beats = async move { + let mut sequence = 1; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + sequence += 1; + fixture.beat(sequence).await; + } + }; + async move { + tokio::select! { + () = beats => unreachable!(), + result = pass.reconciler.reconcile(pass.id) => result, + } + } + }); + + let result = beating.await.expect("the pass should not panic"); + + assert!( + matches!(&result, Err(Error::SandboxSetup(message)) if message.contains("codex --version")), + "{result:?}" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn an_agent_with_a_stalled_guest_can_still_be_deleted() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + let result = fixture.reconciler.reconcile(fixture.id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + + ControlPlane::new(fixture.store.clone(), Rc::new(NotificationCounter::default())) + .delete("worker") + .await + .expect("delete request"); + let sandbox = sandbox_name(&fixture.record().await); + fixture.reconciler.reconcile(fixture.id).await.expect("release"); + + assert!(matches!(fixture.store.get(fixture.id).await, Err(Error::NotFound))); + let retained = fixture.backend.find(&sandbox).await.expect("retained Sandbox"); + assert_eq!(retained.state, sandbox::SandboxState::Stopped); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn waiting_until_ready_ends_once_the_guest_is_recorded_unresponsive() { + let changes = Changes::new(); + let fixture = stalling(changes.clone()).await; + let (controller, wakeup) = Controller::new( + fixture.store.clone(), + fixture.reconciler.clone(), + NO_BACKGROUND_PASSES, + Rc::new(|_, _| {}), + ); + let task = tokio::task::spawn_local(controller.run()); + let convergence = Convergence::new(wakeup, fixture.store.clone(), changes); + convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect("the healthy Agent should become Ready"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + + let started = tokio::time::Instant::now(); + let result = convergence.converge("worker", WaitPolicy::UntilConverged).await; + + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + let waited = started.elapsed(); + assert!( + waited >= UNRESPONSIVE_AFTER && waited < UNRESPONSIVE_AFTER + Duration::from_secs(3), + "the wait ends with the pass that finds the stall, took {waited:?}" + ); + task.abort(); +} + +async fn assert_stopped(store: &memory::InMemoryAgentStore, backend: &sandbox_memory::Provider, id: AgentId) { + let record = store.get(id).await.expect("stored Agent"); + let status = &record.agent.status; + assert!(status.is_stopped(), "{status:?}"); + assert_eq!(status.observed_generation, record.agent.metadata.generation); + assert_eq!(status.failure, None, "a stop is not a failure"); + let ready = condition(status, agent::Condition::READY); + assert_eq!(ready.status, ConditionStatus::False); + assert_eq!( + condition(status, agent::Condition::SANDBOX_READY).reason, + agent::Condition::REASON_STOPPED + ); + assert!( + status + .conditions + .iter() + .all(|condition| condition.kind != agent::Condition::SANDBOX_RESPONSIVE), + "a stopped Sandbox has no guest to be responsive or not: {status:?}" + ); + let sandbox = backend.find(&sandbox_name(&record)).await.expect("kept Sandbox"); + assert_eq!(sandbox.state, sandbox::SandboxState::Stopped); +} + +#[tokio::test(flavor = "local")] +async fn a_stopped_agent_keeps_its_sandbox_stopped_across_passes_and_reapplies() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + reconcile(&fixture, "worker").await; + let ready = stored(&fixture, "worker").await; + let sandbox = ready.agent.status.sandbox.clone().expect("materialized Sandbox"); + + let stopping = fixture + .control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + assert_eq!(stopping.metadata.generation, ready.agent.metadata.generation + 1); + assert_eq!(stopping.spec.run_state, Some(agent::RunState::Stopped)); + reconcile(&fixture, "worker").await; + assert_stopped(&fixture.store, &fixture.backend, ready.id).await; + let stopped = stored(&fixture, "worker").await; + assert_eq!( + stopped.agent.status.sandbox, + Some(sandbox), + "the Sandbox keeps its identity" + ); + + // A periodic pass and a repeated stop change nothing. + reconcile(&fixture, "worker").await; + let again = fixture + .control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("repeated stop"); + assert_eq!(again.metadata.generation, stopped.agent.metadata.generation); + assert_stopped(&fixture.store, &fixture.backend, ready.id).await; + + // Applying a manifest that does not set a run state keeps the Agent stopped. + let reapplied = fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("re-apply"); + assert_eq!(reapplied.metadata.generation, stopped.agent.metadata.generation); + assert!(reapplied.spec.is_stopped()); + let mut changed = apply_request("worker"); + changed.agent.spec.harnesses[0].version = Some("2.1.240".into()); + let changed = fixture.control_plane.apply(changed).await.expect("changed apply"); + assert!(changed.spec.is_stopped(), "a changed manifest keeps the run state too"); + reconcile(&fixture, "worker").await; + assert_stopped(&fixture.store, &fixture.backend, ready.id).await; + assert_eq!(fixture.backend.count(), 1); + + // A manifest that sets the run state changes it, like `agentctl start` does. + let mut running = apply_request("worker"); + running.agent.spec.harnesses[0].version = Some("2.1.240".into()); + running.agent.spec.run_state = Some(agent::RunState::Running); + let started = fixture.control_plane.apply(running).await.expect("apply Running"); + assert_eq!(started.spec.run_state, None, "Running is stored as omitted"); + reconcile(&fixture, "worker").await; + assert!(stored(&fixture, "worker").await.agent.status.is_ready()); +} + +#[tokio::test(flavor = "local")] +async fn a_start_boots_the_same_sandbox_and_wakes_sessions() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = reconciler(store.clone(), provider).with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + reconciler.reconcile(id).await.expect("materialize"); + let sandbox = store.get(id).await.expect("Agent").agent.status.sandbox; + + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconciler.reconcile(id).await.expect("stop pass"); + assert_stopped(&store, &backend, id).await; + let after_stop = notifications.0.get(); + assert!(after_stop > 1, "Sessions are told their Agent is no longer Ready"); + + control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + reconciler.reconcile(id).await.expect("start pass"); + let record = store.get(id).await.expect("Agent"); + assert!(record.agent.status.is_ready()); + assert_eq!(record.agent.status.sandbox, sandbox, "the same Sandbox starts again"); + let running = backend.find(&sandbox_name(&record)).await.expect("Sandbox"); + assert_eq!(running.state, sandbox::SandboxState::Running); + assert!(notifications.0.get() > after_stop, "Sessions wake"); + assert_eq!(backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn a_new_agent_applied_stopped_materializes_only_once_started() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.agent.spec.run_state = Some(agent::RunState::Stopped); + fixture.control_plane.apply(request).await.expect("apply"); + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + assert!(record.agent.status.is_stopped()); + assert_eq!(record.agent.status.sandbox, None); + assert_eq!(fixture.backend.count(), 0); + + fixture + .control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + reconcile(&fixture, "worker").await; + assert!(stored(&fixture, "worker").await.agent.status.is_ready()); + assert_eq!(fixture.backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn a_restarted_daemon_keeps_a_stopped_agent_stopped_and_starts_a_running_one() { + let fixture = fixture(); + for name in ["stopped", "running"] { + fixture.control_plane.apply(apply_request(name)).await.expect("apply"); + reconcile(&fixture, name).await; + } + fixture + .control_plane + .set_run_state("stopped", agent::RunState::Stopped) + .await + .expect("stop"); + reconcile(&fixture, "stopped").await; + // The host went down: every VM is gone, and a new daemon reconciles the stored Agents. + let running = stored(&fixture, "running").await; + fixture + .backend + .stop( + running + .agent + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .expect("Sandbox"), + ) + .await + .expect("VM gone"); + let restarted = reconciler( + fixture.store.clone(), + Rc::new(MemoryProvider::new(fixture.backend.clone())), + ); + for name in ["stopped", "running"] { + restarted + .reconcile(stored(&fixture, name).await.id) + .await + .expect("pass"); + } + + assert_stopped(&fixture.store, &fixture.backend, stored(&fixture, "stopped").await.id).await; + let sandbox = fixture.backend.find(&sandbox_name(&running)).await.expect("Sandbox"); + assert_eq!(sandbox.state, sandbox::SandboxState::Running); +} + +#[tokio::test(flavor = "local")] +async fn a_stopped_agent_can_be_deleted() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + reconcile(&fixture, "worker").await; + fixture + .control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + + fixture.control_plane.delete("worker").await.expect("delete"); + fixture.reconciler.reconcile(record.id).await.expect("release"); + + assert!(matches!(fixture.store.get(record.id).await, Err(Error::NotFound))); + // The test manifest retains its Sandbox on release. + let retained = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("retained Sandbox"); + assert_eq!(retained.state, sandbox::SandboxState::Stopped); + let error = fixture + .control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect_err("a deleted Agent cannot start"); + assert!(matches!(error, Error::NotFound)); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn an_agent_with_a_stalled_guest_can_be_stopped_and_started() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + let result = fixture.reconciler.reconcile(fixture.id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + + let control_plane = ControlPlane::new(fixture.store.clone(), Rc::new(NotificationCounter::default())); + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let setups = fixture.platform.setups.get(); + fixture + .reconciler + .reconcile(fixture.id) + .await + .expect("a stop needs no guest"); + assert_eq!( + fixture.platform.setups.get(), + setups, + "the stop does not reach into the guest" + ); + assert_stopped(&fixture.store, &fixture.backend, fixture.id).await; + + // The started guest beats from a new sequence, and the stall before the stop is forgotten. + fixture.platform.stall.set(false); + control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + fixture.reconciler.reconcile(fixture.id).await.expect("start pass"); + let status = fixture.record().await.agent.status; + assert!(status.is_ready(), "{status:?}"); + assert!(status.unresponsive().is_none()); +} + +#[tokio::test(flavor = "local")] +async fn commands_on_a_stopped_agent_fail_at_once_and_a_wait_ends_when_it_stops() { + let ended = Rc::new(Cell::new(false)); + let fixture = waiting( + [PlannedFailure::Outage { + message: "runtime is down".into(), + ended: ended.clone(), + }], + BACKGROUND_RETRIES, + ) + .await; + let control_plane = ControlPlane::new(fixture.store.clone(), Rc::new(fixture.wakeup.clone())); + + // A wait through an outage ends once the Agent is stopped. + let waited = tokio::task::spawn_local({ + let execution = fixture.execution.clone(); + async move { execution.ensure("worker", WaitPolicy::UntilConverged).await } + }); + tokio::time::sleep(BACKGROUND_RETRIES * 3).await; + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let error = tokio::time::timeout(Duration::from_secs(1), waited) + .await + .expect("the wait ends with the stop") + .expect("the wait does not panic") + .expect_err("a stopped Agent never becomes Ready"); + assert!(matches!(&error, Error::Stopped(name) if name == "worker"), "{error:?}"); + assert_eq!( + error.to_string(), + "Agent \"worker\" is stopped; run `agentctl start agent/worker`" + ); + + // Later commands are refused before anything is woken or waited for. + ended.set(true); + for wait in [WaitPolicy::FirstPass, WaitPolicy::UntilConverged] { + let error = tokio::time::timeout(Duration::from_millis(100), fixture.execution.ensure("worker", wait)) + .await + .expect("refused without waiting") + .expect_err("a stopped Agent runs nothing"); + assert!(matches!(error, Error::Stopped(_)), "{error:?}"); + } + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_stop_recorded_while_an_execution_waits_refuses_it() { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let provider: Rc = Rc::new( + MemoryProvider::new(Rc::new(sandbox_memory::Provider::new())).with_blocking(Blocking { + agent: id, + calls: Rc::new(Cell::new(0)), + started: started.clone(), + release: release.clone(), + }), + ); + let (controller, wakeup) = Controller::new( + store.clone(), + Rc::new(reconciler(store.clone(), provider)), + NO_BACKGROUND_PASSES, + Rc::new(|_, _| {}), + ); + let task = tokio::task::spawn_local(controller.run()); + started.notified().await; + + // The wait is admitted while the Agent runs, and its pass is the one after the stop. + let execution = Rc::new(ExecutionService::new( + store.clone(), + Convergence::new(wakeup, store.clone(), changes), + )); + let waited = tokio::task::spawn_local(async move { execution.ensure("worker", WaitPolicy::FirstPass).await }); + tokio::task::yield_now().await; + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + release.notify_one(); + + let result = tokio::time::timeout(Duration::from_secs(1), waited) + .await + .expect("the wait ends with the stop pass") + .expect("the wait does not panic"); + assert!(matches!(result, Err(Error::Stopped(_))), "{result:?}"); + task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn converging_the_run_state_waits_for_a_stop_and_for_a_start() { + let fixture = waiting([], NO_BACKGROUND_PASSES).await; + let convergence = Convergence::new(fixture.wakeup.clone(), fixture.store.clone(), Changes::new()); + let control_plane = ControlPlane::new(fixture.store.clone(), Rc::new(fixture.wakeup.clone())); + let converged = tokio::time::timeout( + Duration::from_secs(1), + convergence.converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a new Agent converges") + .expect("Ready"); + assert!(converged.agent.status.is_ready()); + + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let converged = tokio::time::timeout( + Duration::from_secs(1), + convergence.converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a stop converges") + .expect("stopped"); + assert!(converged.agent.spec.is_stopped() && converged.agent.status.is_stopped()); + assert_eq!( + converged.agent.status.observed_generation, + converged.agent.metadata.generation + ); + + control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + let converged = tokio::time::timeout( + Duration::from_secs(1), + convergence.converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a start converges") + .expect("Ready"); + assert!(converged.agent.status.is_ready() && !converged.agent.spec.is_stopped()); + + let error = convergence + .converge("missing", WaitPolicy::UntilConverged) + .await + .expect_err("missing Agent"); + assert!(matches!(error, Error::NotFound), "{error:?}"); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn converging_a_stopped_agent_is_not_ended_by_the_stall_it_was_stopped_for() { + let changes = Changes::new(); + let fixture = stalling(changes.clone()).await; + let (controller, wakeup) = Controller::new( + fixture.store.clone(), + fixture.reconciler.clone(), + NO_BACKGROUND_PASSES, + Rc::new(|_, _| {}), + ); + let task = tokio::task::spawn_local(controller.run()); + let convergence = Convergence::new(wakeup, fixture.store.clone(), changes); + convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect("Ready"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + let error = convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect_err("a running Agent with a stalled guest cannot converge"); + assert!(matches!(error, Error::SandboxUnresponsive(_)), "{error:?}"); + + ControlPlane::new(fixture.store.clone(), Rc::new(NotificationCounter::default())) + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let stopped = convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect("the stop converges"); + assert!(stopped.agent.status.is_stopped()); + task.abort(); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn stopping_an_agent_with_a_stalled_guest_tells_its_sessions() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let platform = Rc::new(StallingPlatform::default()); + let sandboxes = + Rc::new(Service::new([provider], [platform.clone() as Rc]).expect("Sandbox service")); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = Reconciler::new(store.clone(), sandboxes, ProvisioningState::default()) + .with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + reconciler.reconcile(id).await.expect("first pass"); + let sandbox = store + .get(id) + .await + .expect("Agent") + .agent + .status + .sandbox + .and_then(|assignment| assignment.id().cloned()) + .expect("materialized Sandbox"); + backend + .set_guest_heartbeat(&sandbox, Some(GuestHeartbeat::new(1))) + .expect("heartbeat should be set"); + platform.stall.set(true); + let result = reconciler.reconcile(id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + // Not Ready already, so only the stop itself can tell the held Sessions. + let before = notifications.0.get(); + + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconciler.reconcile(id).await.expect("stop pass"); + assert!( + notifications.0.get() > before, + "Sessions held by the stalled guest must learn the Agent stopped, so they go Idle" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_failed_stop_reads_as_stopping_and_converges_once_a_retry_stops_it() { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider = MemoryProvider::new(backend.clone()); + let failing_stops = provider.failing_stops.clone(); + let reconciler = Rc::new(reconciler(store.clone(), Rc::new(provider))); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + reconciler.reconcile(id).await.expect("Ready"); + + failing_stops.set(1); + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconciler.reconcile(id).await.expect_err("the stop fails"); + let record = store.get(id).await.expect("Agent"); + let ready = condition(&record.agent.status, agent::Condition::READY); + assert_eq!(ready.reason, agent::Condition::REASON_STOPPING); + assert!(ready.message.contains("runtime unreachable"), "{ready:?}"); + assert_eq!( + record.agent.status.failure, + Some(FailureKind::Transient), + "a failed stop is retried" + ); + let sandbox = backend.find(&sandbox_name(&record)).await.expect("Sandbox"); + assert_eq!(sandbox.state, sandbox::SandboxState::Running, "nothing stopped yet"); + + // The background controller retries, and a wait for the stop ends with it. + let (controller, wakeup) = Controller::new(store.clone(), reconciler, BACKGROUND_RETRIES, Rc::new(|_, _| {})); + let task = tokio::task::spawn_local(controller.run()); + failing_stops.set(1); + let converged = tokio::time::timeout( + Duration::from_secs(1), + Convergence::new(wakeup, store.clone(), changes).converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a retried stop converges") + .expect("stopped"); + assert!(converged.agent.status.is_stopped()); + assert_eq!(failing_stops.get(), 0, "the wait went through a failed retry"); + assert_stopped(&store, &backend, id).await; + task.abort(); +} diff --git a/agentctl/tests/database.rs b/agentctl/tests/database.rs new file mode 100644 index 0000000..8974fbc --- /dev/null +++ b/agentctl/tests/database.rs @@ -0,0 +1,1494 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::{Path, PathBuf}; + +use agent::{ + AgentId, Condition, ConditionStatus, Error, Status, + control_plane::{AgentRecord, AgentStore as _}, + persistence, + sandbox::{Assignment, ProviderId}, + sessions::{Lifecycle, NewSession, SessionName, SessionReports as _, SessionStore as _}, +}; +use sandbox::secret_store::SecretStore as _; +use tempfile::TempDir; +use tokio::runtime::LocalRuntime; + +fn test_agent_id() -> AgentId { + "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID") +} + +fn record_with_id(name: &str, generation: u64, id: AgentId) -> AgentRecord { + let mut agent = support::agent(name); + agent.metadata.generation = generation; + AgentRecord { + id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent, + } +} + +fn record(name: &str, generation: u64) -> AgentRecord { + record_with_id(name, generation, test_agent_id()) +} + +fn ready_record(name: &str, id: AgentId) -> AgentRecord { + let mut ready = record_with_id(name, 1, id); + ready.agent.status = Status::observed( + 1, + Some(Assignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af".parse().expect("Sandbox ID"), + harnesses: ready + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ); + ready +} + +const PREVIEW_1_SCHEMA: &str = " + CREATE TABLE agents ( + id TEXT PRIMARY KEY NOT NULL, + active_name TEXT UNIQUE, + source_directory TEXT NOT NULL, + desired_json TEXT NOT NULL, + deletion_timestamp INTEGER, + status_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE secrets ( + name TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ); + CREATE TABLE provider_accounts ( + provider TEXT PRIMARY KEY NOT NULL, + metadata_json TEXT NOT NULL + ); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id), + name TEXT NOT NULL, + harness TEXT NOT NULL, + created_at INTEGER NOT NULL, + activation_generation INTEGER NOT NULL DEFAULT 0, + lifecycle_json TEXT NOT NULL DEFAULT '{}', + harness_native_id TEXT, + launch_token TEXT UNIQUE, + launch_sandbox TEXT, + launched_at INTEGER, + launch_attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE (agent_id, name) + ); + PRAGMA user_version = 1; +"; + +// The schema produced by the session-management build on main before migrations were introduced. +const EXPANDED_VERSION_1_SCHEMA: &str = " + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY NOT NULL, + active_name TEXT UNIQUE, + source_directory TEXT NOT NULL, + desired_json TEXT NOT NULL, + deletion_timestamp INTEGER, + status_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS secrets ( + name TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ); + CREATE TABLE IF NOT EXISTS provider_accounts ( + provider TEXT PRIMARY KEY NOT NULL, + metadata_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id), + name TEXT NOT NULL, + harness TEXT NOT NULL, + created_at INTEGER NOT NULL, + activation_generation INTEGER NOT NULL DEFAULT 0, + lifecycle_json TEXT NOT NULL DEFAULT '{}', + initial_prompt TEXT, + harness_native_id TEXT, + harness_transcript_path TEXT, + activity_json TEXT NOT NULL DEFAULT '{}', + launch_token TEXT UNIQUE, + launch_sandbox TEXT, + launched_at INTEGER, + launch_attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE (agent_id, name) + ); + CREATE TABLE IF NOT EXISTS session_activity_reports ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + launch_token TEXT NOT NULL, + event_id TEXT NOT NULL, + PRIMARY KEY (session_id, launch_token, event_id) + ); + PRAGMA user_version = 1; +"; + +const PREVIEW_AGENT_ID: &str = "11111111-1111-4111-8111-111111111111"; +const PREVIEW_DELETED_AGENT_ID: &str = "22222222-2222-4222-8222-222222222222"; +const EXPANDED_AGENT_ID: &str = "33333333-3333-4333-8333-333333333333"; +const PREVIEW_SECRET: &[u8] = b"\0preview-one-secret\xff"; + +fn preview_desired(name: &str) -> String { + let mut desired = serde_json::to_value(support::agent(name)).expect("serialize fixture Agent"); + let spec = desired["spec"].as_object_mut().expect("fixture spec"); + let mut instructions = spec.remove("instructions").expect("fixture instructions"); + let instruction = instructions.as_array_mut().expect("current instructions").remove(0); + spec.insert("instructions".into(), instruction); + spec.remove("skills"); + serde_json::to_string(&desired).expect("encode preview desired state") +} + +fn preview_desired_with_null_instructions(name: &str) -> String { + let mut desired = serde_json::to_value(support::agent(name)).expect("serialize fixture Agent"); + let spec = desired["spec"].as_object_mut().expect("fixture spec"); + spec.insert("instructions".into(), serde_json::Value::Null); + spec.remove("skills"); + serde_json::to_string(&desired).expect("encode preview desired state") +} + +fn create_preview_1_database(path: &Path) { + let connection = rusqlite::Connection::open(path).expect("create preview 1 database"); + connection.execute_batch(PREVIEW_1_SCHEMA).expect("preview 1 schema"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'worker', ?2, ?3, NULL, '{}'), (?4, NULL, ?5, ?6, 1700000000, '{}')", + rusqlite::params![ + PREVIEW_AGENT_ID, + serde_json::to_string(Path::new("/preview/source")).expect("source"), + preview_desired("worker"), + PREVIEW_DELETED_AGENT_ID, + serde_json::to_string(Path::new("/preview/deleted")).expect("deleted source"), + preview_desired_with_null_instructions("deleted") + ], + ) + .expect("preview Agents"); + for (index, state) in (0_i64..).zip(["starting", "running", "idle", "failed"]) { + let id = format!("00000000-0000-4000-8000-{index:012}"); + let lifecycle = serde_json::json!({ + "state": state, + "failure": (state == "failed").then_some("preview failure"), + "observedActivationGeneration": index, + }); + connection + .execute( + "INSERT INTO sessions \ + (id, agent_id, name, harness, created_at, activation_generation, lifecycle_json, \ + harness_native_id, launch_token, launch_sandbox, launched_at, launch_attempts) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'preview-sandbox', ?10, ?11)", + rusqlite::params![ + id, + PREVIEW_AGENT_ID, + format!("session-{state}"), + if index % 2 == 0 { "claudeCode" } else { "codex" }, + 1_700_000_000_i64 + index, + index, + serde_json::to_string(&lifecycle).expect("lifecycle"), + format!("native-{index}"), + format!("00000000-0000-4000-9000-{index:012}"), + 1_700_000_100_i64 + index, + index + 1, + ], + ) + .expect("preview Session"); + } + connection + .execute( + "INSERT INTO secrets (name, value) VALUES ('claude-access-token', ?1)", + [PREVIEW_SECRET], + ) + .expect("preview secret"); + connection + .execute( + "INSERT INTO provider_accounts (provider, metadata_json) VALUES ('claudeCode', ?1)", + [r#"{"account":"preview-user"}"#], + ) + .expect("preview provider account"); +} + +#[test] +fn stores_scrub_projected_provenance_and_keep_recorded_manifest_paths() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let mut record = ready_record("worker", test_agent_id()); + record.manifest_path = Some(PathBuf::from("/source/worker.yml")); + record.agent.status.provenance = Some(agent::Provenance { + source_directory: PathBuf::from("/leaked"), + manifest_path: None, + env_file: None, + }); + store.put(record.clone(), 0).await.expect("Agent stored"); + + let stored = store.get(record.id).await.expect("Agent loaded"); + assert_eq!(stored.agent.status.provenance, None); + assert_eq!(stored.manifest_path.as_deref(), Some(Path::new("/source/worker.yml"))); + assert_eq!(stored.source_directory, record.source_directory); + + let mut status = stored.agent.status.clone(); + status.provenance = Some(agent::Provenance { + source_directory: PathBuf::from("/leaked"), + manifest_path: None, + env_file: None, + }); + store + .update_status(record.id, stored.agent.metadata.generation, status) + .await + .expect("status updated"); + let reloaded = store.get(record.id).await.expect("Agent reloaded"); + assert_eq!(reloaded.agent.status.provenance, None); + assert_eq!(reloaded.agent.status.conditions, stored.agent.status.conditions); + assert_eq!(reloaded.manifest_path.as_deref(), Some(Path::new("/source/worker.yml"))); + assert_eq!(reloaded.source_directory, record.source_directory); + }); +} + +fn ready_false(reason: &str, message: &str, at: Option) -> Condition { + Condition { + kind: Condition::READY.into(), + status: ConditionStatus::False, + reason: reason.into(), + message: message.into(), + last_transition_time: at, + } +} + +#[test] +fn status_updates_stamp_condition_transitions_and_keep_the_failure_class() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let entered = time::OffsetDateTime::from_unix_timestamp(1_600_000_000).expect("timestamp"); + let mut record = record("worker", 1); + record.agent.status = Status::observed( + 1, + None, + vec![ready_false("ProviderSelected", "provisioning", Some(entered))], + ); + let changes = store.changes(); + store.put(record.clone(), 0).await.expect("Agent stored"); + let written = changes.revision(); + store.get(record.id).await.expect("Agent read"); + assert_eq!( + changes.revision(), + written, + "reads do not advance the resource revision" + ); + + let mut retry = Status::observed(1, None, vec![ready_false("ProviderSelected", "another detail", None)]); + retry.failure = Some(agent::FailureKind::Transient); + retry.progress = Some(agent::progress::Provisioning { + pass: changes.revision(), + progress: sandbox::progress::Progress::new(), + }); + let stored = store.update_status(record.id, 1, retry).await.expect("status updated"); + assert_ne!( + changes.revision(), + written, + "status writes advance the resource revision" + ); + assert_eq!(stored.progress, None, "progress is projected, never stored"); + assert_eq!( + stored.conditions[0].last_transition_time, + Some(entered), + "a message-only change is not a transition" + ); + assert_eq!(stored.failure, Some(agent::FailureKind::Transient)); + let reloaded = store.get(record.id).await.expect("Agent reloaded"); + assert_eq!(reloaded.agent.status, stored, "the returned status is what was stored"); + + let failed = Status::observed(1, None, vec![ready_false("SandboxReconcileFailed", "boom", None)]); + let stored = store.update_status(record.id, 1, failed).await.expect("status updated"); + assert!( + stored.conditions[0].last_transition_time.is_some_and(|at| at > entered), + "a reason change is stamped" + ); + assert_eq!(stored.failure, None); + }); +} + +#[test] +fn sessions_are_idempotent_and_survive_database_reopen() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let first = persistence::Database::open(&path).expect("open first database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let ready = ready_record("worker", test_agent_id()); + first.put(ready, 0).await.expect("ready Agent"); + let name = SessionName::new("s1").expect("session name"); + let created = first + .ensure_session( + "worker", + &name, + NewSession { + initial_prompt: Some("first prompt".into()), + ..NewSession::resolved( + agent::Harness::ClaudeCode, + agent::ModelSelection { + model: Some(agent::Model::new("fable").expect("model")), + effort: Some(agent::Effort::new("xhigh").expect("effort")), + }, + &agent::ModelSelection::default(), + ) + }, + ) + .await + .expect("create session"); + let existing = first + .ensure_session( + "worker", + &name, + NewSession { + initial_prompt: Some("ignored: not created here".into()), + ..NewSession::resolved( + agent::Harness::ClaudeCode, + agent::ModelSelection { + model: Some(agent::Model::new("fable").expect("model")), + effort: Some(agent::Effort::new("xhigh").expect("effort")), + }, + &agent::ModelSelection::default(), + ) + }, + ) + .await + .expect("get session"); + assert_eq!(created.agent_id, test_agent_id()); + assert_eq!(created.harness, agent::Harness::ClaudeCode); + assert_eq!(created.model_selection.model_str(), Some("fable")); + assert_eq!(created.model_selection.effort_str(), Some("xhigh")); + assert_eq!(created, existing, "creation-time selections are recorded once"); + let unselected = first + .ensure_session( + "worker", + &SessionName::new("plain").expect("session name"), + NewSession::for_harness(agent::Harness::Codex), + ) + .await + .expect("create session without selections"); + assert!(unselected.model_selection.is_empty()); + first + .update_session_lifecycle(created.id, Lifecycle::running(), 0) + .await + .expect("persist observed state"); + }); + drop(first); + + let second = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let sessions = second.list_agent_sessions("worker").await.expect("persistent sessions"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[1].name.as_str(), "s1"); + assert_eq!(sessions[1].harness, agent::Harness::ClaudeCode); + assert_eq!(sessions[1].model_selection.model_str(), Some("fable")); + assert_eq!(sessions[1].model_selection.effort_str(), Some("xhigh")); + assert_eq!(sessions[0].name.as_str(), "plain"); + assert!(sessions[0].model_selection.is_empty()); + assert_eq!( + sessions[1].status.lifecycle.state, + agent::sessions::LifecycleState::Running + ); + assert_eq!( + rusqlite::Connection::open(&path) + .expect("read database") + .query_row( + "SELECT initial_prompt FROM sessions WHERE id = ?1", + [sessions[1].id.to_string()], + |row| row.get::<_, Option>(0) + ) + .expect("initial prompt") + .as_deref(), + Some("first prompt"), + "the first prompt is recorded once, at creation" + ); + assert_eq!( + second + .get_agent_session("worker", &SessionName::new("s1").expect("Session name")) + .await + .expect("named Session"), + sessions[1] + ); + }); +} + +#[test] +fn concurrent_creation_only_conflicts_on_explicit_selections() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("ready Agent"); + let name = SessionName::new("raced").expect("session name"); + let selection = |model: Option<&str>, effort: Option<&str>| agent::ModelSelection { + model: model.map(|model| agent::Model::new(model).expect("model")), + effort: effort.map(|effort| agent::Effort::new(effort).expect("effort")), + }; + let defaults = selection(Some("fable"), Some("xhigh")); + let first = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, selection(Some("opus"), None), &defaults), + ) + .await + .expect("first creation"); + assert_eq!(first.model_selection, selection(Some("opus"), Some("xhigh"))); + + // A loser that chose nothing resolved to other values, but it did not ask for them. + let omitted = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, agent::ModelSelection::default(), &defaults), + ) + .await + .expect("omitted selections take the Session as recorded"); + assert_eq!(omitted.id, first.id); + assert_eq!(omitted.model_selection, first.model_selection); + + let same = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, selection(Some("opus"), None), &defaults), + ) + .await + .expect("the same explicit choice finds the Session"); + assert_eq!(same.id, first.id); + + let conflict = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, selection(Some("sonnet"), None), &defaults), + ) + .await + .expect_err("a loser that explicitly chose differently is told"); + assert_eq!( + conflict.to_string(), + "invalid Agent: Session \"raced\" already uses model \"opus\", not \"sonnet\"" + ); + let unselected = store + .ensure_session( + "worker", + &name, + NewSession::resolved( + agent::Harness::ClaudeCode, + selection(None, Some("low")), + &agent::ModelSelection::default(), + ), + ) + .await + .expect_err("an explicit effort conflicts with the recorded one"); + assert!( + unselected + .to_string() + .contains("already uses effort \"xhigh\", not \"low\""), + "{unselected}" + ); + }); +} + +#[test] +fn attach_error_identifies_the_session_and_its_lifecycle_failure() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("ready Agent"); + let session = store + .ensure_session("worker", &SessionName::new("recovering").expect("Session name"), NewSession::for_harness(agent::Harness::Codex)) + .await + .expect("Session"); + store + .update_session_lifecycle( + session.id, + Lifecycle::starting("harness exited; relaunching after up to 10s of backoff"), + 1, + ) + .await + .expect("lifecycle"); + + assert_eq!( + store + .session_attach_target(session.id) + .await + .expect_err("Session is not running") + .to_string(), + "invalid Agent: Session \"recovering\" is not running: harness exited; relaunching after up to 10s of backoff" + ); + }); +} + +#[test] +fn finalized_agents_and_their_sessions_remain_as_tombstones_when_a_name_is_reused() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("open database owner"); + let old_id = "f9fc2dac-ae2d-4534-a9c1-dd13dd9b5160".parse().expect("old Agent ID"); + let new_id = "f50fbec8-03a9-43ea-b65d-c15a86e9eb65".parse().expect("new Agent ID"); + LocalRuntime::new().expect("local runtime").block_on(async { + store.put(ready_record("worker", old_id), 0).await.expect("old Agent"); + let old_session = SessionName::new("old-session").expect("session name"); + store + .ensure_session( + "worker", + &old_session, + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("old session"); + store.mark_deleting("worker").await.expect("mark deleting"); + store.finalize_deletion(old_id, 1).await.expect("finalize deletion"); + assert!(matches!(store.get(old_id).await, Err(Error::NotFound))); + + store + .put(ready_record("worker", new_id), 0) + .await + .expect("new Agent incarnation"); + let sessions = store.list_agent_sessions("worker").await.expect("new Agent sessions"); + assert!(sessions.is_empty()); + }); + drop(store); + + let connection = rusqlite::Connection::open(path).expect("inspect database"); + assert_eq!( + connection + .query_row("SELECT COUNT(*) FROM agents", [], |row| row.get::<_, i64>(0)) + .expect("Agent count"), + 2 + ); + assert_eq!( + connection + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get::<_, i64>(0)) + .expect("session count"), + 1 + ); +} + +#[test] +fn released_preview_1_database_migrates_without_losing_state() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + create_preview_1_database(&path); + + let database = persistence::Database::open(&path).expect("migrate preview 1 database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let agent = database + .get(PREVIEW_AGENT_ID.parse().expect("preview Agent ID")) + .await + .expect("migrated Agent"); + assert_eq!(agent.source_directory, Path::new("/preview/source")); + assert_eq!(agent.agent.spec.instructions.len(), 1); + assert!(agent.agent.spec.skills.is_empty()); + let sessions = database.list_agent_sessions("worker").await.expect("migrated Sessions"); + assert_eq!(sessions.len(), 4); + assert_eq!( + sessions[0].status.lifecycle.state, + agent::sessions::LifecycleState::Failed + ); + assert_eq!( + sessions[1].status.lifecycle.state, + agent::sessions::LifecycleState::Idle + ); + assert_eq!( + sessions[2].status.lifecycle.state, + agent::sessions::LifecycleState::Running + ); + assert_eq!( + sessions[3].status.lifecycle.state, + agent::sessions::LifecycleState::Starting + ); + }); + drop(database); + + let connection = rusqlite::Connection::open(&path).expect("inspect migrated database"); + assert_eq!( + connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) + .expect("schema version"), + 5 + ); + assert_migrated_session_selections(&connection, 2, 2); + assert_eq!( + connection + .query_row( + "SELECT source_directory FROM agents WHERE id = ?1", + [PREVIEW_DELETED_AGENT_ID], + |row| row.get::<_, String>(0), + ) + .expect("deleted Agent source"), + serde_json::to_string(Path::new("/preview/deleted")).expect("source") + ); + let deleted: agent::Agent = serde_json::from_str( + &connection + .query_row( + "SELECT desired_json FROM agents WHERE id = ?1", + [PREVIEW_DELETED_AGENT_ID], + |row| row.get::<_, String>(0), + ) + .expect("deleted Agent desired state"), + ) + .expect("migrated deleted Agent"); + assert!(deleted.spec.instructions.is_empty()); + assert_eq!( + connection + .query_row( + "SELECT value FROM secrets WHERE name = 'claude-access-token'", + [], + |row| { row.get::<_, Vec>(0) } + ) + .expect("migrated secret"), + PREVIEW_SECRET + ); + assert_eq!( + connection + .query_row( + "SELECT metadata_json FROM provider_accounts WHERE provider = 'claudeCode'", + [], + |row| row.get::<_, String>(0), + ) + .expect("provider metadata"), + r#"{"account":"preview-user"}"# + ); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM sessions \ + WHERE initial_prompt IS NULL AND harness_transcript_path IS NULL AND activity_json = '{}'", + [], + |row| row.get::<_, u32>(0), + ) + .expect("migrated Session defaults"), + 4 + ); + drop(connection); + drop(persistence::Database::open(&path).expect("reopen migrated database")); +} + +#[test] +fn preview_1_home_opened_by_the_expanded_version_1_build_migrates() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + create_preview_1_database(&path); + let connection = rusqlite::Connection::open(&path).expect("open intermediate database"); + connection + .execute_batch( + "CREATE TABLE session_activity_reports ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + launch_token TEXT NOT NULL, + event_id TEXT NOT NULL, + PRIMARY KEY (session_id, launch_token, event_id) + );", + ) + .expect("intermediate reports table"); + let expanded_desired = serde_json::to_string(&support::agent("new-worker")).expect("expanded desired state"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'new-worker', ?2, ?3, NULL, '{}')", + rusqlite::params![ + EXPANDED_AGENT_ID, + serde_json::to_string(Path::new("/expanded/source")).expect("source"), + expanded_desired, + ], + ) + .expect("expanded Agent"); + drop(connection); + + let database = persistence::Database::open(&path).expect("migrate intermediate database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let agent = database + .get(PREVIEW_AGENT_ID.parse().expect("preview Agent ID")) + .await + .expect("preserved Agent"); + assert_eq!(agent.agent.spec.instructions.len(), 1); + let expanded = database + .get(EXPANDED_AGENT_ID.parse().expect("expanded Agent ID")) + .await + .expect("preserved expanded Agent"); + assert_eq!(expanded.agent.spec.instructions.len(), 1); + }); + drop(database); + + assert_eq!(schema_snapshot(&path).0, 5); + assert_eq!( + connection_value(&path, EXPANDED_AGENT_ID, "desired_json"), + expanded_desired, + "array-valued instructions should not rewrite current desired state" + ); +} + +#[test] +fn version_2_home_records_the_model_existing_claude_code_sessions_launched_with() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("create version 2 database"); + connection + .execute_batch(EXPANDED_VERSION_1_SCHEMA) + .expect("version 2 tables"); + connection.pragma_update(None, "user_version", 2).expect("version 2"); + let desired = serde_json::to_string(&support::agent("worker")).expect("desired state"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'worker', ?2, ?3, NULL, '{}')", + rusqlite::params![ + PREVIEW_AGENT_ID, + serde_json::to_string(Path::new("/source")).expect("source"), + desired, + ], + ) + .expect("Agent"); + for (index, harness) in ["claudeCode", "codex"].into_iter().enumerate() { + connection + .execute( + "INSERT INTO sessions (id, agent_id, name, harness, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + format!("00000000-0000-4000-8000-{index:012}"), + PREVIEW_AGENT_ID, + format!("session-{harness}"), + harness, + 1_700_000_000_i64, + ], + ) + .expect("version 2 Session"); + } + drop(connection); + + let database = persistence::Database::open(&path).expect("migrate version 2 database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let sessions = database.list_agent_sessions("worker").await.expect("Sessions"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].harness, agent::Harness::ClaudeCode); + assert_eq!(sessions[0].model_selection.model_str(), Some("fable")); + assert_eq!(sessions[0].model_selection.effort_str(), None); + assert_eq!(sessions[1].harness, agent::Harness::Codex); + assert!(sessions[1].model_selection.is_empty()); + }); + drop(database); + assert_eq!(schema_snapshot(&path).0, 5); + assert!( + directory.path().join("backups").is_dir(), + "a pending migration is backed up first" + ); +} + +#[test] +fn expanded_version_1_schema_is_adopted_without_losing_state() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("create expanded version 1 database"); + connection + .execute_batch(EXPANDED_VERSION_1_SCHEMA) + .expect("expanded version 1 schema"); + let desired = serde_json::to_string(&support::agent("worker")).expect("expanded desired state"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'worker', ?2, ?3, NULL, '{}')", + rusqlite::params![ + EXPANDED_AGENT_ID, + serde_json::to_string(Path::new("/expanded/source")).expect("source"), + desired, + ], + ) + .expect("expanded Agent"); + drop(connection); + let before = schema_snapshot(&path).1; + + let database = persistence::Database::open(&path).expect("adopt expanded version 1 database"); + LocalRuntime::new().expect("local runtime").block_on(async { + database + .get(EXPANDED_AGENT_ID.parse().expect("expanded Agent ID")) + .await + .expect("preserved Agent"); + }); + drop(database); + + let after = schema_snapshot(&path); + assert_eq!(after.0, 5); + let unchanged = |snapshot: &[(String, String)]| { + snapshot + .iter() + .filter(|(name, _)| name != "table:sessions") + .cloned() + .collect::>() + }; + assert_eq!(unchanged(&after.1), unchanged(&before)); + let sessions_sql = |snapshot: &[(String, String)]| { + snapshot + .iter() + .find(|(name, _)| name == "table:sessions") + .map(|(_, sql)| sql.clone()) + .expect("sessions table") + }; + let (before_sessions, after_sessions) = (sessions_sql(&before), sessions_sql(&after.1)); + assert!(!before_sessions.contains("model TEXT")); + assert!( + after_sessions.contains("model TEXT") && after_sessions.contains("effort TEXT"), + "only the Session selection columns are added: {after_sessions}" + ); +} + +#[test] +fn partial_version_1_schema_is_rejected_without_mutation() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("partial database"); + connection + .execute_batch("CREATE TABLE agents (id TEXT PRIMARY KEY NOT NULL); PRAGMA user_version = 1;") + .expect("partial version 1 schema"); + let before = schema_snapshot(&path); + + let Err(error) = persistence::Database::open(&path) else { + panic!("partial schema should be rejected"); + }; + assert!(error.to_string().contains("not a recognized released schema")); + assert_eq!(schema_snapshot(&path), before, "rejection must not mutate the schema"); +} + +#[test] +fn future_schema_is_rejected_without_mutation() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("future database"); + connection + .pragma_update(None, "user_version", 99) + .expect("future version"); + drop(connection); + let before = schema_snapshot(&path); + let Err(error) = persistence::Database::open(&path) else { + panic!("future schema should be rejected"); + }; + assert!(error.to_string().contains("newer than the supported schema")); + assert_eq!( + rusqlite::Connection::open(&path) + .expect("inspect future database") + .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) + .expect("future version"), + 99 + ); + assert_eq!(schema_snapshot(&path), before); +} + +#[test] +fn failed_preview_1_row_migration_rolls_back_schema_and_rows() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + create_preview_1_database(&path); + let connection = rusqlite::Connection::open(&path).expect("corrupt preview fixture"); + connection + .execute( + "UPDATE agents SET desired_json = 'not-json' WHERE id = ?1", + [PREVIEW_DELETED_AGENT_ID], + ) + .expect("corrupt later Agent row"); + drop(connection); + let before = schema_snapshot(&path); + let valid_desired = connection_value(&path, PREVIEW_AGENT_ID, "desired_json"); + + let Err(error) = persistence::Database::open(&path) else { + panic!("malformed desired state should fail migration"); + }; + assert!(error.to_string().contains("invalid desired state during migration")); + assert_eq!(schema_snapshot(&path), before); + assert_eq!(connection_value(&path, PREVIEW_AGENT_ID, "desired_json"), valid_desired); + let connection = rusqlite::Connection::open(path).expect("inspect rollback"); + assert_eq!( + connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) + .expect("rolled-back version"), + 1 + ); +} + +/// Preview Sessions never chose a model, but every Claude Code Session launched on +/// the `fable` alias the adapter hardcoded; the migration records that so they stay +/// on the same model, while Codex Sessions keep their harness default. +fn assert_migrated_session_selections(connection: &rusqlite::Connection, claude_code: u32, codex: u32) { + let count = |filter: &str| { + connection + .query_row(&format!("SELECT COUNT(*) FROM sessions WHERE {filter}"), [], |row| { + row.get::<_, u32>(0) + }) + .expect("migrated Sessions") + }; + assert_eq!( + count("harness = 'claudeCode' AND model = 'fable' AND effort IS NULL"), + claude_code + ); + assert_eq!(count("harness = 'codex' AND model IS NULL AND effort IS NULL"), codex); +} + +fn schema_snapshot(path: &Path) -> (u32, Vec<(String, String)>) { + let connection = rusqlite::Connection::open(path).expect("snapshot database"); + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .expect("snapshot version"); + let mut statement = connection + .prepare( + "SELECT type || ':' || name, coalesce(sql, '') FROM sqlite_schema \ + WHERE name NOT LIKE 'sqlite_%' ORDER BY 1", + ) + .expect("snapshot query"); + let schema = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .expect("snapshot rows") + .collect::>() + .expect("snapshot values"); + (version, schema) +} + +fn connection_value(path: &Path, id: &str, column: &str) -> String { + let connection = rusqlite::Connection::open(path).expect("read fixture value"); + connection + .query_row(&format!("SELECT {column} FROM agents WHERE id = ?1"), [id], |row| { + row.get(0) + }) + .expect("fixture value") +} + +#[test] +fn secret_material_is_persistent_and_replaced_by_name() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let first = persistence::Database::open(&path).expect("open first database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + first.set("claude-access", b"first").await.expect("store secret"); + }); + drop(first); + + let second = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let reference = second.set("claude-access", b"second").await.expect("replace secret"); + let material = second.resolve(&reference).await.expect("resolve secret"); + assert_eq!(material.expose(), b"second"); + }); +} + +#[test] +fn finalizing_an_agent_removes_only_its_scoped_secret_material() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let id = test_agent_id(); + store.put(record("worker", 1), 0).await.expect("Agent"); + let agent_secret = store + .set(&format!("agent/{id}/github-token"), b"github-secret") + .await + .expect("Agent secret"); + let provider_secret = store + .set("claude-access-token", b"claude-secret") + .await + .expect("provider secret"); + + store.mark_deleting("worker").await.expect("mark deleting"); + store.finalize_deletion(id, 1).await.expect("finalize deletion"); + + assert!(store.resolve(&agent_secret).await.is_err()); + assert_eq!( + store + .resolve(&provider_secret) + .await + .expect("provider secret remains") + .expose(), + b"claude-secret" + ); + }); +} + +#[test] +fn agent_records_survive_reopen_and_preserve_compare_and_swap() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let first = persistence::Database::open(&path).expect("open first database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + first + .put(record("worker", 1), 0) + .await + .expect("insert first generation"); + let error = first + .put(record("worker", 2), 0) + .await + .expect_err("duplicate create should conflict"); + assert!(matches!(error, Error::Conflict)); + }); + drop(first); + + let second = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + assert_eq!( + second.get(test_agent_id()).await.expect("persistent record"), + record("worker", 1) + ); + second.put(record("worker", 2), 1).await.expect("compare and swap"); + let error = second + .update_status(test_agent_id(), 1, Status::default()) + .await + .expect_err("stale status should conflict"); + assert!(matches!(error, Error::Conflict)); + }); +} + +#[test] +fn desired_state_cannot_change_after_deletion_starts() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("database"); + LocalRuntime::new().expect("local runtime").block_on(async { + store.put(record("worker", 1), 0).await.expect("Agent"); + let mut stale = store.get_by_name("worker").await.expect("stale desired state"); + store.mark_deleting("worker").await.expect("mark deleting"); + + stale.agent.metadata.generation = 2; + let error = store + .put(stale, 1) + .await + .expect_err("deleting Agent must reject desired-state updates"); + assert!(matches!(error, Error::Conflict)); + assert!( + store + .get_by_name("worker") + .await + .expect("deleting Agent") + .agent + .metadata + .deletion_timestamp + .is_some() + ); + }); +} + +#[tokio::test(flavor = "local")] +async fn initial_prompt_consumption_and_launch_record_commit_together() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("agent.db"); + let database = persistence::Database::open(&path).expect("database"); + database + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession { + initial_prompt: Some("once".into()), + ..NewSession::for_harness(agent::Harness::ClaudeCode) + }, + ) + .await + .expect("Session"); + let token: agent::sessions::LaunchToken = "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("token"); + let launch = agent::sessions::LaunchRecord { + token: token.clone(), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }; + let inspect = rusqlite::Connection::open(&path).expect("inspect"); + inspect.execute_batch("CREATE TRIGGER reject_consumption AFTER UPDATE OF initial_prompt ON sessions WHEN OLD.initial_prompt IS NOT NULL AND NEW.initial_prompt IS NULL BEGIN SELECT RAISE(ABORT, 'injected consumption failure'); END;").expect("inject failure"); + database + .record_session_launch(session.id, launch.clone()) + .await + .expect_err("consumption failure"); + assert_eq!( + database.session_launch_state(session.id).await.expect("state"), + None, + "failed consumption rolls back launch bookkeeping" + ); + inspect + .execute_batch("DROP TRIGGER reject_consumption;") + .expect("remove failure"); + assert_eq!( + database + .record_session_launch(session.id, launch.clone()) + .await + .expect("consume"), + Some("once".into()), + "failed transaction did not consume the prompt" + ); + drop(database); + let reopened = persistence::Database::open(&path).expect("reopen"); + assert_eq!( + reopened + .record_session_launch(session.id, launch) + .await + .expect("relaunch"), + None, + "recovery after a crash never replays the prompt" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_session_records_when_it_entered_its_state() { + use agent::sessions::{ActivityEvent, LaunchRecord, State}; + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + database + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let failed = Lifecycle::failed("harness exited"); + database + .update_session_lifecycle(session.id, failed.clone(), 0) + .await + .expect("failed"); + let entered = database + .get_session(session.id) + .await + .expect("Session") + .status + .state_since; + assert!(entered.is_some(), "a lifecycle change is stamped"); + database + .update_session_lifecycle(session.id, failed, 0) + .await + .expect("still failed"); + assert_eq!( + database + .get_session(session.id) + .await + .expect("Session") + .status + .state_since, + entered, + "the same lifecycle state keeps its time" + ); + + database + .update_session_lifecycle(session.id, Lifecycle::running(), 0) + .await + .expect("running"); + let token: agent::sessions::LaunchToken = "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("token"); + database + .record_session_launch( + session.id, + LaunchRecord { + token: token.clone(), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("launch"); + let at = |seconds| time::OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + database + .record_session_start_for_launch(session.id, &token, uuid::Uuid::new_v4(), "native", None, at(100)) + .await + .expect("start"); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + ActivityEvent::TurnCompleted, + at(110), + ) + .await + .expect("turn"); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + ActivityEvent::WaitingForInput, + at(170), + ) + .await + .expect("idle notification"); + let status = database.get_session(session.id).await.expect("Session").status; + assert_eq!(status.state, State::WaitingForInput); + assert_eq!( + status.state_since, + Some(at(110)), + "waiting since the turn ended, not since the notification" + ); +} + +#[tokio::test(flavor = "local")] +async fn activity_deduplication_is_durable_and_rolls_back_with_the_fold() { + use agent::sessions::{ActivityEvent, LaunchRecord}; + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("agent.db"); + let database = persistence::Database::open(&path).expect("database"); + database + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let token: agent::sessions::LaunchToken = "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("token"); + database + .record_session_launch( + session.id, + LaunchRecord { + token: token.clone(), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("launch"); + let event_id = uuid::Uuid::new_v4(); + let at = time::OffsetDateTime::now_utc(); + let inspect = rusqlite::Connection::open(&path).expect("inspect"); + inspect.execute_batch("CREATE TRIGGER reject_activity BEFORE UPDATE OF activity_json ON sessions BEGIN SELECT RAISE(ABORT, 'injected fold failure'); END;").expect("inject failure"); + database + .apply_session_activity_for_launch(session.id, &token, event_id, ActivityEvent::TurnCompleted, at) + .await + .expect_err("fold failure"); + inspect + .execute_batch("DROP TRIGGER reject_activity;") + .expect("remove failure"); + let activity = database + .apply_session_activity_for_launch(session.id, &token, event_id, ActivityEvent::TurnCompleted, at) + .await + .expect("retry") + .expect("applied"); + assert_eq!(activity.turns, 1, "rolled-back receipt must not suppress the retry"); + drop(database); + let reopened = persistence::Database::open(&path).expect("reopen"); + assert_eq!( + reopened + .apply_session_activity_for_launch(session.id, &token, event_id, ActivityEvent::TurnCompleted, at) + .await + .expect("lost response retry"), + None + ); + assert_eq!( + reopened + .get_session(session.id) + .await + .expect("Session") + .status + .reported + .activity, + activity, + "duplicate does not change count, phase, or timestamp" + ); +} + +#[test] +fn a_deleted_session_is_marked_before_it_is_removed_and_frees_its_name() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("open database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let name = SessionName::new("s1").expect("session name"); + let created = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect("create Session"); + + let marked = store + .mark_session_deleting("worker", &name) + .await + .expect("mark deleting"); + assert_eq!(marked.id, created.id); + let timestamp = marked.deletion_timestamp.expect("deletion timestamp"); + assert_eq!( + store + .mark_session_deleting("worker", &name) + .await + .expect("repeated deletion is safe") + .deletion_timestamp, + Some(timestamp), + "the first request owns the deletion timestamp" + ); + + // Reconciliation still sees the Session it has to release, so a daemon + // restart between the request and the release cannot strand a harness. + assert!( + store + .get_session(created.id) + .await + .expect("marked Session") + .is_deleting() + ); + assert!( + store + .list_all_sessions() + .await + .expect("sessions") + .iter() + .any(|session| session.id == created.id) + ); + // The name stays taken until the harness is gone. + let reused = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect_err("the name is still taken"); + assert!(reused.to_string().contains("is being deleted"), "{reused}"); + + store + .finalize_session_deletion(created.id) + .await + .expect("finalize deletion"); + assert!(matches!(store.get_session(created.id).await, Err(Error::NotFound))); + assert!(matches!( + store.finalize_session_deletion(created.id).await, + Err(Error::Conflict) + )); + assert!(matches!( + store.mark_session_deleting("worker", &name).await, + Err(Error::NotFound) + )); + + let replacement = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect("the freed name is available again"); + assert_ne!(replacement.id, created.id); + }); + drop(store); + + let connection = rusqlite::Connection::open(path).expect("inspect database"); + assert_eq!( + connection + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get::<_, i64>(0)) + .expect("session count"), + 1, + "the released Session leaves no row behind" + ); +} + +#[test] +fn ssh_host_keys_are_stored_per_incarnation_and_removed_with_it() { + use agent::ssh::HostKeyStore as _; + + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let id = test_agent_id(); + store.put(record("worker", 1), 0).await.expect("Agent"); + assert!(store.load_host_key(id).await.expect("load").is_none()); + store + .store_host_key(id, zeroize::Zeroizing::new(b"host-key".to_vec())) + .await + .expect("store"); + assert_eq!( + store.load_host_key(id).await.expect("load").expect("stored").as_slice(), + b"host-key" + ); + // Manifest secrets of the same Agent live under another prefix. + let agent_secret = store + .set(&format!("agent/{id}/github-token"), b"github-secret") + .await + .expect("Agent secret"); + store.mark_deleting("worker").await.expect("mark deleting"); + store.finalize_deletion(id, 1).await.expect("finalize deletion"); + assert!(store.load_host_key(id).await.expect("load").is_none()); + assert!(store.resolve(&agent_secret).await.is_err()); + + store + .store_host_key(id, zeroize::Zeroizing::new(b"again".to_vec())) + .await + .expect("store"); + store.delete_host_key(id).await.expect("delete"); + store.delete_host_key(id).await.expect("deleting twice is fine"); + assert!(store.load_host_key(id).await.expect("load").is_none()); + }); +} + +#[test] +fn archiving_a_session_is_recorded_once_and_survives_reopening() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("open database owner"); + let name = SessionName::new("s1").expect("session name"); + let (id, archived_at) = LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let created = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect("create Session"); + assert!(!created.is_archived()); + + let archived = store + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + let archived_at = archived.archived_at.expect("archive time"); + assert_eq!( + store + .set_session_archived("worker", &name, true) + .await + .expect("archiving again is safe") + .archived_at, + Some(archived_at), + "the first request owns the archive time" + ); + (created.id, archived_at) + }); + drop(store); + + let store = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let session = store.get_session(id).await.expect("archived Session"); + assert_eq!(session.archived_at, Some(archived_at)); + let unarchived = store + .set_session_archived("worker", &name, false) + .await + .expect("unarchive"); + assert!(!unarchived.is_archived()); + + store + .mark_session_deleting("worker", &name) + .await + .expect("mark deleting"); + assert!(matches!( + store.set_session_archived("worker", &name, true).await, + Err(Error::NotFound) + )); + }); +} diff --git a/agentctl/tests/fixtures/claude-code-transcript.jsonl b/agentctl/tests/fixtures/claude-code-transcript.jsonl new file mode 100644 index 0000000..7778991 --- /dev/null +++ b/agentctl/tests/fixtures/claude-code-transcript.jsonl @@ -0,0 +1,23 @@ +{"type":"queue-operation","operation":"enqueue","timestamp":"2026-09-09T08:00:00.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001"} +{"type":"user","isSidechain":false,"uuid":"u1","parentUuid":null,"timestamp":"2026-09-09T08:00:01.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","cwd":"/home/agent/code","version":"2.1.266","userType":"external","message":{"role":"user","content":"Rename the helper and run the tests."}} +{"type":"assistant","isSidechain":false,"uuid":"a1","parentUuid":"u1","timestamp":"2026-09-09T08:00:03.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_1","apiBlockIndex":0,"message":{"id":"msg_01","role":"assistant","model":"fable","content":[{"type":"text","text":"Renaming the helper first."}]}} +{"type":"assistant","isSidechain":false,"uuid":"a2","parentUuid":"a1","timestamp":"2026-09-09T08:00:03.100Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_1","apiBlockIndex":1,"message":{"id":"msg_01","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_01","name":"Edit","input":{"file_path":"src/lib.rs"}}]}} +{"type":"user","isSidechain":false,"uuid":"u2","parentUuid":"a2","timestamp":"2026-09-09T08:00:04.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","toolUseResult":{},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","is_error":false,"content":"edited"}]}} +{"type":"assistant","isSidechain":false,"uuid":"a3","parentUuid":"u2","timestamp":"2026-09-09T08:00:05.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_2","apiBlockIndex":0,"message":{"id":"msg_02","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{"command":"cargo test"}}]}} +{"type":"user","isSidechain":false,"uuid":"u3","parentUuid":"a3","timestamp":"2026-09-09T08:00:09.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","toolUseResult":{},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","is_error":true,"content":"error[E0425]: cannot find function `helper`"}]}} +{"type":"assistant","isSidechain":true,"uuid":"s1","parentUuid":null,"timestamp":"2026-09-09T08:00:09.500Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"id":"msg_side","role":"assistant","content":[{"type":"text","text":"subagent chatter"}]}} +{"type":"assistant","isSidechain":false,"uuid":"a4","parentUuid":"u3","timestamp":"2026-09-09T08:00:10.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_3","apiBlockIndex":0,"message":{"id":"msg_03","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_03","name":"Bash","input":{"command":"cargo test"}}]}} +{"type":"user","isSidechain":false,"uuid":"u4","parentUuid":"a4","timestamp":"2026-09-09T08:00:14.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","toolUseResult":{},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_03","is_error":false,"content":"test result: ok"}]}} +{"type":"assistant","isSidechain":false,"uuid":"a5","parentUuid":"u4","timestamp":"2026-09-09T08:00:15.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_4","apiBlockIndex":0,"message":{"id":"msg_04","role":"assistant","model":"fable","content":[{"type":"text","text":"Renamed and the tests pass."}]}} +{"type":"user","isSidechain":false,"uuid":"u5","parentUuid":"a5","timestamp":"2026-09-09T08:00:16.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"\nb554bjc3w\ncompleted\n"}} +{"type":"user","isSidechain":false,"isMeta":true,"uuid":"u6","parentUuid":"u5","timestamp":"2026-09-09T08:00:17.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands."}} +{"type":"user","isSidechain":false,"uuid":"u7","parentUuid":"u6","timestamp":"2026-09-09T08:00:17.500Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"/cost\ncost\n"}} +{"type":"user","isSidechain":false,"uuid":"u8","parentUuid":"u7","timestamp":"2026-09-09T08:00:18.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"Total cost: $0.42"}} +{"type":"user","isSidechain":false,"uuid":"u9","parentUuid":"u8","timestamp":"2026-09-09T08:00:30.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"Now commit it."}} +{"type":"assistant","isSidechain":false,"uuid":"a6","parentUuid":"u9","timestamp":"2026-09-09T08:00:31.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_5","apiBlockIndex":0,"message":{"id":"msg_05","role":"assistant","model":"fable","content":[{"type":"text","text":"Committing."}]}} +{"type":"assistant","isSidechain":false,"uuid":"a7","parentUuid":"a6","timestamp":"2026-09-09T08:00:31.100Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_5","apiBlockIndex":1,"message":{"id":"msg_05","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_04","name":"Bash","input":{"command":"git commit -am rename"}}]}} +{"type":"queue-operation","operation":"enqueue","timestamp":"2026-09-09T08:00:32.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","content":"Use a conventional commit message."} +{"type":"queue-operation","operation":"remove","timestamp":"2026-09-09T08:00:33.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","content":"Use a conventional commit message.","reason":"absorbed_mid_turn"} +{"type":"attachment","isSidechain":false,"uuid":"at1","parentUuid":"a7","timestamp":"2026-09-09T08:00:33.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","attachment":{"type":"queued_command","prompt":"Use a conventional commit message.","source_uuid":"q1","commandMode":"prompt","origin":{"kind":"human"},"timestamp":"2026-09-09T08:00:32.000Z"},"rendered":[{"content":"\nThe user sent a new message while you were working:\nUse a conventional commit message.\n"}]} +{"type":"attachment","isSidechain":false,"uuid":"at2","parentUuid":"at1","timestamp":"2026-09-09T08:00:33.100Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","attachment":{"type":"queued_command","prompt":"\nx\n","source_uuid":"q2","commandMode":"prompt","origin":{"kind":"system"},"timestamp":"2026-09-09T08:00:33.000Z"},"rendered":[]} +{"type":"attachment","isSidechain":false,"uuid":"at3","parentUuid":"at2","timestamp":"2026-09-09T08:00:33.200Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","attachment":{"type":"total_tokens_reminder","text":"..."},"rendered":[]} diff --git a/agentctl/tests/fixtures/codex-rollout.jsonl b/agentctl/tests/fixtures/codex-rollout.jsonl new file mode 100644 index 0000000..3b66f2d --- /dev/null +++ b/agentctl/tests/fixtures/codex-rollout.jsonl @@ -0,0 +1,22 @@ +{"timestamp":"2026-09-09T08:00:00.000Z","type":"session_meta","payload":{"id":"019fb826-b440-7432-8531-ac6be65198ec","timestamp":"2026-09-09T08:00:00.000Z","cwd":"/home/agent/code","originator":"codex_cli_rs","cli_version":"0.153.4","source":"cli"}} +{"timestamp":"2026-09-09T08:00:01.000Z","ordinal":1,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1","model_context_window":258400,"collaboration_mode_kind":"default"}} +{"timestamp":"2026-09-09T08:00:01.001Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"\nFilesystem sandboxing is disabled.\n"}]}} +{"timestamp":"2026-09-09T08:00:01.002Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions\n\n\n# AGENTS.md\n\nDefault workflow for coding.\n"}]}} +{"timestamp":"2026-09-09T08:00:01.003Z","type":"turn_context","payload":{"turn_id":"turn-1","cwd":"/home/agent/code","approval_policy":"never","model":"gpt-5.5-codex","effort":"medium","summary":"auto"}} +{"timestamp":"2026-09-09T08:00:01.004Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Measure the desk and draw it."}]}} +{"timestamp":"2026-09-09T08:00:03.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"**Inspecting the workspace**"}],"content":null,"encrypted_content":"gAAAA"}} +{"timestamp":"2026-09-09T08:00:03.500Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I'll inspect the photo and the existing files first."}],"phase":"commentary"}} +{"timestamp":"2026-09-09T08:00:04.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_1","name":"exec_command","input":"ls"}} +{"timestamp":"2026-09-09T08:00:04.500Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_1","output":"Chunk ID: 123abc\nWall time: 0.1000 seconds\nProcess exited with code 0\nOutput:\nindex.html"}} +{"timestamp":"2026-09-09T08:00:05.000Z","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":null}} +{"timestamp":"2026-09-09T08:00:06.000Z","type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"command\":[\"python3\",\"measure.py\"]}","call_id":"call_2"}} +{"timestamp":"2026-09-09T08:00:07.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_2","output":"Exit code: 2\nWall time: 0.1 seconds\nOutput:\npython3: can't open file 'measure.py'"}} +{"timestamp":"2026-09-09T08:00:09.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Drawn to scale."}],"phase":"final_answer"}} +{"timestamp":"2026-09-09T08:00:09.500Z","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":null}} +{"timestamp":"2026-09-09T08:00:10.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","last_agent_message":"Drawn to scale."}} +{"timestamp":"2026-09-09T08:05:00.000Z","ordinal":2,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn-2","model_context_window":258400,"collaboration_mode_kind":"default"}} +{"timestamp":"2026-09-09T08:05:00.001Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n /home/agent/code\n"}]}} +{"timestamp":"2026-09-09T08:05:00.002Z","type":"turn_context","payload":{"turn_id":"turn-2","cwd":"/home/agent/code","approval_policy":"never","model":"gpt-5.5-codex","effort":"medium","summary":"auto"}} +{"timestamp":"2026-09-09T08:05:00.003Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Make the top bar slimmer."}]}} +{"timestamp":"2026-09-09T08:05:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Adjusting the bar height."}],"phase":"commentary"}} +{"timestamp":"2026-09-09T08:05:03.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"in_progress","call_id":"call_3","name":"exec_command","input":"sed -i s/40px/24px/ index.html"}} diff --git a/agentctl/tests/home.rs b/agentctl/tests/home.rs new file mode 100644 index 0000000..db5e130 --- /dev/null +++ b/agentctl/tests/home.rs @@ -0,0 +1,55 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::Path; + +use agent::local::home::ControlPlaneHome; + +use support::TempDirectory; + +#[test] +fn configured_home_is_absolute_and_uses_fixed_socket_path() { + let temporary = TempDirectory::new("home-path"); + let relative = temporary.path().file_name().expect("temporary name"); + let home = ControlPlaneHome::resolve(Some(Path::new(relative))).expect("home should resolve"); + + assert!(home.path().is_absolute()); + assert_eq!(home.socket_path().file_name(), Some("agentd.sock".as_ref())); +} + +#[test] +fn only_one_daemon_can_lock_a_home() { + let temporary = TempDirectory::new("home-lock"); + let home = ControlPlaneHome::resolve(Some(temporary.path())).expect("home should resolve"); + let first = home.acquire_lock().expect("first lock should succeed"); + + let second = home.acquire_lock().expect_err("second lock should fail"); + assert!(second.to_string().contains("already locked")); + + drop(first); + home.acquire_lock().expect("lock should be released when dropped"); +} + +#[cfg(unix)] +#[test] +fn home_and_lock_file_are_private() { + use std::os::unix::fs::PermissionsExt; + + let temporary = TempDirectory::new("home-mode"); + let home = ControlPlaneHome::resolve(Some(temporary.path())).expect("home should resolve"); + let _lock = home.acquire_lock().expect("lock should succeed"); + + let directory_mode = std::fs::metadata(home.path()) + .expect("home metadata") + .permissions() + .mode() + & 0o777; + let file_mode = std::fs::metadata(home.path().join("agentd.lock")) + .expect("lock metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(file_mode, 0o600); +} diff --git a/agentctl/tests/manifest.rs b/agentctl/tests/manifest.rs new file mode 100644 index 0000000..afff1e3 --- /dev/null +++ b/agentctl/tests/manifest.rs @@ -0,0 +1,845 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::PathBuf; + +use agent::{API_VERSION, EnvironmentSpec, Harness, KIND, SecretSpec, manifest}; +use sandbox::RootFilesystemMode; + +#[test] +fn decodes_sandbox_mount_primitives() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: example.invalid/agent:latest + platform: + os: linux + resources: + cpu: "2" + memory: "1Gi" + rootFilesystem: + capacity: "4Gi" + mode: layered + mounts: + - type: bind + source: ../.. + target: /home/agent/code/altinn-studio + readOnly: false + - type: tmpfs + target: /tmp + capacity: "1Gi" + home: + source: home + harnesses: + - type: claudeCode + version: "2.1.266" + auth: mediated + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest with Sandbox Mounts should decode"); + let value = serde_json::to_value(agent).expect("Agent JSON"); + + assert_eq!(value["spec"]["sandbox"]["platform"]["os"], "linux"); + assert_eq!(value["spec"]["sandbox"]["mounts"][0]["source"], "../.."); + assert_eq!(value["spec"]["sandbox"]["mounts"][1]["capacity"], "1Gi"); +} + +#[test] +fn decodes_a_harness_without_a_declared_version() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: example.invalid/agent:latest + platform: + os: linux + resources: + cpu: "2" + memory: "1Gi" + rootFilesystem: + capacity: "4Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest without a harness version should decode"); + assert_eq!(agent.spec.harnesses[0].version, None); + + let value = serde_json::to_value(agent).expect("Agent JSON"); + assert!(value["spec"]["harnesses"][0].get("version").is_none()); +} + +#[test] +fn decodes_the_minimal_manifest() { + let bytes = include_bytes!("../examples/minimal/agent.yaml"); + let agent = manifest::decode(bytes).expect("minimal manifest should decode"); + + assert_eq!(agent.api_version, API_VERSION); + assert_eq!(agent.kind, KIND); + assert_eq!(agent.metadata.name, "altinn-studio"); + assert_eq!(agent.spec.sandbox.platform.os, "linux"); + assert_eq!(agent.spec.sandbox.platform.architecture, None); + assert_eq!(agent.spec.sandbox.retention_policy, None); + assert_eq!(agent.spec.harnesses.len(), 1); + assert!(!agent.spec.harnesses[0].default); + assert_eq!( + agent.spec.default_harness().map(|harness| harness.kind), + Some(Harness::ClaudeCode) + ); + assert_eq!( + agent.spec.sandbox.resources.root_filesystem().mode(), + RootFilesystemMode::Layered + ); +} + +#[test] +fn a_manifest_may_set_the_run_state_and_omits_it_by_default() { + let minimal = include_str!("../examples/minimal/agent.yaml"); + let agent = manifest::decode(minimal.as_bytes()).expect("minimal manifest should decode"); + assert_eq!(agent.spec.run_state, None); + assert!(!agent.spec.is_stopped()); + + let stopped = minimal.replacen("spec:\n", "spec:\n runState: Stopped\n", 1); + let agent = manifest::decode(stopped.as_bytes()).expect("a Stopped run state should decode"); + assert_eq!(agent.spec.run_state, Some(agent::RunState::Stopped)); + let encoded = serde_yaml_ng::to_string(&agent).expect("encode"); + assert!(encoded.contains("runState: Stopped"), "{encoded}"); + + let paused = minimal.replacen("spec:\n", "spec:\n runState: Paused\n", 1); + manifest::decode(paused.as_bytes()).expect_err("only Running and Stopped exist"); +} + +/// The image owns the harness version, so a manifest that repeats it only creates a second place +/// to forget. The examples are what people copy, so none of them may pin one. +#[test] +fn no_example_manifest_pins_a_harness_version() { + let examples = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"); + let mut checked = 0; + for example in std::fs::read_dir(&examples).expect("examples directory") { + let directory = example.expect("examples entry").path(); + if !directory.is_dir() { + continue; + } + for manifest in std::fs::read_dir(&directory).expect("example directory") { + let path = manifest.expect("example entry").path(); + if !path.is_file() { + continue; + } + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + let is_manifest = name.starts_with("agent") + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("yaml")); + if !is_manifest { + continue; + } + let agent = manifest::resolve(&path) + .unwrap_or_else(|error| panic!("{} should resolve: {error}", path.display())) + .agent; + for harness in &agent.spec.harnesses { + assert_eq!( + harness.version, + None, + "{} pins a version for {:?}; the image owns it", + path.display(), + harness.kind + ); + } + checked += 1; + } + } + assert!(checked > 0, "no example manifests were checked"); +} + +#[test] +fn decodes_the_self_development_manifest() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/self-dev/agent.worktree.yaml"); + let agent = manifest::resolve(&path) + .expect("self-development manifest should resolve") + .agent; + + assert_eq!(agent.metadata.name, "agent-dev-worktree"); + assert_eq!(agent.spec.sandbox.platform.architecture, None); + assert_eq!(agent.spec.secrets.len(), 1); + assert_eq!(agent.spec.secrets[0].environment, "GITHUB_TOKEN"); + assert_eq!(agent.spec.secrets[0].source(), "GITHUB_TOKEN"); + assert_eq!(agent.spec.environment.len(), 2); + assert_eq!(agent.spec.environment[0].name, "GIT_USER_NAME"); + assert_eq!(agent.spec.environment[0].source(), "GIT_USER_NAME"); + assert_eq!( + agent.spec.secrets[0].placeholder.as_deref(), + Some("github_pat_AGENT_MEDIATED_GITHUB_TOKEN") + ); + assert!( + agent.spec.secrets[0] + .allowed_hosts + .iter() + .any(|host| host == "uploads.github.com") + ); + assert_eq!(agent.spec.skills.len(), 1); + assert_eq!(agent.spec.skills[0].name(), Some("pr-evidence")); + assert_eq!(agent.spec.harnesses.len(), 2); + assert!(agent.spec.harnesses[0].default); + assert_eq!(agent.spec.harnesses[0].kind, Harness::ClaudeCode); + assert_eq!(agent.spec.harnesses[0].version, None); + assert_eq!(agent.spec.harnesses[1].kind, Harness::Codex); + assert!(!agent.spec.harnesses[1].default); + assert_eq!( + agent.spec.sandbox.resources.root_filesystem().mode(), + RootFilesystemMode::Direct + ); +} + +#[test] +fn published_manifests_explicitly_select_git_identity() { + let manifests = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../agents"); + if !manifests.is_dir() { + eprintln!("skipping published manifests omitted from this sparse checkout"); + return; + } + + for path in [ + manifests.join("minimal/agent.yaml"), + manifests.join("full/agent.yaml"), + manifests.join("full/agent.nested.yaml"), + manifests.join("full/agent.nested-build.yaml"), + manifests.join("full/agent.worktree.yaml"), + manifests.join("desktop/agent.yaml"), + manifests.join("desktop/agent.nested.yaml"), + manifests.join("desktop/agent.nested-build.yaml"), + manifests.join("desktop/agent.worktree.yaml"), + ] { + let agent = manifest::resolve(&path) + .expect("published Agent manifest should resolve") + .agent; + let names = agent + .spec + .environment + .iter() + .map(|variable| variable.name.as_str()) + .collect::>(); + assert_eq!(names, ["GIT_USER_NAME", "GIT_USER_EMAIL"]); + } +} + +#[test] +fn decodes_explicit_non_secret_environment_with_an_optional_source() { + let mut agent = support::agent("worker"); + agent.spec.environment = vec![ + EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: Some("HOST_GIT_NAME".into()), + }, + EnvironmentSpec { + name: "GIT_USER_EMAIL".into(), + source: None, + }, + ]; + + let encoded = serde_yaml_ng::to_string(&agent).expect("encoded manifest"); + let decoded = manifest::decode(encoded.as_bytes()).expect("manifest environment"); + + assert_eq!(decoded.spec.environment[0].name, "GIT_USER_NAME"); + assert_eq!(decoded.spec.environment[0].source(), "HOST_GIT_NAME"); + assert_eq!(decoded.spec.environment[1].source(), "GIT_USER_EMAIL"); +} + +#[test] +fn optional_secret_round_trips_without_changing_the_required_default() { + let mut agent = support::agent("worker"); + agent.spec.secrets.push(SecretSpec { + environment: "OPTIONAL_TOKEN".into(), + optional: true, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: None, + }); + + let encoded = serde_yaml_ng::to_string(&agent).expect("encoded manifest"); + let decoded = manifest::decode(encoded.as_bytes()).expect("manifest with optional secret"); + assert!(decoded.spec.secrets[0].optional); + assert!(encoded.contains("optional: true")); + + agent.spec.secrets[0].optional = false; + let required = serde_yaml_ng::to_string(&agent).expect("encoded required secret"); + assert!(!required.contains("optional:")); +} + +#[test] +fn rejects_invalid_duplicate_and_unpaired_environment_names() { + let mut invalid = support::agent("worker"); + invalid.spec.environment.push(EnvironmentSpec { + name: "NOT-PORTABLE".into(), + source: None, + }); + assert!(matches!( + invalid.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.environment[0]") + )); + + let mut duplicate = support::agent("worker"); + duplicate.spec.environment = vec![ + EnvironmentSpec { + name: "EDITOR".into(), + source: None, + }, + EnvironmentSpec { + name: "EDITOR".into(), + source: Some("HOST_EDITOR".into()), + }, + ]; + assert!(matches!( + duplicate.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.environment[1]") + )); + + let mut unpaired = support::agent("worker"); + unpaired.spec.environment.push(EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: None, + }); + assert!(matches!( + unpaired.validate(), + Err(agent::Error::Invalid(message)) if message.contains("GIT_USER_NAME and GIT_USER_EMAIL") + )); +} + +#[test] +fn rejects_environment_collisions_with_secrets_and_harness_owned_values() { + let mut secret_collision = support::agent("worker"); + secret_collision.spec.environment.push(EnvironmentSpec { + name: "PLAIN_VALUE".into(), + source: Some("SHARED_VALUE".into()), + }); + secret_collision.spec.secrets.push(SecretSpec { + environment: "API_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: Some("SHARED_VALUE".into()), + }); + assert!(matches!( + secret_collision.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.secrets[0]") + )); + + let mut harness_collision = support::agent("worker"); + harness_collision.spec.environment.push(EnvironmentSpec { + name: "CLAUDE_CONFIG_DIR".into(), + source: None, + }); + assert!(matches!( + harness_collision.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.environment[0]") + )); +} + +#[test] +fn self_development_mounts_the_host_checkout_instead_of_cloning() { + let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/self-dev"); + let agent = manifest::resolve(&directory.join("agent.worktree.yaml")) + .expect("self-development worktree variant should resolve") + .agent; + let dockerfile = include_str!("../examples/self-dev/Dockerfile"); + + let mounts = &agent.spec.sandbox.mounts; + assert_eq!(mounts.len(), 2); + assert!(matches!( + &mounts[0], + manifest::MountSpec::Bind { source, target, read_only } + if source == std::path::Path::new("../../../../..") + && target.as_str() == "/home/agent/code/altinn-studio" + && !read_only + )); + assert!(!dockerfile.contains("gh repo clone")); + + let default = manifest::resolve(&directory.join("agent.yaml")) + .expect("default manifest should resolve") + .agent; + assert_eq!(default.metadata.name, "agent-dev"); + assert_eq!(default.spec.sandbox.mounts.len(), 1); + assert_eq!(default.spec.environment, agent.spec.environment); + let nested = manifest::resolve(&directory.join("agent.nested.yaml")) + .expect("nested manifest should resolve") + .agent; + assert_eq!(nested.metadata.name, "agent-dev-nested"); + assert_eq!(nested.spec.sandbox.mounts, default.spec.sandbox.mounts); + assert_eq!(nested.spec.environment, agent.spec.environment); + assert!(nested.spec.sandbox.resources.memory() < default.spec.sandbox.resources.memory()); + for resolved in [&default, &nested, &agent] { + assert!(matches!( + &resolved.spec.sandbox.image, + sandbox::image::ImageSource::Build { .. } + )); + } +} + +#[test] +fn self_development_image_leaves_harness_startup_to_sessions() { + let dockerfile = include_str!("../examples/self-dev/Dockerfile"); + + assert!(!dockerfile.lines().any(|line| line.trim_start().starts_with("CMD "))); + assert!(dockerfile.contains("podman")); + assert!(dockerfile.contains("podman-docker")); + assert!(dockerfile.contains("nftables")); + assert!(dockerfile.contains("rustup")); + assert!(dockerfile.contains("cargo-machete")); + assert!(dockerfile.contains("ENV DOCKER_HOST=unix:///run/podman/podman.sock")); + assert!(dockerfile.contains("ENV CARGO_TARGET_DIR=")); +} + +#[test] +fn rejects_removed_repository_bootstrap_configuration() { + let bytes = br" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + repositories: [] +"; + let error = manifest::decode(bytes).expect_err("repository bootstrap should not be part of the manifest"); + + assert!(error.to_string().contains("repositories")); +} + +#[test] +fn rejects_an_agent_name_that_cannot_identify_its_sandbox() { + let agent = support::agent("Worker_Name"); + let error = agent.validate().expect_err("non-portable name should be rejected"); + + assert!(matches!(error, agent::Error::Invalid(message) if message.starts_with("metadata.name:"))); +} + +#[test] +fn rejects_a_custom_placeholder_that_collides_with_a_generated_one() { + let mut agent = support::agent("worker"); + agent.spec.secrets = vec![ + SecretSpec { + environment: "FIRST_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: None, + }, + SecretSpec { + environment: "SECOND_TOKEN".into(), + optional: false, + placeholder: Some("$AGENT_SECRET_FIRST_TOKEN".into()), + allowed_hosts: vec!["example.com".into()], + source: None, + }, + ]; + + let error = agent + .validate() + .expect_err("effective placeholders must remain unambiguous"); + + assert!(matches!(error, agent::Error::Invalid(message) if message.contains("spec.secrets[1]"))); +} + +#[test] +fn decodes_an_optional_harness_installation_and_omits_the_flag_by_default() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: ghcr.io/altinn/altinn-studio/agent-minimal:latest + platform: + os: linux + resources: + cpu: "2" + memory: "4Gi" + rootFilesystem: + capacity: "32Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated + default: true + - type: codex + auth: mediated + optional: true + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest with an optional harness should decode"); + let claude = agent + .spec + .harness(Harness::ClaudeCode) + .expect("Claude Code installation"); + let codex = agent.spec.harness(Harness::Codex).expect("Codex installation"); + assert!(!claude.optional); + assert!(codex.optional); + + // The flag is absent from a required installation's serialized form, so manifests that never + // opt in are unchanged by this field existing. + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert!(value["spec"]["harnesses"][0].get("optional").is_none()); + assert_eq!(value["spec"]["harnesses"][1]["optional"], true); +} + +#[test] +fn validates_harness_installation_cardinality_and_defaults() { + let mut empty = support::agent("worker"); + empty.spec.harnesses.clear(); + assert!(matches!( + empty.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.harnesses must not be empty") + )); + + let installation = support::agent("worker").spec.harnesses.remove(0); + let mut duplicate = support::agent("worker"); + let mut explicit_default = installation.clone(); + explicit_default.default = true; + duplicate.spec.harnesses = vec![explicit_default, installation.clone()]; + assert!(matches!( + duplicate.validate(), + Err(agent::Error::Invalid(message)) if message.contains("duplicate harness kind") + )); + + let mut codex = installation.clone(); + codex.kind = Harness::Codex; + codex.version = Some("0.149.1".into()); + + let mut no_default = support::agent("worker"); + no_default.spec.harnesses = vec![installation.clone(), codex.clone()]; + assert!(matches!( + no_default.validate(), + Err(agent::Error::Invalid(message)) if message.contains("exactly one default") + )); + + let mut multiple_defaults = support::agent("worker"); + let mut first = installation; + first.default = true; + let mut second = codex; + second.default = true; + multiple_defaults.spec.harnesses = vec![first, second]; + assert!(matches!( + multiple_defaults.validate(), + Err(agent::Error::Invalid(message)) if message.contains("exactly one default") + )); +} + +#[test] +fn rejects_manifest_secrets_owned_by_a_declared_harness() { + let mut agent = support::agent("worker"); + let mut codex = agent.spec.harnesses[0].clone(); + codex.kind = Harness::Codex; + codex.version = Some("0.149.1".into()); + codex.default = false; + agent.spec.harnesses[0].default = true; + agent.spec.harnesses.push(codex); + agent.spec.secrets.push(SecretSpec { + environment: "AGENT_CODEX_ACCESS_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["chatgpt.com".into()], + source: None, + }); + + assert!(matches!( + agent.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.secrets[0]") + )); +} + +#[test] +fn status_tolerates_unknown_fields_inside_provenance_and_conditions() { + let status: agent::Status = serde_json::from_value(serde_json::json!({ + "observedGeneration": 1, + "futureField": true, + "provenance": { + "sourceDirectory": "/source", + "manifestPath": "/source/worker.yml", + "futureField": "ignored" + }, + "conditions": [{ "type": "Ready", "status": "True", "futureField": "ignored" }] + })) + .expect("newer status should decode"); + assert!(status.is_ready()); + let provenance = status.provenance.expect("provenance"); + assert_eq!(provenance.source_directory, std::path::Path::new("/source")); + assert_eq!( + provenance.manifest_path.as_deref(), + Some(std::path::Path::new("/source/worker.yml")) + ); +} + +#[test] +fn rejects_skills_without_a_directory_name_or_with_duplicate_names() { + let mut agent = support::agent("worker"); + agent.spec.skills = vec![agent::SkillSpec { + source: PathBuf::from("skills/.."), + name: None, + }]; + let error = agent.validate().expect_err("a source ending in .. has no skill name"); + assert!(matches!(error, agent::Error::Invalid(message) if message.starts_with("spec.skills[0]"))); + + agent.spec.skills = vec![ + agent::SkillSpec { + source: PathBuf::from("skills/evidence"), + name: None, + }, + agent::SkillSpec { + source: PathBuf::from("../shared/evidence/"), + name: None, + }, + ]; + let error = agent + .validate() + .expect_err("two skills with the same directory name collide"); + assert!( + matches!(error, agent::Error::Invalid(message) if message == "spec.skills[1] duplicates skill \"evidence\"") + ); + + agent.spec.skills.pop(); + agent.validate().expect("one named skill is valid"); + assert_eq!(agent.spec.skills[0].name(), Some("evidence")); + + agent.spec.skills[0].name = Some("installed-evidence".into()); + agent.validate().expect("an explicit skill name is valid"); + assert_eq!(agent.spec.skills[0].name(), Some("installed-evidence")); +} + +#[test] +fn harness_installations_declare_optional_model_and_effort_defaults() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: build + context: . + dockerfile: Dockerfile + platform: + os: linux + resources: + cpu: "1" + memory: "1Gi" + rootFilesystem: + capacity: "8Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated + default: true + defaults: + model: fable + effort: xhigh + - type: codex + auth: mediated + defaults: + model: gpt-5.4-codex + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest with harness defaults should decode"); + let claude = &agent.spec.harnesses[0].defaults; + assert_eq!(claude.model_str(), Some("fable")); + assert_eq!(claude.effort_str(), Some("xhigh")); + let codex = &agent.spec.harnesses[1].defaults; + assert_eq!(codex.model_str(), Some("gpt-5.4-codex")); + assert_eq!(codex.effort_str(), None); + + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert_eq!(value["spec"]["harnesses"][0]["defaults"]["model"], "fable"); + assert_eq!(value["spec"]["harnesses"][0]["defaults"]["effort"], "xhigh"); + assert!(value["spec"]["harnesses"][1]["defaults"].get("effort").is_none()); + let plain = manifest::decode(include_bytes!("../examples/minimal/agent.yaml")).expect("minimal manifest"); + let plain = serde_json::to_value(&plain).expect("Agent JSON"); + assert_eq!(plain["spec"]["harnesses"][0]["defaults"]["model"], "fable"); + assert!(plain["spec"]["harnesses"][0]["defaults"].get("effort").is_none()); + + for (field, valid, invalid) in [ + ("model", "fable", "\"\""), + ("effort", "xhigh", "\"\""), + ("model", "fable", "\"gpt 5\""), + ("effort", "xhigh", "\"hi'gh\""), + ] { + let yaml = String::from_utf8_lossy(bytes).replace( + &format!(" {field}: {valid}\n"), + &format!(" {field}: {invalid}\n"), + ); + let error = manifest::decode(yaml.as_bytes()).expect_err("invalid selections are rejected"); + assert!( + error + .to_string() + .contains(&format!("{field} must be 1-128 ASCII letters")), + "{field} = {invalid}: {error}" + ); + } +} + +/// The `agents/` manifests declare the same default but live outside this crate, +/// which the portable hosts build from a sparse checkout, so only the examples are +/// guarded here. +#[test] +fn example_manifests_keep_claude_code_sessions_on_fable() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + for path in [ + root.join("examples/minimal/agent.yaml"), + root.join("examples/self-dev/agent.yaml"), + root.join("examples/self-dev/agent.nested.yaml"), + root.join("examples/self-dev/agent.worktree.yaml"), + ] { + let agent = manifest::resolve(&path).expect("manifest should resolve").agent; + let claude = agent + .spec + .harness(Harness::ClaudeCode) + .expect("every example manifest installs Claude Code"); + assert_eq!(claude.defaults.model_str(), Some("fable")); + assert_eq!(claude.defaults.effort_str(), None); + } +} + +const ACCESS_MANIFEST_HEAD: &str = r#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: example.invalid/agent:latest + platform: + os: linux + resources: + cpu: "2" + memory: "1Gi" + rootFilesystem: + capacity: "4Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated +"#; + +const ACCESS_MANIFEST_TAIL: &str = r" + network: + mode: mediated + allow: all +"; + +fn manifest_with_access(access: &str) -> Vec { + format!("{ACCESS_MANIFEST_HEAD}{access}{ACCESS_MANIFEST_TAIL}").into_bytes() +} + +#[test] +fn decodes_ssh_access_as_a_tagged_agent_capability() { + let agent = manifest::decode(&manifest_with_access(" access:\n - type: ssh\n")).expect("SSH access decodes"); + assert_eq!(agent.spec.access, vec![agent::AccessSpec::Ssh {}]); + assert!(agent.spec.ssh_access()); + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert_eq!(value["spec"]["access"], serde_json::json!([{"type": "ssh"}])); + + let without = manifest::decode(&manifest_with_access("")).expect("omitted access decodes"); + assert!(without.spec.access.is_empty()); + assert!(!without.spec.ssh_access()); + let value = serde_json::to_value(&without).expect("Agent JSON"); + assert!(value["spec"].get("access").is_none(), "an empty list is not serialized"); +} + +#[test] +fn decodes_vnc_access_beside_ssh_as_a_tagged_agent_capability() { + let agent = manifest::decode(&manifest_with_access(" access:\n - type: ssh\n - type: vnc\n")) + .expect("SSH and VNC access decode"); + assert_eq!( + agent.spec.access, + vec![agent::AccessSpec::Ssh {}, agent::AccessSpec::Vnc {}] + ); + assert!(agent.spec.ssh_access()); + assert!(agent.spec.vnc_access()); + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert_eq!( + value["spec"]["access"], + serde_json::json!([{"type": "ssh"}, {"type": "vnc"}]) + ); + + let ssh_only = manifest::decode(&manifest_with_access(" access:\n - type: ssh\n")).expect("SSH only"); + assert!(!ssh_only.spec.vnc_access(), "one capability does not imply the other"); +} + +#[test] +fn rejects_unknown_duplicate_and_configured_access_capabilities() { + assert!(matches!( + manifest::decode(&manifest_with_access(" access:\n - type: ssh\n - type: ssh\n")), + Err(agent::Error::Invalid(message)) if message.contains("spec.access[1]") + )); + assert!(matches!( + manifest::decode(&manifest_with_access(" access:\n - type: vnc\n - type: vnc\n")), + Err(agent::Error::Invalid(message)) if message.contains("spec.access[1]") + )); + assert!(matches!( + manifest::decode(&manifest_with_access(" access:\n - type: rdp\n")), + Err(agent::Error::Yaml(_)) + )); + assert!( + matches!( + manifest::decode(&manifest_with_access(" access:\n - type: vnc\n port: 5901\n")), + Err(agent::Error::Yaml(_)) + ), + "VNC access exposes no tunables" + ); + assert!( + matches!( + manifest::decode(&manifest_with_access(" access:\n - type: ssh\n port: 22\n")), + Err(agent::Error::Yaml(_)) + ), + "SSH access exposes no tunables" + ); + // `access` belongs to the Agent, not the Sandbox: nest it in the existing sandbox block. + let nested = format!("{ACCESS_MANIFEST_HEAD}{ACCESS_MANIFEST_TAIL}").replace( + " mode: layered\n", + " mode: layered\n access:\n - type: ssh\n", + ); + assert!( + nested.contains(" access:"), + "fixture places access under spec.sandbox" + ); + assert!(matches!( + manifest::decode(nested.as_bytes()), + Err(agent::Error::Yaml(_)) + )); +} diff --git a/agentctl/tests/microsandbox_attach.rs b/agentctl/tests/microsandbox_attach.rs new file mode 100644 index 0000000..ed0a889 --- /dev/null +++ b/agentctl/tests/microsandbox_attach.rs @@ -0,0 +1,18 @@ +#![allow(clippy::expect_used)] + +use sandbox_microsandbox::MicrosandboxProvider; +use tempfile::TempDir; + +#[tokio::test(flavor = "local")] +async fn daemon_and_direct_attach_clients_can_open_the_same_microsandbox_home() { + let directory = TempDir::new().expect("temporary directory"); + let daemon = MicrosandboxProvider::open(directory.path()) + .await + .expect("daemon provider"); + let attach = MicrosandboxProvider::open(directory.path()) + .await + .expect("direct attach provider"); + + drop(attach); + drop(daemon); +} diff --git a/agentctl/tests/platform_api.rs b/agentctl/tests/platform_api.rs new file mode 100644 index 0000000..39af048 --- /dev/null +++ b/agentctl/tests/platform_api.rs @@ -0,0 +1,373 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{path::PathBuf, rc::Rc, time::Duration}; + +use agent::{ + AgentId, Condition, ConditionStatus, Status, + control_plane::{AgentRecord, AgentStore as _}, + persistence, + sandbox::{Assignment as SandboxAssignment, ProviderId}, + sessions::{LaunchRecord, SessionName, SessionStore as _}, +}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + +fn ready_record(name: &str, id: AgentId) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + resource.status = Status::observed( + 1, + Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af".parse().expect("Sandbox ID"), + harnesses: resource + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ); + AgentRecord { + id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +async fn request_to(port: u16, path: &str, token: &str, body: &str) -> u16 { + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect to Platform API endpoint"); + let request = format!( + "POST {path} HTTP/1.1\r\nhost: h\r\nauthorization: Bearer {token}\r\n\ + content-type: application/json\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + stream.write_all(request.as_bytes()).await.expect("send request"); + let mut response = String::new(); + stream.read_to_string(&mut response).await.expect("read response"); + response + .split(' ') + .nth(1) + .and_then(|status| status.parse().ok()) + .expect("response status") +} + +async fn request(port: u16, token: &str, body: &str) -> u16 { + request_to(port, "/v1/session/hooks", token, body).await +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn session_reports_require_the_current_launch_token() { + const TOKEN_1: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const TOKEN_2: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + database.put(ready_record("worker", agent_id), 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + agent::sessions::NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("session"); + database + .record_session_launch( + session.id, + LaunchRecord { + token: TOKEN_1.parse().expect("launch token"), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("record launch"); + + let listener = agent::platform_api::bind_persistent(&directory.path().join("platform-api-port")) + .await + .expect("bind Platform API listener"); + let port = listener.local_addr().expect("local address").port(); + let reported_errors = Rc::new(std::cell::Cell::new(0)); + let error_count = reported_errors.clone(); + let server = Rc::new(agent::platform_api::Server::new( + Rc::new(database.clone()), + Rc::new(move |error| { + assert!( + error.to_string().contains("injected fold failure"), + "unexpected Platform API error: {error}" + ); + error_count.set(error_count.get() + 1); + }), + )); + let server_task = tokio::task::spawn_local(server.serve(listener)); + + let native = "0f0e0d0c-0b0a-4908-8706-050403020100"; + let transcript = "/home/agent/.claude/projects/-home-agent-code/0f0e0d0c-0b0a-4908-8706-050403020100.jsonl"; + let report = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{}","event":"sessionStart","nativeSessionId":"{native}","transcriptPath":"{transcript}","source":"startup"}}"#, + session.id + ); + + assert_eq!(request_to(port, "/v1/session-reports", TOKEN_1, &report).await, 404); + // A stale or foreign token authenticates as nothing. + assert_eq!(request(port, "unknown-token", &report).await, 401); + // A valid token for a different platform Session is rejected. + let mismatched = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{agent_id}","event":"sessionStart","nativeSessionId":"{native}"}}"# + ); + assert_eq!(request(port, TOKEN_1, &mismatched).await, 401); + // Harness-native IDs are opaque to the platform layer. + let opaque = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000006","sessionId":"{}","event":"sessionStart","nativeSessionId":"opaque-harness-id"}}"#, + session.id + ); + assert_eq!(request(port, TOKEN_1, &opaque).await, 204); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!( + stored.status.reported.harness_session_id.as_deref(), + Some("opaque-harness-id") + ); + assert_eq!(stored.status.reported.harness_transcript_path, None); + // A transcript location must be an absolute Sandbox path. + let relative = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{}","event":"sessionStart","nativeSessionId":"{native}","transcriptPath":"relative.jsonl"}}"#, + session.id + ); + assert_eq!(request(port, TOKEN_1, &relative).await, 400); + + let empty = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{}","event":"sessionStart","nativeSessionId":""}}"#, + session.id + ); + assert_eq!(request(port, TOKEN_1, &empty).await, 400); + + assert_eq!(request(port, TOKEN_1, &report).await, 204); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!(stored.status.reported.harness_session_id.as_deref(), Some(native)); + assert_eq!( + stored.status.reported.harness_transcript_path.as_deref(), + Some(transcript) + ); + assert_eq!(stored.status.reported.activity.phase, agent::sessions::Phase::Working); + + // A replay of an older start must not overwrite the newer conversation. + assert_eq!(request(port, TOKEN_1, &opaque).await, 204); + assert_eq!( + database.get_session(session.id).await.expect("session").status.reported, + stored.status.reported + ); + + let inspect = rusqlite::Connection::open(directory.path().join("agent.db")).expect("inspect"); + let next_start = report + .replace("000000000001", "000000000007") + .replace(native, "next-conversation"); + inspect.execute_batch("CREATE TRIGGER reject_activity BEFORE UPDATE OF activity_json ON sessions BEGIN SELECT RAISE(ABORT, 'injected fold failure'); END;").expect("inject failure"); + assert_eq!(request(port, TOKEN_1, &next_start).await, 500); + assert_eq!(reported_errors.get(), 1); + assert_eq!( + database.get_session(session.id).await.expect("session").status.reported, + stored.status.reported + ); + inspect + .execute_batch("DROP TRIGGER reject_activity;") + .expect("remove failure"); + assert_eq!(request(port, TOKEN_1, &next_start).await, 204); + let applied = database.get_session(session.id).await.expect("session"); + assert_eq!( + applied.status.reported.harness_session_id.as_deref(), + Some("next-conversation") + ); + assert_eq!(request(port, TOKEN_1, &next_start).await, 204); + // Restore the conversation with a new event for the remaining assertions. + assert_eq!( + request(port, TOKEN_1, &report.replace("000000000001", "000000000008")).await, + 204 + ); + + // Lifecycle writes cannot touch the reported half. + database + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::running(), 0) + .await + .expect("status update"); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!(stored.status.reported.harness_session_id.as_deref(), Some(native)); + assert_eq!(stored.status.lifecycle.state, agent::sessions::LifecycleState::Running); + + // Activity events fold into the durable report. + let event = |name: &str| { + let suffix = match name { + "turnStarted" => 2, + "waitingForInput" => 3, + "turnCompleted" => 4, + _ => 5, + }; + format!( + r#"{{"eventId":"00000000-0000-4000-8000-{suffix:012}","sessionId":"{}","event":"{name}"}}"#, + session.id + ) + }; + assert_eq!(request(port, TOKEN_1, &event("turnStarted")).await, 204); + assert_eq!(request(port, TOKEN_1, &event("waitingForInput")).await, 204); + assert_eq!(request(port, TOKEN_1, &event("turnCompleted")).await, 204); + // A successful completion whose HTTP response was lost must not count twice. + assert_eq!(request(port, TOKEN_1, &event("turnCompleted")).await, 204); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!(stored.status.reported.activity.turns, 1); + assert_eq!( + stored.status.reported.activity.phase, + agent::sessions::Phase::WaitingForInput + ); + assert!(stored.status.reported.activity.last_event_at.is_some()); + // An unknown event is a malformed report. + assert_eq!(request(port, TOKEN_1, &event("danced")).await, 400); + + // A relaunch rotates the token; the old incarnation's token stops working. + database + .record_session_launch( + session.id, + LaunchRecord { + token: TOKEN_2.parse().expect("launch token"), + sandbox: "sandbox-1".into(), + launched_at: 1, + attempts: 2, + }, + ) + .await + .expect("record relaunch"); + // The relaunch reset what the old launch reported, so the Session is + // Starting again until the new process reports; the old ID stays for resume. + let relaunched = database.get_session(session.id).await.expect("session"); + assert_eq!(relaunched.status.state, agent::sessions::State::Starting); + assert_eq!(relaunched.status.reported.activity.turns, 0); + assert_eq!(relaunched.status.reported.harness_session_id.as_deref(), Some(native)); + assert_eq!(request(port, TOKEN_1, &report).await, 401); + // A stale token's activity report is a silent no-op, not an error the hook retries. + assert_eq!(request(port, TOKEN_1, &event("turnCompleted")).await, 204); + assert_eq!( + database + .get_session(session.id) + .await + .expect("session") + .status + .reported + .activity + .turns, + 0 + ); + assert_eq!(request(port, TOKEN_2, &report).await, 204); + + server_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn launch_bookkeeping_round_trips_and_resets() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "48f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + database.put(ready_record("worker", agent_id), 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + agent::sessions::NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("session"); + + assert_eq!( + database.session_launch_state(session.id).await.expect("empty state"), + None + ); + database + .record_session_launch( + session.id, + LaunchRecord { + token: "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("launch token"), + sandbox: "sandbox-1".into(), + launched_at: 42, + attempts: 3, + }, + ) + .await + .expect("record launch"); + let state = database + .session_launch_state(session.id) + .await + .expect("state") + .expect("recorded state"); + assert_eq!(state.sandbox, "sandbox-1"); + assert_eq!(state.launched_at, 42); + assert_eq!(state.attempts, 3); + + database + .reset_session_launch_attempts(session.id) + .await + .expect("reset attempts"); + let state = database + .session_launch_state(session.id) + .await + .expect("state") + .expect("recorded state"); + assert_eq!(state.attempts, 0); +} + +#[tokio::test(flavor = "local")] +async fn platform_api_bounds_stalled_connections() { + const TOKEN: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let listener = agent::platform_api::bind_persistent(&directory.path().join("platform-api-port")) + .await + .expect("bind Platform API listener"); + let port = listener.local_addr().expect("local address").port(); + let server = Rc::new(agent::platform_api::Server::new( + Rc::new(database), + Rc::new(|_error| {}), + )); + let server_task = tokio::task::spawn_local(server.serve(listener)); + + let mut stalled = Vec::new(); + for _ in 0..64 { + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect stalled client"); + stream.write_all(b"P").await.expect("start incomplete request"); + stalled.push(stream); + tokio::task::yield_now().await; + } + tokio::time::sleep(Duration::from_millis(25)).await; + + let blocked = tokio::time::timeout( + Duration::from_millis(100), + request_to(port, "/v1/session/hooks", TOKEN, "{}"), + ) + .await; + assert!(blocked.is_err(), "a connection beyond the limit must wait for capacity"); + + drop(stalled.pop()); + let status = tokio::time::timeout( + Duration::from_secs(1), + request_to(port, "/v1/session/hooks", TOKEN, "{}"), + ) + .await + .expect("request should proceed after capacity is released"); + assert_eq!(status, 400); + + server_task.abort(); +} diff --git a/agentctl/tests/platform_linux.rs b/agentctl/tests/platform_linux.rs new file mode 100644 index 0000000..a7725b8 --- /dev/null +++ b/agentctl/tests/platform_linux.rs @@ -0,0 +1,951 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{io::Cursor, path::PathBuf, rc::Rc}; + +use agent::{ + AgentId, + control_plane::AgentRecord, + sandbox::{PlatformAdapter as _, platform::Linux}, +}; +use sandbox::{ + EnsureSandboxRequest, Platform, SandboxPath, SandboxService, + execution::{ExecutionEvent, ExitStatus, Program}, + memory, +}; +use tempfile::TempDir; +use tokio::io::AsyncReadExt as _; + +/// Setup steps whose progress is discarded. +fn setup_phase() -> sandbox::SandboxProgress { + sandbox::ProgressReporter::from_callback(|_| {}).steps() +} + +fn is_claude_version(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" && args == &["claude", "--version"] + ) +} + +fn is_codex_version(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" && args == &["codex", "--version"] + ) +} + +fn is_podman_presence_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/test" && args == &["-x", "/usr/bin/podman"] + ) +} + +fn is_git_presence_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" && args == &["git", "--version"] + ) +} + +fn is_git_config(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" + && args.first().map(String::as_str) == Some("git") + && args.get(1).map(String::as_str) == Some("config") + && args.get(2).map(String::as_str) == Some("--global") + ) +} + +fn is_systemd_readiness_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/sudo" && args == &["-n", "/usr/bin/systemctl", "is-system-running", "--wait"] + ) +} + +fn completed(code: i32) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code }), + ] +} + +const PODMAN_CONTAINERS_CONF: &[u8] = br#"[containers] +env = [ + "SSL_CERT_FILE=/run/agent/tls/ca-bundle.pem", + "CURL_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "REQUESTS_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem", + "GIT_SSL_CAINFO=/run/agent/tls/ca-bundle.pem", + "NPM_CONFIG_CAFILE=/run/agent/tls/ca-bundle.pem", +] +"#; +const PODMAN_RUNTIME_CONF: &[u8] = b"[engine]\ncgroup_manager = \"cgroupfs\"\ncompat_api_enforce_docker_hub = true\nhooks_dir = [\"/etc/containers/oci/hooks.d\"]\n"; +const PODMAN_REGISTRIES_CONF: &[u8] = + b"unqualified-search-registries = [\"docker.io\"]\nshort-name-mode = \"enforcing\"\n"; +const PODMAN_MOUNTS_CONF: &[u8] = b"/etc/ssl/certs/ca-certificates.crt:/run/agent/tls/ca-bundle.pem\n"; +const PODMAN_SOCKET_DROP_IN: &[u8] = b"[Socket]\nDirectoryMode=0755\nSocketGroup=agent\nSocketMode=0660\n"; + +async fn read_file(sandbox: &sandbox::SandboxHandle, path: &str) -> Vec { + let mut bytes = Vec::new(); + sandbox + .read_file(&SandboxPath::new(path)) + .await + .expect("read file") + .read_to_end(&mut bytes) + .await + .expect("read file bytes"); + bytes +} + +fn assert_podman_setup_commands(executions: &[sandbox::execution::ExecutionSpec]) { + let count = |expected: &[&str]| { + executions + .iter() + .filter(|spec| match spec.program() { + Program::Command { executable, args } => { + executable.as_str() == "/usr/bin/sudo" + && args.iter().map(String::as_str).eq(expected.iter().copied()) + } + Program::ImageEntrypoint => false, + }) + .count() + }; + assert_eq!(count(&["-n", "/usr/bin/systemctl", "daemon-reload"]), 2); + // systemd readiness is confirmed before the first systemctl call of a setup pass. + let daemon_reload = executions + .iter() + .position(|spec| matches!(spec.program(), Program::Command { args, .. } if args.contains(&"daemon-reload".to_owned()))) + .expect("daemon-reload runs"); + assert!(executions.iter().take(daemon_reload).any(is_systemd_readiness_check)); + assert!( + executions + .iter() + .filter(|spec| is_systemd_readiness_check(spec)) + .count() + >= 2 + ); + assert_eq!( + count(&["-n", "/usr/bin/systemctl", "enable", "--now", "podman.socket"]), + 2 + ); + assert_eq!(count(&["-n", "/usr/bin/install", "-d", "-m", "0755", "/run/podman"]), 2); + assert_eq!( + count(&["-n", "/bin/chmod", "0755", "/usr/local/libexec/agent-container-ca"]), + 2 + ); + assert!(!executions.iter().any(|spec| { + match spec.program() { + Program::Command { args, .. } => args + .iter() + .any(|argument| matches!(argument.as_str(), "agent-containers" | "/dev/net/tun")), + Program::ImageEntrypoint => false, + } + })); +} + +/// Every harness the Agent declares, as preparation would report when all host logins are present. +fn declared(record: &AgentRecord) -> Vec { + record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect() +} + +/// Setup acts on the installed set preparation reported, not on everything the Agent declares. +/// +/// The two run in the same convergence pass and must agree about an optional installation whose +/// host login was absent. Preparation decides and reports; setup is told. An omitted harness is +/// skipped entirely: not verified, not configured. +#[tokio::test(flavor = "local")] +async fn linux_setup_configures_only_the_harnesses_preparation_reported() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions\n").expect("instruction file"); + let agent_id: AgentId = "5c0fd6ac-1d5a-4f8b-9f58-6b0f4de1f6c1".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.harnesses[0].default = true; + resource.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: None, + auth: agent::HarnessAuthMode::Mediated, + optional: true, + default: false, + defaults: agent::ModelSelection::default(), + }); + let record = AgentRecord { + id: agent_id, + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + Linux + .setup(&record, &sandbox, &[agent::Harness::ClaudeCode], &setup_phase()) + .await + .expect("setup"); + + let writes = backend + .file_writes() + .into_iter() + .map(|path| path.as_str().to_owned()) + .collect::>(); + assert!( + writes.iter().any(|path| path == "/home/agent/.claude/CLAUDE.md"), + "the required harness is still configured: {writes:?}" + ); + assert!( + !writes.iter().any(|path| path.starts_with("/home/agent/.codex/")), + "the omitted harness must not be configured: {writes:?}" + ); + assert!( + !backend.execution_specs().iter().any(is_codex_version), + "the omitted harness must not be verified either" + ); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn linux_setup_rewrites_configuration_without_owning_workspace_initialization() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions\n").expect("instruction file"); + std::fs::write( + directory.path().join("environment.md"), + "# Environment\n\nhas a browser\n", + ) + .expect("environment file"); + let skill = directory.path().join("skills").join("evidence"); + std::fs::create_dir_all(skill.join("references")).expect("skill directory"); + std::fs::write(skill.join("SKILL.md"), "---\nname: evidence\n---\ncapture").expect("skill file"); + std::fs::write(skill.join("references").join("gif.md"), "palette").expect("skill reference"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.skills = vec![agent::SkillSpec { + source: PathBuf::from("skills/evidence"), + name: None, + }]; + resource.spec.instructions.push(agent::InstructionsSpec { + source: PathBuf::from("environment.md"), + }); + resource.spec.harnesses[0].default = true; + resource.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: Some("0.149.1".into()), + auth: agent::HarnessAuthMode::Mediated, + optional: false, + default: false, + defaults: agent::ModelSelection::default(), + }); + let record = AgentRecord { + id: agent_id, + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + + let backend = Rc::new(memory::Provider::new()); + for _ in 0..2 { + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching( + is_codex_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("codex-cli 0.149.1\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + } + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec) + .with_environment([("AGENT_CODEX_ACCOUNT_ID".into(), "account-test".into())]), + ) + .await + .expect("Sandbox"); + let platform = Linux; + + platform + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("first setup"); + let first_pass_writes = backend.file_writes(); + let mutable_state = br#"{"theme":"light","projects":{"/home/agent/code/example":{"hasTrustDialogAccepted":true}}}"#; + sandbox + .write_file( + &SandboxPath::new("/home/agent/.claude/.claude.json"), + Box::pin(Cursor::new(mutable_state.to_vec())), + ) + .await + .expect("write harness-owned state"); + platform + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("second setup"); + + // Harnesses watch their configuration and skills live: a pass that changes nothing must not + // rewrite them. Only the home archive, consumed by tar and watched by nobody, is re-sent. + let second_pass_writes = backend + .file_writes() + .into_iter() + .skip(first_pass_writes.len() + 1) + .map(|path| path.as_str().to_owned()) + .collect::>(); + assert_eq!(second_pass_writes, ["/tmp/agent-home.tar"]); + assert!( + first_pass_writes + .iter() + .any(|path| path.as_str() == "/home/agent/.claude/skills/evidence/SKILL.md") + ); + + let preserved = read_file(&sandbox, "/home/agent/.claude/.claude.json").await; + assert_eq!(preserved, mutable_state); + let instructions = read_file(&sandbox, "/home/agent/.claude/CLAUDE.md").await; + assert_eq!(instructions, b"test instructions\n\n# Environment\n\nhas a browser\n"); + let codex_instructions = read_file(&sandbox, "/home/agent/.codex/AGENTS.md").await; + assert_eq!(codex_instructions, instructions); + for root in ["/home/agent/.claude/skills", "/home/agent/.agents/skills"] { + let skill = read_file(&sandbox, &format!("{root}/evidence/SKILL.md")).await; + assert_eq!(skill, b"---\nname: evidence\n---\ncapture"); + let reference = read_file(&sandbox, &format!("{root}/evidence/references/gif.md")).await; + assert_eq!(reference, b"palette"); + } + let codex_auth: serde_json::Value = + serde_json::from_slice(&read_file(&sandbox, "/home/agent/.codex/auth.json").await).expect("Codex auth JSON"); + assert_eq!(codex_auth["auth_mode"], "chatgpt"); + assert_eq!(codex_auth["tokens"]["account_id"], "account-test"); + assert_eq!(codex_auth["tokens"]["access_token"], codex_auth["tokens"]["id_token"]); + assert!(codex_auth["last_refresh"].is_string()); + let codex_hooks: serde_json::Value = + serde_json::from_slice(&read_file(&sandbox, "/home/agent/.codex/hooks.json").await).expect("Codex hooks JSON"); + assert_eq!( + codex_hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "node /home/agent/.codex/hooks/activity-hook.mjs" + ); + assert!(codex_hooks["hooks"]["SessionStart"][0].get("matcher").is_none()); + for event in ["UserPromptSubmit", "Interrupt", "Stop", "PermissionRequest"] { + assert_eq!( + codex_hooks["hooks"][event][0]["hooks"][0]["command"], "node /home/agent/.codex/hooks/activity-hook.mjs", + "Codex registers {event}" + ); + } + assert!( + codex_hooks["hooks"].get("Notification").is_none(), + "Codex has no Notification hook" + ); + let hook_script = read_file(&sandbox, "/home/agent/.codex/hooks/activity-hook.mjs").await; + assert!( + String::from_utf8(hook_script) + .expect("UTF-8 hook") + .contains(r#""Stop":"turnCompleted""#) + ); + + let executions = backend.execution_specs(); + let commands = executions + .iter() + .filter_map(|spec| match spec.program() { + Program::Command { executable, args } => Some((executable.as_str(), args.as_slice())), + Program::ImageEntrypoint => None, + }) + .collect::>(); + assert_eq!( + commands + .iter() + .filter(|(executable, _)| *executable == "/usr/bin/env") + .count(), + 4 + ); + assert_eq!( + commands + .iter() + .filter(|(executable, _)| *executable == "/usr/bin/tar") + .count(), + 2 + ); + assert_eq!( + commands + .iter() + .filter(|(executable, args)| { + *executable == "/usr/bin/install" && args == &["-d", "-m", "0755", "/home/agent/code"] + }) + .count(), + 2 + ); + assert!(!commands.iter().any(|(executable, _)| *executable == "/usr/bin/git")); + assert!( + !commands.iter().any(|(executable, args)| { + *executable == "/usr/bin/sudo" && args.iter().any(|arg| arg == "podman.socket") + }) + ); + assert!(!commands.iter().any(|(executable, args)| { + *executable == "/usr/bin/touch" || (*executable == "/usr/bin/sudo" && args.iter().any(|arg| arg == "-R")) + })); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn linux_setup_convergently_configures_podman_container_trust() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: agent_id, + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + for _ in 0..2 { + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(0)); + } + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + // The first setup pass races the image init: systemd is not PID 1 yet, then boots degraded. + backend.queue_execution_events_matching( + is_systemd_readiness_check, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stderr( + "System has not been booted with systemd as init system (PID 1). Can't operate.\n".into(), + ), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + backend.queue_execution_events_matching( + is_systemd_readiness_check, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("degraded\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("first setup"); + sandbox + .write_file( + &SandboxPath::new("/etc/containers/containers.conf.d/50-agent-ca.conf"), + Box::pin(Cursor::new(b"stale\n".to_vec())), + ) + .await + .expect("replace managed configuration"); + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("second setup"); + + assert_eq!( + read_file(&sandbox, "/etc/containers/containers.conf.d/50-agent-ca.conf").await, + PODMAN_CONTAINERS_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/containers/containers.conf.d/51-agent-runtime.conf").await, + PODMAN_RUNTIME_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/containers/mounts.conf").await, + PODMAN_MOUNTS_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/containers/registries.conf.d/50-agent-docker-hub.conf").await, + PODMAN_REGISTRIES_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/systemd/system/podman.socket.d/50-agent-access.conf").await, + PODMAN_SOCKET_DROP_IN + ); + let hook_configuration: serde_json::Value = + serde_json::from_slice(&read_file(&sandbox, "/etc/containers/oci/hooks.d/50-agent-ca.json").await) + .expect("OCI hook JSON"); + assert_eq!( + hook_configuration["hook"]["path"], + "/usr/local/libexec/agent-container-ca" + ); + assert_eq!(hook_configuration["stages"], serde_json::json!(["createRuntime"])); + let hook = + String::from_utf8(read_file(&sandbox, "/usr/local/libexec/agent-container-ca").await).expect("hook script"); + assert!(hook.starts_with("#!/bin/sh\n")); + // Distro trust paths are copied, never bind-mounted, so package managers can replace them. + assert!( + !PODMAN_MOUNTS_CONF + .windows(b"/etc/ssl/certs/ca-certificates.crt:/etc/".len()) + .any(|w| w == b"/etc/ssl/certs/ca-certificates.crt:/etc/") + ); + for path in [ + "etc/ssl/certs/ca-certificates.crt", + "etc/pki/tls/certs/ca-bundle.crt", + "etc/ssl/cert.pem", + "usr/local/share/ca-certificates/agent-mediator.crt", + "etc/pki/ca-trust/source/anchors/agent-mediator.crt", + ] { + assert!(hook.contains(path), "{path}"); + } + assert!(hook.contains("/.msb/tls/ca.pem")); + + assert_podman_setup_commands(&backend.execution_specs()); + // Two setup passes; the first retried once while systemd was not yet PID 1. + assert_eq!( + backend + .execution_specs() + .iter() + .filter(|spec| is_systemd_readiness_check(spec)) + .count(), + 3 + ); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_accepts_any_installed_version_when_none_is_declared() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.harnesses[0].version = None; + let record = AgentRecord { + id: agent_id, + source_directory: PathBuf::from(directory.path()), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.258 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("setup without a declared version"); + + assert_eq!( + backend + .execution_specs() + .iter() + .filter(|spec| is_claude_version(spec)) + .count(), + 1, + "the installation is still checked for presence" + ); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_converges_git_identity_after_home_sync() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + for _ in 0..2 { + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + backend.queue_execution_events_matching(is_git_presence_check, completed(0)); + } + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let request = |name: &str, email: &str| { + EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec.clone()).with_environment([ + ("GIT_USER_NAME".into(), name.into()), + ("GIT_USER_EMAIL".into(), email.into()), + ]) + }; + let first = service + .ensure(&request("First User", "first@example.com")) + .await + .expect("first Sandbox"); + Linux + .setup(&record, &first, &declared(&record), &setup_phase()) + .await + .expect("first setup"); + let second = service + .ensure(&request("Second User", "second@example.com")) + .await + .expect("updated Sandbox"); + Linux + .setup(&record, &second, &declared(&record), &setup_phase()) + .await + .expect("updated setup"); + + let executions = backend.execution_specs(); + let git = executions.iter().filter(|spec| is_git_config(spec)).collect::>(); + assert_eq!(git.len(), 4); + let arguments = git + .iter() + .map(|spec| match spec.program() { + Program::Command { args, .. } => args.clone(), + Program::ImageEntrypoint => unreachable!(), + }) + .collect::>(); + assert_eq!( + arguments, + [ + ["git", "config", "--global", "user.name", "First User"], + ["git", "config", "--global", "user.email", "first@example.com"], + ["git", "config", "--global", "user.name", "Second User"], + ["git", "config", "--global", "user.email", "second@example.com"], + ] + .map(|args| args.map(str::to_owned).to_vec()) + ); + assert!( + git.iter() + .all(|spec| spec.environment().get("HOME").map(String::as_str) == Some("/home/agent")) + ); + let first_tar = executions + .iter() + .position(|spec| matches!(spec.program(), Program::Command { executable, .. } if executable.as_str() == "/usr/bin/tar")) + .expect("home synchronization"); + let first_git = executions.iter().position(is_git_config).expect("Git configuration"); + assert!( + first_tar < first_git, + "Git identity must be applied after the home overlay" + ); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_skips_git_identity_when_git_is_absent() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + backend.queue_execution_events_matching(is_git_presence_check, completed(127)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec).with_environment([ + ("GIT_USER_NAME".into(), "Test User".into()), + ("GIT_USER_EMAIL".into(), "test@example.com".into()), + ]), + ) + .await + .expect("Sandbox"); + + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("setup without Git"); + + assert!(!backend.execution_specs().iter().any(is_git_config)); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_rejects_partial_git_identity() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.instructions.clear(); + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec) + .with_environment([("GIT_USER_NAME".into(), "Test User".into())]), + ) + .await + .expect("Sandbox"); + + let error = Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect_err("partial Git identity"); + + assert!(matches!(error, agent::Error::Invalid(message) if message.contains("must both be configured"))); + assert!(!backend.execution_specs().iter().any(is_git_presence_check)); + assert!(!backend.execution_specs().iter().any(is_git_config)); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_rejects_a_declared_harness_version_mismatch_before_injection() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: agent_id, + source_directory: PathBuf::from(directory.path()), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.240 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + let error = Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect_err("version mismatch"); + + assert!(error.to_string().contains("does not match installed version")); + assert_eq!( + backend.execution_specs().len(), + 1, + "verification must happen before injection" + ); +} + +// The host is what holds the FIFO; Windows has no mkfifo, and the Linux Sandbox setup runs the same +// walker on every host, so one Unix host exercising it is enough. +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn linux_setup_rejects_a_skill_tree_with_a_fifo_instead_of_blocking() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + let skill = directory.path().join("skills").join("evidence"); + std::fs::create_dir_all(&skill).expect("skill directory"); + std::fs::write(skill.join("SKILL.md"), "capture").expect("skill file"); + let status = std::process::Command::new("mkfifo") + .arg(skill.join("pipe")) + .status() + .expect("mkfifo runs"); + assert!(status.success()); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.instructions.clear(); + resource.spec.skills = vec![agent::SkillSpec { + source: PathBuf::from("skills/evidence"), + name: None, + }]; + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + let error = Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect_err("FIFO must be rejected"); + + assert!( + matches!(&error, agent::Error::Invalid(message) if message.contains("non-regular file pipe")), + "unexpected error: {error:?}" + ); +} diff --git a/agentctl/tests/policy.rs b/agentctl/tests/policy.rs new file mode 100644 index 0000000..538bf18 --- /dev/null +++ b/agentctl/tests/policy.rs @@ -0,0 +1,260 @@ +#![allow(clippy::expect_used)] + +mod support; + +use agent::{SecretSpec, authorization::AgentPolicyEngine}; +use sandbox::SandboxName; +use sandbox_authorization::{ + Action, AuthorizationContext, AuthorizationDecision, AuthorizationRequest, PolicyEngine as _, Principal, Resource, + vocabulary::{action, context, principal_kind, resource_kind}, +}; + +#[tokio::test(flavor = "local")] +async fn allows_general_egress_but_scopes_each_secret_to_its_hosts() { + let mut agent = support::agent("worker"); + agent.spec.network.deny.push("blocked.example".into()); + agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: Some("GH_PAT".into()), + }); + let policy = AgentPolicyEngine::new(); + let sandbox = SandboxName::new("agent-test-id").expect("Sandbox name"); + policy.set_agent(&sandbox, &agent, []); + + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::HTTP_REQUEST, + "externalService", + "github.com", + "github.com" + ) + .await, + AuthorizationDecision::Allow + ); + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::HTTP_REQUEST, + "externalService", + "blocked.example", + "blocked.example", + ) + .await, + AuthorizationDecision::Deny + ); + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::SECRET_USE, + resource_kind::SECRET, + "GITHUB_TOKEN", + "github.com", + ) + .await, + AuthorizationDecision::Allow + ); + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::SECRET_USE, + resource_kind::SECRET, + "GITHUB_TOKEN", + "example.com", + ) + .await, + AuthorizationDecision::Deny + ); +} + +#[tokio::test(flavor = "local")] +async fn host_destined_traffic_reaches_only_the_platform_api() { + let agent = support::agent("worker"); + let policy = AgentPolicyEngine::new(); + let sandbox = SandboxName::new("agent-test-id").expect("Sandbox name"); + policy.set_agent(&sandbox, &agent, []); + + // Fail closed: without a registered Platform API endpoint every host-destined + // connect is denied, even under the allow-all egress default. + assert_eq!( + connect( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "100.64.0.2:9999" + ) + .await, + AuthorizationDecision::Deny + ); + + policy.set_platform_endpoint("host.microsandbox.internal", 4_100); + + assert_eq!( + connect( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "100.64.0.2:4100" + ) + .await, + AuthorizationDecision::Allow + ); + assert_eq!( + connect( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "100.64.0.2:8080" + ) + .await, + AuthorizationDecision::Deny + ); + // A raw dial into a host-reserved range carries no hostname and is denied + // even on the Platform API port: only the alias identifies the endpoint. + assert_eq!( + connect(&policy, sandbox.as_str(), None, "100.64.0.2:4100").await, + AuthorizationDecision::Deny + ); + assert_eq!( + connect(&policy, sandbox.as_str(), None, "127.0.0.1:80").await, + AuthorizationDecision::Deny + ); + assert_eq!( + connect(&policy, sandbox.as_str(), None, "169.254.169.254:80").await, + AuthorizationDecision::Deny + ); + assert_eq!( + connect(&policy, sandbox.as_str(), None, "[fd00::2]:443").await, + AuthorizationDecision::Deny + ); + // Ordinary public egress is unaffected. + assert_eq!( + connect(&policy, sandbox.as_str(), Some("github.com"), "140.82.121.4:443").await, + AuthorizationDecision::Allow + ); + // Resolving the alias stays possible; it only reveals the gateway. + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::DNS_QUERY, + resource_kind::DOMAIN, + "host.microsandbox.internal", + "host.microsandbox.internal", + ) + .await, + AuthorizationDecision::Allow + ); +} + +#[tokio::test(flavor = "local")] +async fn host_destined_flag_denies_the_gateway_by_number_outside_guessed_ranges() { + let agent = support::agent("worker"); + let policy = AgentPolicyEngine::new(); + let sandbox = SandboxName::new("agent-test-id").expect("Sandbox name"); + policy.set_agent(&sandbox, &agent, []); + policy.set_platform_endpoint("host.microsandbox.internal", 4_100); + + // The default guest pool 172.16/12 is not a guessed reserved range, so + // before the Backend flag an HTTP dial to the gateway with a plausible but + // spoofed authority looked like ordinary egress and reached host loopback. + assert_eq!( + connect_flagged(&policy, sandbox.as_str(), Some("github.com"), "172.16.0.5:4100", false).await, + AuthorizationDecision::Allow + ); + // Flagged host-destined by the trusted Backend, the same dial is denied: + // the spoofed authority is not the alias, so it cannot reach host loopback. + assert_eq!( + connect_flagged(&policy, sandbox.as_str(), Some("github.com"), "172.16.0.5:4100", true).await, + AuthorizationDecision::Deny + ); + // The genuine Platform API — alias on its registered port — still succeeds. + assert_eq!( + connect_flagged( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "172.16.0.5:4100", + true + ) + .await, + AuthorizationDecision::Allow + ); + // Flagged host-destined on another port is denied even via the alias. + assert_eq!( + connect_flagged( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "172.16.0.5:8080", + true + ) + .await, + AuthorizationDecision::Deny + ); +} + +async fn connect( + policy: &AgentPolicyEngine, + agent: &str, + hostname: Option<&str>, + destination: &str, +) -> AuthorizationDecision { + connect_flagged(policy, agent, hostname, destination, false).await +} + +async fn connect_flagged( + policy: &AgentPolicyEngine, + agent: &str, + hostname: Option<&str>, + destination: &str, + destination_is_host: bool, +) -> AuthorizationDecision { + let mut authorization_context = AuthorizationContext::new() + .with_attribute(context::SANDBOX_NAME, agent) + .with_attribute(context::NETWORK_DESTINATION_ADDRESS, destination) + .with_attribute(context::NETWORK_DESTINATION_IS_HOST, destination_is_host); + if let Some(hostname) = hostname { + authorization_context.insert(context::NETWORK_HOSTNAME, hostname); + } + policy + .evaluate(AuthorizationRequest { + principal: Principal::new(principal_kind::SANDBOX, "sandbox-id"), + action: Action::new(action::NETWORK_CONNECT), + resource: Resource::new( + resource_kind::EXTERNAL_SERVICE, + hostname.map_or_else(|| destination.into(), str::to_owned), + ), + context: authorization_context, + }) + .await + .expect("policy decision") +} + +async fn evaluate( + policy: &AgentPolicyEngine, + agent: &str, + operation: &str, + resource_kind: &str, + resource: &str, + host: &str, +) -> AuthorizationDecision { + policy + .evaluate(AuthorizationRequest { + principal: Principal::new(principal_kind::SANDBOX, "sandbox-id"), + action: Action::new(operation), + resource: Resource::new(resource_kind, resource), + context: AuthorizationContext::new() + .with_attribute(context::SANDBOX_NAME, agent) + .with_attribute(context::HTTP_AUTHORITY, host), + }) + .await + .expect("policy decision") +} diff --git a/agentctl/tests/session_controller.rs b/agentctl/tests/session_controller.rs new file mode 100644 index 0000000..f964358 --- /dev/null +++ b/agentctl/tests/session_controller.rs @@ -0,0 +1,3437 @@ +#![allow(clippy::expect_used, clippy::panic)] + +mod support; + +use std::{ + cell::{Cell, RefCell}, + path::PathBuf, + rc::Rc, + time::Duration, +}; + +use agent::{ + AgentId, Condition, ConditionStatus, Error, Status, + control_plane::{AgentRecord, AgentStore as _, Convergence, WaitPolicy}, + local::home::ControlPlaneHome, + persistence, + resources::Changes, + sandbox::{Assignment as SandboxAssignment, PlatformAdapter, Provider, ProviderEnsureOutcome, ProviderId}, + sessions::{NewSession, Reconcile, SessionId, SessionName, SessionReports as _, SessionRequest, SessionStore as _}, +}; +use sandbox::{ + EnsureSandboxRequest, LocalFuture, Platform, SandboxHandle, SandboxService, + execution::{ExecutionEvent, ExitStatus, Program}, + memory as sandbox_memory, + network::{NetworkEndpointSelection, PacketMedium}, +}; +use tempfile::TempDir; +use tokio::sync::Notify; + +struct BlockingReconcile { + slow: SessionId, + slow_calls: Rc>, + active_slow: Rc>, + started: Rc, + release: Rc, +} + +fn is_session_observation(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/bin/sh" + && args.iter().any(|argument| argument.contains("session_activity")) + ) +} + +struct BlockingAgentReady { + database: persistence::Database, + started: Rc, + release: Rc, +} + +impl Reconcile for BlockingAgentReady { + fn reconcile(&self, id: AgentId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.started.notify_one(); + self.release.notified().await; + let record = self.database.get(id).await?; + self.database + .update_status( + id, + record.agent.metadata.generation, + Status::observed( + record.agent.metadata.generation, + Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory")?, + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af" + .parse() + .map_err(|error| Error::Database(format!("test Sandbox ID: {error}")))?, + harnesses: record.agent.spec.harnesses.iter().map(|i| i.kind).collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ), + ) + .await + .map(drop) + }) + } +} + +struct MarkSessionReady(persistence::Database); + +struct NoopAgentReconcile; + +impl Reconcile for NoopAgentReconcile { + fn reconcile(&self, _id: AgentId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async { Ok(()) }) + } +} + +impl Reconcile for MarkSessionReady { + fn reconcile(&self, id: SessionId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.0 + .update_session_lifecycle(id, agent::sessions::Lifecycle::running(), 0) + .await + }) + } +} + +struct NoopPlatform; + +impl PlatformAdapter for NoopPlatform { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + _record: &'a AgentRecord, + _sandbox: &'a SandboxHandle, + _harnesses: &'a [agent::Harness], + _steps: &'a sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Ok(()) }) + } +} + +struct CountingProvider { + id: ProviderId, + service: SandboxService, + ensure_calls: Rc>, +} + +impl Provider for CountingProvider { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async { Ok(true) }) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + _progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.ensure_calls.set(self.ensure_calls.get() + 1); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = self + .service + .ensure(&EnsureSandboxRequest::new(record.sandbox_name()?, spec).with_environment(environment)) + .await + .map_err(Error::from)?; + Ok(ProviderEnsureOutcome { + sandbox, + runtime_restarted: false, + harnesses: record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }) + }) + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.service + .open(id, record.agent.spec.sandbox.resolved_retention_policy()) + .await + .map_err(Error::from) + }) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.service.stop(&record.sandbox_name()?).await.map_err(Error::from) }) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.service + .release( + &record.sandbox_name()?, + record.agent.spec.sandbox.resolved_retention_policy(), + ) + .await + .map_err(Error::from) + }) + } +} + +impl Reconcile for BlockingReconcile { + fn reconcile(&self, id: SessionId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + if id == self.slow { + assert_eq!(self.active_slow.replace(self.active_slow.get() + 1), 0); + let call = self.slow_calls.get() + 1; + self.slow_calls.set(call); + if call == 1 { + self.started.notify_one(); + self.release.notified().await; + } + self.active_slow.set(0); + } + Ok(()) + }) + } +} + +fn ready_record(name: &str, id: AgentId) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + resource.status = Status::observed( + 1, + Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af".parse().expect("Sandbox ID"), + harnesses: resource.spec.harnesses.iter().map(|i| i.kind).collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ); + AgentRecord { + id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +/// A throwaway Sandbox service and tmux runtime for Session Service tests that +/// never reach the runtime (they resolve with `WaitPolicy::FirstPass`). +fn unused_sandboxes() -> Rc { + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: SandboxService::new(backend), + ensure_calls: Rc::new(Cell::new(0)), + }); + Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ) +} + +fn tmux_runtime() -> Rc { + Rc::new(agent::sessions::Tmux) +} + +/// A scripted Session runtime: records deliveries and launches, serves a +/// conversation the test appends to. +struct FakeRuntime { + /// Whether the harness process is present; a launch is expected when it is not. + present: Cell, + fail_observe_once: Cell, + attached: Cell, + stop_calls: Cell, + fail_stop_once: Cell, + stops_failing: Cell, + /// Seconds since the harness's last terminal or transcript activity. + idle_seconds: Cell, + fail_start: Cell, + delivery_delay: Cell, + fail_transcript: Cell, + delivered: Notify, + hold_completion: Cell, + release_completion: Notify, + ready_without_report: Cell, + fail_input_ready_once: Cell, + launch_started: Notify, + /// Holds the next observation until `release_observe`, after `observe_started`. + hold_observe: Cell, + observe_started: Notify, + release_observe: Notify, + conversation: RefCell>, + sent: RefCell>, + launches: RefCell, Option)>>, + launch_tokens: RefCell>, +} + +impl Default for FakeRuntime { + fn default() -> Self { + Self { + present: Cell::new(true), + fail_observe_once: Cell::new(false), + attached: Cell::new(false), + stop_calls: Cell::new(0), + fail_stop_once: Cell::new(false), + stops_failing: Cell::new(false), + idle_seconds: Cell::new(0), + fail_start: Cell::new(false), + delivery_delay: Cell::new(Duration::ZERO), + fail_transcript: Cell::new(false), + delivered: Notify::new(), + hold_completion: Cell::new(false), + release_completion: Notify::new(), + ready_without_report: Cell::new(false), + fail_input_ready_once: Cell::new(false), + launch_started: Notify::new(), + hold_observe: Cell::new(false), + observe_started: Notify::new(), + release_observe: Notify::new(), + conversation: RefCell::default(), + sent: RefCell::default(), + launches: RefCell::default(), + launch_tokens: RefCell::default(), + } + } +} + +fn user_turn(text: &str) -> agent::sessions::Turn { + agent::sessions::Turn { + messages: vec![agent::sessions::Message { + role: agent::sessions::Role::User, + parts: vec![agent::sessions::Part::Text { text: text.into() }], + }], + } +} + +fn assistant_text(text: &str) -> agent::sessions::Message { + agent::sessions::Message { + role: agent::sessions::Role::Assistant, + parts: vec![agent::sessions::Part::Text { text: text.into() }], + } +} + +impl agent::sessions::SessionRuntime for FakeRuntime { + fn observe<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + ) -> LocalFuture<'a, Result> { + if self.fail_observe_once.replace(false) { + return Box::pin(async { Err(Error::Session("injected observation failure".into())) }); + } + let hold = self.hold_observe.replace(false); + Box::pin(async move { + if hold { + self.observe_started.notify_one(); + self.release_observe.notified().await; + } + Ok(if self.present.get() { + agent::sessions::Observation::Alive { + attached: self.attached.get(), + idle_seconds: self.idle_seconds.get(), + } + } else { + agent::sessions::Observation::Missing + }) + }) + } + + fn start<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + _session_hook_url: &'a str, + token: &'a agent::sessions::LaunchToken, + resume: Option<&'a str>, + initial_prompt: Option<&'a str>, + ) -> LocalFuture<'a, Result<(), Error>> { + self.launches + .borrow_mut() + .push((resume.map(str::to_owned), initial_prompt.map(str::to_owned))); + self.launch_tokens.borrow_mut().push(token.clone()); + self.launch_started.notify_one(); + let fail = self.fail_start.get(); + self.present.set(!fail); + Box::pin(async move { + if fail { + Err(Error::Session("injected uncertain launch".into())) + } else { + Ok(()) + } + }) + } + + fn stop<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + ) -> LocalFuture<'a, Result<(), Error>> { + self.stop_calls.set(self.stop_calls.get() + 1); + if self.fail_stop_once.replace(false) || self.stops_failing.get() { + return Box::pin(async { Err(Error::Session("injected stop failure".into())) }); + } + self.present.set(false); + Box::pin(async { Ok(()) }) + } + + fn input_ready<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + ) -> LocalFuture<'a, Result> { + if self.fail_input_ready_once.replace(false) { + return Box::pin(async { Err(Error::Session("injected readiness failure".into())) }); + } + Box::pin(async { Ok(self.ready_without_report.get()) }) + } + + fn prompt<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + prompt: &'a str, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + tokio::time::sleep(self.delivery_delay.get()).await; + self.sent.borrow_mut().push(prompt.to_owned()); + self.conversation.borrow_mut().push(user_turn(prompt)); + self.delivered.notify_one(); + if self.hold_completion.get() { + self.release_completion.notified().await; + } + Ok(()) + }) + } + + fn turns<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + last: Option, + ) -> LocalFuture<'a, Result, Error>> { + let mut turns = self.conversation.borrow().clone(); + if let Some(last) = last + && turns.len() > last + { + turns.drain(0..turns.len() - last); + } + Box::pin(async move { + if self.fail_transcript.get() { + return Err(Error::Session("transcript unavailable".into())); + } + Ok(turns) + }) + } + + fn attach<'a>( + &'a self, + _home: &'a std::path::Path, + _target: &'a agent::sessions::AttachTarget, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Err(Error::Invalid("no terminal in tests".into())) }) + } +} + +/// A database, a materialized memory Sandbox for Agent `worker`, and one +/// Running Session `s1` launched with `token`; with `started`, its harness has +/// already reported its start (native ID and conversation location). +/// The Agent's materialized assignment, observing exactly these harnesses. +fn observing(record: &AgentRecord, harnesses: &[agent::Harness]) -> SandboxAssignment { + let Some(SandboxAssignment::Materialized { provider, id, .. }) = &record.agent.status.sandbox else { + panic!("the fixture Agent has a materialized Sandbox"); + }; + SandboxAssignment::Materialized { + provider: provider.clone(), + id: id.clone(), + harnesses: harnesses.to_vec(), + } +} + +/// Observes every declared harness, as a convergence with all host logins present would. +fn observe_all_harnesses(record: &mut AgentRecord) { + let declared = record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(); + if let Some(SandboxAssignment::Materialized { harnesses, .. }) = &mut record.agent.status.sandbox { + *harnesses = declared; + } +} + +async fn running_session( + directory: &TempDir, + token: &str, + started: bool, +) -> ( + persistence::Database, + Rc, + agent::sessions::Session, +) { + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = + SandboxService::new(backend).with_network_backend(Rc::new(sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ))); + let mut record = ready_record("worker", agent_id); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: Vec::new(), + }); + observe_all_harnesses(&mut record); + database.put(record, 0).await.expect("Agent"); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: Rc::new(Cell::new(0)), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let activation = database.activate_session(session.id).await.expect("activate"); + database + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::running(), activation) + .await + .expect("running"); + database + .record_session_launch( + session.id, + agent::sessions::LaunchRecord { + token: token.parse().expect("launch token"), + sandbox: sandbox.id().to_string(), + launched_at: time::OffsetDateTime::now_utc().unix_timestamp(), + attempts: 1, + }, + ) + .await + .expect("launch bookkeeping"); + if started { + let token: agent::sessions::LaunchToken = token.parse().expect("launch token"); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-0", + Some("/home/agent/conversation.jsonl"), + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("start report"); + } + let session = database.get_session(session.id).await.expect("Session"); + (database, sandboxes, session) +} + +async fn resume_fixture( + directory: &TempDir, + token: &str, +) -> ( + persistence::Database, + Rc, + Rc, + agent::sessions::Session, +) { + let (database, sandboxes, session) = running_session(directory, token, true).await; + database + .reset_session_launch_attempts(session.id) + .await + .expect("reset backoff"); + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + let reconciler = Rc::new(agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + )); + (database, runtime, reconciler, session) +} + +async fn interrupt_resume_after_start( + database: &persistence::Database, + runtime: &FakeRuntime, + reconciler: &Rc, + session: &agent::sessions::Session, +) { + let reconciling = { + let reconciler = reconciler.clone(); + let id = session.id; + tokio::task::spawn_local(async move { reconciler.reconcile(id).await }) + }; + runtime.launch_started.notified().await; + let token = runtime.launch_tokens.borrow().last().expect("launch token").clone(); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-0", + Some("/home/agent/conversation.jsonl"), + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("resumed start report"); + reconciling.abort(); + reconciling.await.expect_err("reconciliation interrupted"); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn prompt_waits_for_completion_and_turns_are_read_separately() { + const TOKEN: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + // An earlier exchange the hook counter never saw (it was folded before a + // relaunch); it remains available through the separate turns operation. + let mut earlier = user_turn("earlier prompt"); + earlier.messages.push(assistant_text("earlier answer")); + runtime.conversation.borrow_mut().push(earlier); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let name = SessionName::new("s1").expect("name"); + + let sending_service = service.clone(); + let sending_name = name.clone(); + let delivered = runtime.delivered.notified(); + let send = tokio::task::spawn_local(async move { + sending_service + .prompt( + "worker", + &sending_name, + "do the thing", + true, + Some(Duration::from_secs(10)), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), delivered) + .await + .expect("prompt delivery"); + assert_eq!(runtime.sent.borrow().as_slice(), ["do the thing"]); + assert!( + !send.is_finished(), + "the wait blocks until the harness reports the turn complete" + ); + + // The harness works, then reports the turn complete through the hook; the + // Platform API folds it durably for the service to poll. + let token: agent::sessions::LaunchToken = TOKEN.parse().expect("token"); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnStarted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold"); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!send.is_finished(), "working is not done"); + runtime + .conversation + .borrow_mut() + .last_mut() + .expect("the sent turn") + .messages + .push(assistant_text("did the thing")); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnCompleted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold"); + + send.await.expect("send task").expect("turn completed"); + + // Without wait the delivery returns immediately and reads nothing. + service + .prompt("worker", &name, "and another", false, None) + .await + .expect("send"); + assert_eq!(runtime.sent.borrow().len(), 2); + + // `turns` reads the whole conversation; `last` trims it. + let all = service.turns("worker", &name, None).await.expect("turns"); + assert_eq!(all.len(), 3); + let last = service.turns("worker", &name, Some(1)).await.expect("turns"); + assert_eq!(last.len(), 1); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_wait_reports_a_failed_session_instead_of_hanging() { + const TOKEN: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + Rc::new(FakeRuntime::default()), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let name = SessionName::new("s1").expect("name"); + + let sending_service = service.clone(); + let sending_name = name.clone(); + let send = tokio::task::spawn_local(async move { + sending_service + .prompt( + "worker", + &sending_name, + "do the thing", + true, + Some(Duration::from_mins(1)), + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!send.is_finished()); + // No activity report arrives; the lifecycle write itself wakes the wait. + session_store + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::failed("harness exited"), 1) + .await + .expect("failed"); + let error = send + .await + .expect("send task") + .expect_err("a failed Session ends the wait"); + assert!(error.to_string().contains("harness exited"), "{error}"); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn prompt_wait_handles_mid_turn_input_after_a_late_start_report() { + const TOKEN: &str = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, false).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let name = SessionName::new("s1").expect("name"); + let token: agent::sessions::LaunchToken = TOKEN.parse().expect("token"); + + // The Session has launched but its harness has not reported its start: + // nothing is delivered until it does. + let sending_service = service.clone(); + let sending_name = name.clone(); + let send = tokio::task::spawn_local(async move { + sending_service + .prompt( + "worker", + &sending_name, + "steer left", + true, + Some(Duration::from_secs(10)), + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(runtime.sent.borrow().is_empty(), "no delivery before the start report"); + + // The harness starts on its first prompt and is mid-turn when it reports. + runtime.conversation.borrow_mut().push(user_turn("first prompt")); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-1", + Some("/home/agent/t.jsonl"), + time::OffsetDateTime::now_utc(), + ) + .await + .expect("start report"); + // The event is stamped in the past so the input-readiness grace is over. + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnStarted, + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("fold"); + tokio::time::timeout(Duration::from_secs(1), runtime.delivered.notified()) + .await + .expect("delivery after the readiness poll"); + assert_eq!( + runtime.sent.borrow().as_slice(), + ["steer left"], + "delivered once the start is reported" + ); + // The fake appended the steering input as a new turn; a real harness folds + // it into the running turn, so model that: merge it back. + let steer = runtime.conversation.borrow_mut().pop().expect("steer turn"); + runtime + .conversation + .borrow_mut() + .last_mut() + .expect("running turn") + .messages + .extend(steer.messages); + runtime + .conversation + .borrow_mut() + .last_mut() + .expect("running turn") + .messages + .push(assistant_text("went left")); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnCompleted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold"); + + send.await.expect("send task").expect("turn completed"); + agent_task.abort(); + session_task.abort(); +} + +/// A Session service over a started `s1` with a fake runtime, with a database +/// the test writes harness reports through. +struct ServiceHarness { + database: persistence::Database, + runtime: Rc, + service: Rc, + session: agent::sessions::Session, + token: agent::sessions::LaunchToken, + tasks: Vec>, +} + +impl ServiceHarness { + async fn start(directory: &TempDir, token: &str) -> Self { + Self::start_with_report(directory, token, true).await + } + + async fn start_with_report(directory: &TempDir, token: &str, started: bool) -> Self { + let (database, sandboxes, session) = running_session(directory, token, started).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let tasks = vec![ + tokio::task::spawn_local(agent_controller.run()), + tokio::task::spawn_local(session_controller.run()), + ]; + // The controller's startup pass writes a lifecycle. Finish it here so + // it cannot land after a lifecycle the test writes itself. + session_wakeup + .reconcile(session.id) + .await + .expect("startup Session reconciliation"); + let service = Rc::new(agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + Self { + database, + runtime, + service, + session, + token: token.parse().expect("token"), + tasks, + } + } + + /// Reports one activity event for the current launch, as the Platform API would. + async fn report(&self, event: agent::sessions::ActivityEvent) { + self.database + .apply_session_activity_for_launch( + self.session.id, + &self.token, + uuid::Uuid::new_v4(), + event, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold") + .expect("current launch"); + } + + fn append_to_last_turn(&self, message: agent::sessions::Message) { + self.runtime + .conversation + .borrow_mut() + .last_mut() + .expect("a turn") + .messages + .push(message); + } + + fn prompt(&self, text: &'static str) -> tokio::task::JoinHandle> { + let service = self.service.clone(); + tokio::task::spawn_local(async move { + service + .prompt( + "worker", + &SessionName::new("s1").expect("name"), + text, + true, + Some(Duration::from_secs(10)), + ) + .await + }) + } + + async fn await_delivery(&self, answer: &mut tokio::task::JoinHandle>) { + tokio::time::timeout(Duration::from_secs(5), async { + tokio::select! { + () = self.runtime.delivered.notified() => {}, + result = answer => panic!("prompt finished before delivery acknowledgement: {result:?}"), + } + }) + .await + .expect("prompt delivery acknowledgement"); + } + + fn finish(self) { + for task in self.tasks { + task.abort(); + } + drop(self.database); + } +} + +#[tokio::test(flavor = "local")] +async fn upgrade_preflight_reports_work_and_terminal_attachments() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "10101010-1010-4010-8010-101010101010").await; + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("working blockers") + .blockers, + ["session/worker/s1 (working)"] + ); + + harness + .database + .set_session_archived("worker", &harness.session.name, true) + .await + .expect("archive"); + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("archiving blockers") + .blockers, + ["session/worker/s1 (working)"], + "an archive waiting for the turn does not let an upgrade cut it short" + ); + harness + .database + .set_session_archived("worker", &harness.session.name, false) + .await + .expect("unarchive"); + + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + assert!( + harness + .service + .upgrade_readiness() + .await + .expect("quiescent") + .blockers + .is_empty() + ); + harness.runtime.attached.set(true); + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("attachment blockers") + .blockers, + ["session/worker/s1 (terminal attached)"] + ); + harness.finish(); + + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start_with_report(&directory, "11111111-1111-4111-8111-111111111111", false).await; + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("missing native ID") + .warnings, + ["session/worker/s1 will start a new conversation"] + ); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn daemon_owned_relaunch_marker_is_retryable_and_removed_after_success() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "20202020-2020-4020-8020-202020202020").await; + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + let matched_generation = harness + .database + .activate_session(harness.session.id) + .await + .expect("matched activation"); + harness + .database + .update_session_lifecycle( + harness.session.id, + agent::sessions::Lifecycle::idle(), + matched_generation, + ) + .await + .expect("Idle Session"); + let marker = home.pending_session_relaunch_path(); + std::fs::write(&marker, br#"{"buildVersion":"another-build"}"#).expect("write mismatched marker"); + let mismatch = agent::upgrade::consume_pending_session_relaunch(&home, &harness.service) + .await + .expect_err("wrong daemon build"); + assert!(mismatch.to_string().contains("another-build")); + assert!(marker.exists(), "a mismatched daemon retains the marker"); + std::fs::write( + &marker, + serde_json::to_vec(&serde_json::json!({"buildVersion": agent::build_version()})).expect("marker"), + ) + .expect("write marker"); + + harness.runtime.attached.set(true); + agent::upgrade::consume_pending_session_relaunch(&home, &harness.service) + .await + .expect_err("attachment prevents relaunch"); + assert!(marker.exists(), "failed pass retains its marker"); + + harness.runtime.attached.set(false); + agent::upgrade::consume_pending_session_relaunch(&home, &harness.service) + .await + .expect("relaunch"); + assert!(!marker.exists(), "successful pass removes its marker"); + assert!(!harness.runtime.present.get()); + assert_eq!(harness.runtime.stop_calls.get(), 1); + let reactivated = harness + .database + .get_session(harness.session.id) + .await + .expect("reactivated Session"); + assert_eq!( + reactivated.status.lifecycle.state, + agent::sessions::LifecycleState::Idle + ); + assert_eq!( + harness + .database + .activate_session(harness.session.id) + .await + .expect("activation after marker"), + matched_generation + 2, + "the marker pass must request one new activation" + ); + assert_eq!( + harness + .database + .session_launch_state(harness.session.id) + .await + .expect("launch state") + .expect("launch") + .attempts, + 0 + ); + + harness + .service + .relaunch_after_upgrade() + .await + .expect("idempotent retry"); + assert_eq!(harness.runtime.stop_calls.get(), 1); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn upgrade_reactivates_an_idle_session_whose_runtime_is_already_missing() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "30303030-3030-4030-8030-303030303030").await; + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + let matched_generation = harness + .database + .activate_session(harness.session.id) + .await + .expect("matched activation"); + harness + .database + .update_session_lifecycle( + harness.session.id, + agent::sessions::Lifecycle::idle(), + matched_generation, + ) + .await + .expect("Idle Session"); + harness.runtime.present.set(false); + + harness + .service + .relaunch_after_upgrade() + .await + .expect("request relaunch"); + + assert_eq!( + harness + .database + .activate_session(harness.session.id) + .await + .expect("activation after upgrade"), + matched_generation + 2, + "the upgrade must request an activation before reconciliation" + ); + assert_eq!(harness.runtime.stop_calls.get(), 0); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_waits_for_one_more_completion_without_reading_the_transcript() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "11111111-1111-4111-8111-111111111111").await; + harness.runtime.fail_transcript.set(true); + // A previous completion does not satisfy this invocation. Neither does a + // permission wait in the current turn. + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + let mut waiting = harness.prompt("continue"); + harness.await_delivery(&mut waiting).await; + for event in [ + agent::sessions::ActivityEvent::TurnStarted, + agent::sessions::ActivityEvent::WaitingForInput, + ] { + harness.report(event).await; + } + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut waiting) + .await + .is_err() + ); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + tokio::time::timeout(Duration::from_secs(2), waiting) + .await + .expect("completion reports") + .expect("task") + .expect("prompt"); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_waits_for_a_turn_that_started_before_delivery_finished() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "22222222-2222-4222-8222-222222222222").await; + harness.runtime.fail_transcript.set(true); + harness.runtime.hold_completion.set(true); + harness.report(agent::sessions::ActivityEvent::TurnStarted).await; + let mut waiting = harness.prompt("queued input"); + harness.await_delivery(&mut waiting).await; + // The current turn finishes and queued input starts before delivery returns. + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + harness.report(agent::sessions::ActivityEvent::TurnStarted).await; + assert!(!waiting.is_finished(), "delivery must finish first"); + harness.runtime.release_completion.notify_one(); + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut waiting) + .await + .is_err() + ); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + tokio::time::timeout(Duration::from_secs(2), waiting) + .await + .expect("completion reports") + .expect("task") + .expect("prompt"); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_settles_after_completion_and_follows_turns_started_in_the_window() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "22222222-2222-4222-8222-222222222222").await; + harness.runtime.fail_transcript.set(true); + let mut waiting = harness.prompt("steer or queue"); + harness.await_delivery(&mut waiting).await; + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut waiting) + .await + .is_err(), + "a completion must settle before returning" + ); + harness.report(agent::sessions::ActivityEvent::TurnStarted).await; + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut waiting) + .await + .is_err(), + "a turn started during settling must complete" + ); + harness.report(agent::sessions::ActivityEvent::WaitingForInput).await; + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut waiting) + .await + .is_err(), + "a permission wait does not complete the new turn" + ); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + } + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut waiting) + .await + .is_err() + ); + tokio::time::timeout(Duration::from_secs(2), waiting) + .await + .expect("settled completion") + .expect("task") + .expect("prompt"); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn the_first_prompt_can_start_an_unreported_conversation() { + for wait in [false, true] { + let directory = TempDir::new().expect("temporary directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + harness + .database + .clear_session_report(harness.session.id) + .await + .expect("no conversation yet"); + harness.runtime.ready_without_report.set(true); + let service = harness.service.clone(); + let mut answer = tokio::task::spawn_local(async move { + service + .prompt( + "worker", + &SessionName::new("s1").expect("name"), + "first input", + wait, + Some(Duration::from_secs(10)), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(5), harness.runtime.delivered.notified()) + .await + .expect("first input must not wait for its own start report"); + if wait { + assert!(!answer.is_finished(), "delivery alone is not turn completion"); + harness + .database + .record_session_start_for_launch( + harness.session.id, + &harness.token, + uuid::Uuid::new_v4(), + "new-conversation", + Some("/new.jsonl"), + time::OffsetDateTime::now_utc(), + ) + .await + .expect("start report"); + harness.append_to_last_turn(assistant_text("first answer")); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + } + (&mut answer).await.expect("task").expect("prompt"); + harness.finish(); + } +} + +#[tokio::test(flavor = "local")] +async fn a_prompt_without_wait_still_waits_for_the_harness_to_report_in() { + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = + running_session(&directory, "33333333-3333-4333-8333-333333333333", false).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let fire_and_forget = { + let service = service.clone(); + tokio::task::spawn_local(async move { + service + .prompt("worker", &SessionName::new("s1").expect("name"), "go", false, None) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + runtime.sent.borrow().is_empty(), + "nothing is pasted into a harness that has not reported in" + ); + let token: agent::sessions::LaunchToken = "33333333-3333-4333-8333-333333333333".parse().expect("token"); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-1", + None, + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("start report"); + fire_and_forget.await.expect("task").expect("delivered"); + assert_eq!(runtime.sent.borrow().as_slice(), ["go"]); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_failed_initial_launch_recovers_without_replaying_the_prompt() { + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, _) = running_session(&directory, "ffffffff-ffff-4fff-8fff-ffffffffffff", true).await; + let session = database + .ensure_session( + "worker", + &SessionName::new("uncertain").expect("name"), + NewSession { + initial_prompt: Some("perform once".into()), + ..NewSession::for_harness(agent::Harness::ClaudeCode) + }, + ) + .await + .expect("Session"); + database.activate_session(session.id).await.expect("activate"); + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + runtime.fail_start.set(true); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + reconciler.reconcile(session.id).await.expect_err("failed launch"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(None, Some("perform once".into()))] + ); + database + .reset_session_launch_attempts(session.id) + .await + .expect("reset backoff"); + runtime.fail_start.set(false); + reconciler.reconcile(session.id).await.expect("automatic recovery"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(None, Some("perform once".into())), (None, None)] + ); + assert_eq!( + database + .get_session(session.id) + .await + .expect("Session") + .status + .lifecycle + .state, + agent::sessions::LifecycleState::Running + ); +} + +#[tokio::test(flavor = "local")] +async fn a_fresh_launch_carries_the_first_prompt_and_a_resume_does_not() { + const TOKEN: &str = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, existing) = running_session(&directory, TOKEN, true).await; + let sandbox_id = database + .session_launch_state(existing.id) + .await + .expect("launch state") + .expect("recorded launch") + .sandbox; + let prompted = database + .ensure_session( + "worker", + &SessionName::new("prompted").expect("name"), + NewSession { + initial_prompt: Some("start here".into()), + ..NewSession::for_harness(agent::Harness::ClaudeCode) + }, + ) + .await + .expect("Session"); + database.activate_session(prompted.id).await.expect("activate"); + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + + reconciler.reconcile(prompted.id).await.expect("first launch"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(None, Some("start here".to_owned()))], + "the first launch starts on the first prompt" + ); + // A fresh conversation later (nothing reported to resume) starts empty. + // Clear the crash backoff the first launch armed so the pass relaunches now. + database + .reset_session_launch_attempts(prompted.id) + .await + .expect("reset attempts"); + runtime.present.set(false); + reconciler.reconcile(prompted.id).await.expect("fresh relaunch"); + assert_eq!( + runtime.launches.borrow().last().expect("second launch"), + &(None, None), + "a Sandbox replacement does not replay the task" + ); + + // Once the harness has reported a conversation, a relaunch resumes it and + // does not repeat the prompt. + let token: agent::sessions::LaunchToken = "abababab-abab-4bab-8bab-abababababab".parse().expect("token"); + database + .record_session_launch( + prompted.id, + agent::sessions::LaunchRecord { + token: token.clone(), + sandbox: sandbox_id, + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("launch bookkeeping"); + database + .record_session_start_for_launch( + prompted.id, + &token, + uuid::Uuid::new_v4(), + "native-1", + Some("/home/agent/t.jsonl"), + time::OffsetDateTime::now_utc(), + ) + .await + .expect("start report"); + runtime.present.set(false); + runtime.ready_without_report.set(true); + reconciler.reconcile(prompted.id).await.expect("relaunch"); + assert_eq!( + runtime.launches.borrow().last().expect("third launch"), + &(Some("native-1".to_owned()), None) + ); + let relaunched = database.get_session(prompted.id).await.expect("Session"); + assert_eq!( + relaunched.status.reported.harness_transcript_path.as_deref(), + Some("/home/agent/t.jsonl"), + "a relaunch in the same Sandbox keeps the reported conversation" + ); + assert_eq!( + relaunched.status.state, + agent::sessions::State::WaitingForInput, + "a resumed harness settles once its input is visible" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_resumed_start_report_settles_at_waiting_for_input() { + const TOKEN: &str = "45454545-4545-4545-8545-454545454545"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + + let reconciling = { + let reconciler = reconciler.clone(); + tokio::task::spawn_local(async move { reconciler.reconcile(session.id).await }) + }; + runtime.launch_started.notified().await; + let token = runtime.launch_tokens.borrow().last().expect("launch token").clone(); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-0", + Some("/home/agent/conversation.jsonl"), + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("resumed start report"); + runtime.ready_without_report.set(true); + reconciling.await.expect("task").expect("reconciliation"); + + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn an_unready_resumed_harness_is_stopped_and_fails() { + const TOKEN: &str = "56565656-5656-4565-8565-565656565656"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + + let reconciling = { + let reconciler = reconciler.clone(); + tokio::task::spawn_local(async move { reconciler.reconcile(session.id).await }) + }; + runtime.launch_started.notified().await; + tokio::time::advance(Duration::from_secs(16)).await; + let error = reconciling + .await + .expect("task") + .expect_err("an unready resume must fail"); + + assert!(error.to_string().contains("did not become ready"), "{error}"); + assert_eq!(runtime.stop_calls.get(), 1); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Failed + ); +} + +#[tokio::test(flavor = "local")] +async fn interrupted_resume_readiness_is_finished_by_the_next_reconciliation() { + const TOKEN: &str = "67676767-6767-4767-8767-676767676767"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + + interrupt_resume_after_start(&database, runtime.as_ref(), &reconciler, &session).await; + runtime.ready_without_report.set(true); + + reconciler.reconcile(session.id).await.expect("retry readiness"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local")] +async fn observation_failure_preserves_pending_resume_readiness() { + const TOKEN: &str = "89898989-8989-4898-8989-898989898989"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + interrupt_resume_after_start(&database, runtime.as_ref(), &reconciler, &session).await; + + runtime.fail_observe_once.set(true); + reconciler.reconcile(session.id).await.expect_err("observation failure"); + let interrupted = database.get_session(session.id).await.expect("Session"); + assert_eq!( + interrupted.status.lifecycle.state, + agent::sessions::LifecycleState::Resuming + ); + assert!( + interrupted + .status + .lifecycle + .failure + .is_some_and(|failure| failure.contains("observation failure")) + ); + runtime.ready_without_report.set(true); + reconciler.reconcile(session.id).await.expect("retry readiness"); + + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local")] +async fn temporarily_unready_agent_preserves_pending_resume_readiness() { + const TOKEN: &str = "90909090-9090-4909-8909-909090909090"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + interrupt_resume_after_start(&database, runtime.as_ref(), &reconciler, &session).await; + + let owner = database.get(session.agent_id).await.expect("Agent"); + database + .update_status(session.agent_id, owner.agent.metadata.generation, Status::default()) + .await + .expect("temporarily unready Agent"); + reconciler.reconcile(session.id).await.expect("observe unready Agent"); + assert_eq!( + database + .get_session(session.id) + .await + .expect("Session") + .status + .lifecycle + .state, + agent::sessions::LifecycleState::Resuming + ); + database + .update_status(session.agent_id, owner.agent.metadata.generation, owner.agent.status) + .await + .expect("restore ready Agent"); + runtime.ready_without_report.set(true); + reconciler.reconcile(session.id).await.expect("retry readiness"); + + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local")] +async fn transient_resume_readiness_failure_is_retried() { + const TOKEN: &str = "78787878-7878-4787-8787-787878787878"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + runtime.ready_without_report.set(true); + runtime.fail_input_ready_once.set(true); + + reconciler.reconcile(session.id).await.expect("retry readiness"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +/// A Session service over a two-harness Agent whose installations declare +/// manifest defaults: Claude Code (default) selects `fable`, Codex `high` effort. +struct SelectionFixture { + database: persistence::Database, + record: agent::control_plane::AgentRecord, + service: agent::sessions::Service, + agent_task: tokio::task::JoinHandle<()>, + session_task: tokio::task::JoinHandle<()>, +} + +async fn selection_fixture(directory: &TempDir) -> SelectionFixture { + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut record = ready_record("worker", agent_id); + record.agent.spec.harnesses[0].default = true; + record.agent.spec.harnesses[0].defaults.model = Some(agent::Model::new("fable").expect("model")); + record.agent.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: Some("0.149.1".into()), + auth: agent::HarnessAuthMode::Mediated, + optional: true, + default: false, + defaults: agent::ModelSelection { + model: None, + effort: Some(agent::Effort::new("high").expect("effort")), + }, + }); + observe_all_harnesses(&mut record); + database.put(record.clone(), 0).await.expect("Agent"); + let agent_store: Rc = Rc::new(database.clone()); + let session_store: Rc = Rc::new(database.clone()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new( + agent_store.clone(), + unused_sandboxes(), + )), + tmux_runtime(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + + SelectionFixture { + database, + record, + service, + agent_task, + session_task, + } +} + +/// The original race: a Session admitted before its Agent had ever converged. +/// +/// Admission cannot know whether an optional harness will be installed, because nothing has been +/// observed yet, so the Session is persisted and activated. Convergence then omits Codex. The +/// reconciler must park the Session rather than launch a harness the image ships but nothing +/// authenticated — and must start it once a later convergence installs Codex. +#[tokio::test(flavor = "local")] +async fn a_session_admitted_before_convergence_is_parked_until_its_optional_harness_is_installed() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = + SandboxService::new(backend).with_network_backend(Rc::new(sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ))); + let mut record = ready_record("worker", agent_id); + record.agent.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: None, + auth: agent::HarnessAuthMode::Mediated, + optional: true, + default: false, + defaults: agent::ModelSelection::default(), + }); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + // Convergence found no Codex host login, so it installed and observed only Claude Code. + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: vec![agent::Harness::ClaudeCode], + }); + database.put(record.clone(), 0).await.expect("Agent"); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: Rc::new(Cell::new(0)), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + + // The Session was admitted on Codex before the Agent had observed anything. + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::Codex), + ) + .await + .expect("Session"); + database.activate_session(session.id).await.expect("activate"); + + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + let reconciler = Rc::new(agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + )); + + reconciler + .reconcile(session.id) + .await + .expect("parked rather than failed"); + let parked = database.get_session(session.id).await.expect("Session"); + assert_eq!(parked.status.lifecycle.state, agent::sessions::LifecycleState::Starting); + assert!( + parked + .status + .lifecycle + .failure + .as_deref() + .is_some_and(|reason| reason.contains("does not carry harness")), + "the parked reason names the missing harness: {:?}", + parked.status.lifecycle.failure + ); + assert!( + runtime.launch_tokens.borrow().is_empty(), + "an uninstalled harness must not be launched" + ); + + // `agentctl codex login`, and the next Agent convergence installs and observes it. + observe_all_harnesses(&mut record); + database + .update_status(agent_id, record.agent.metadata.generation, record.agent.status.clone()) + .await + .expect("observed status"); + + reconciler.reconcile(session.id).await.expect("starts once installed"); + assert!( + !runtime.launch_tokens.borrow().is_empty(), + "the Session starts once its harness is installed" + ); +} + +/// A failed convergence pass keeps a valid observation, so the refusal must still apply. +/// +/// The Agent materialized its Sandbox and observed only Claude Code, then a later pass failed and +/// cleared readiness while preserving that observation. Gating on readiness would defer here, bind +/// the name to Codex, and then skip the post-convergence check when `converge` returns the failure. +#[tokio::test(flavor = "local")] +async fn an_unready_agent_with_a_materialized_observation_still_refuses_an_absent_harness() { + let directory = TempDir::new().expect("temporary directory"); + let SelectionFixture { + database, + record, + service, + agent_task, + session_task, + } = selection_fixture(&directory).await; + + database + .update_status( + record.id, + record.agent.metadata.generation, + Status::observed( + record.agent.metadata.generation, + Some(observing(&record, &[agent::Harness::ClaudeCode])), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "SshAccessFailed".into(), + message: "ssh access failed".into(), + last_transition_time: None, + }], + ), + ) + .await + .expect("observed status"); + + let name = SessionName::new("codex-session").expect("name"); + let error = service + .ensure( + "worker", + &name, + SessionRequest { + harness: Some(agent::Harness::Codex), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("an absent harness is refused even while the Agent is not ready"); + assert!(error.to_string().contains("is not installed"), "{error}"); + assert!(matches!( + database.get_agent_session("worker", &name).await, + Err(Error::NotFound) + )); + + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_session_on_an_optional_harness_the_agent_does_not_carry_is_refused_without_persisting() { + let directory = TempDir::new().expect("temporary directory"); + let SelectionFixture { + database, + record, + service, + agent_task, + session_task, + } = selection_fixture(&directory).await; + + database + .update_status( + record.id, + record.agent.metadata.generation, + Status::observed( + record.agent.metadata.generation, + Some(observing(&record, &[agent::Harness::ClaudeCode])), + record.agent.status.conditions.clone(), + ), + ) + .await + .expect("observed status"); + + let name = SessionName::new("codex-session").expect("name"); + let error = service + .ensure( + "worker", + &name, + SessionRequest { + harness: Some(agent::Harness::Codex), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("an uninstalled optional harness is refused"); + assert!(error.to_string().contains("is not installed"), "{error}"); + assert!(matches!( + database.get_agent_session("worker", &name).await, + Err(Error::NotFound) + )); + + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_resolves_explicit_and_implicit_harnesses() { + let directory = TempDir::new().expect("temporary directory"); + let SelectionFixture { + database, + service, + agent_task, + session_task, + .. + } = selection_fixture(&directory).await; + + let invalid_name = SessionName::new("invalid-prompt").expect("name"); + let oversized_prompt = "'".repeat(17_000); + let error = service + .ensure( + "worker", + &invalid_name, + SessionRequest { + initial_prompt: Some(oversized_prompt.clone()), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("oversized initial prompt"); + assert!(error.to_string().contains("encoded launch argument")); + assert!(matches!( + database.get_agent_session("worker", &invalid_name).await, + Err(Error::NotFound) + )); + + let explicit = service + .ensure( + "worker", + &SessionName::new("explicit").expect("name"), + SessionRequest { + harness: Some(agent::Harness::Codex), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect("explicit harness Session"); + let implicit = service + .ensure( + "worker", + &SessionName::new("implicit").expect("name"), + SessionRequest::default(), + WaitPolicy::FirstPass, + ) + .await + .expect("implicit default Session"); + + assert_eq!(explicit.session.harness, agent::Harness::Codex); + assert_eq!(implicit.session.harness, agent::Harness::ClaudeCode); + // Manifest defaults fill omitted selections per installation, and nothing else. + assert_eq!(explicit.session.model_selection.model_str(), None); + assert_eq!(explicit.session.model_selection.effort_str(), Some("high")); + assert_eq!(implicit.session.model_selection.model_str(), Some("fable")); + assert_eq!(implicit.session.model_selection.effort_str(), None); + + let conflict = service + .ensure( + "worker", + &SessionName::new("explicit").expect("name"), + SessionRequest { + harness: Some(agent::Harness::ClaudeCode), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("an existing Session keeps its harness"); + assert!(conflict.to_string().contains("already uses harness \"codex\"")); + + agent_task.abort(); + session_task.abort(); +} + +impl SelectionFixture { + async fn ensure(&self, name: &str, request: SessionRequest) -> Result { + let name = SessionName::new(name).expect("name"); + let target = self + .service + .ensure("worker", &name, request, WaitPolicy::FirstPass) + .await?; + Ok(target.session) + } +} + +fn selection(model: Option<&str>, effort: Option<&str>) -> SessionRequest { + SessionRequest { + model_selection: agent::ModelSelection { + model: model.map(|model| agent::Model::new(model).expect("model")), + effort: effort.map(|effort| agent::Effort::new(effort).expect("effort")), + }, + ..SessionRequest::default() + } +} + +fn recorded(session: &agent::sessions::Session) -> (Option<&str>, Option<&str>) { + ( + session.model_selection.model_str(), + session.model_selection.effort_str(), + ) +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_resolves_model_and_effort_with_manifest_defaults() { + let directory = TempDir::new().expect("temporary directory"); + let fixture = selection_fixture(&directory).await; + let implicit = fixture + .ensure("implicit", SessionRequest::default()) + .await + .expect("implicit default Session"); + assert_eq!(recorded(&implicit), (Some("fable"), None)); + + let chosen = fixture + .ensure("chosen", selection(Some("claude-opus-5"), Some("low"))) + .await + .expect("explicit selections Session"); + assert_eq!(chosen.harness, agent::Harness::ClaudeCode); + assert_eq!(recorded(&chosen), (Some("claude-opus-5"), Some("low"))); + + let same = fixture + .ensure("chosen", selection(Some("claude-opus-5"), None)) + .await + .expect("repeating the recorded selection is not a conflict"); + assert_eq!((same.id, recorded(&same)), (chosen.id, recorded(&chosen))); + let model_conflict = fixture + .ensure("chosen", selection(Some("fable"), None)) + .await + .expect_err("an existing Session keeps its model"); + assert!( + model_conflict + .to_string() + .contains("already uses model \"claude-opus-5\", not \"fable\""), + "{model_conflict}" + ); + let effort_conflict = fixture + .ensure("implicit", selection(None, Some("max"))) + .await + .expect_err("an existing Session keeps the harness default effort"); + assert!( + effort_conflict + .to_string() + .contains("leaves the effort to the harness default, not \"max\""), + "{effort_conflict}" + ); + + // A changed manifest default never reaches an existing Session. + let mut changed = fixture.record.clone(); + changed.agent.spec.harnesses[0].defaults.model = Some(agent::Model::new("claude-sonnet-5").expect("model")); + fixture + .database + .put(changed, 1) + .await + .expect("changed manifest defaults"); + let relaunched = fixture + .ensure("implicit", SessionRequest::default()) + .await + .expect("existing Session under changed defaults"); + assert_eq!(recorded(&relaunched), (Some("fable"), None)); + let fresh = fixture + .ensure("fresh", SessionRequest::default()) + .await + .expect("new Session under changed defaults"); + assert_eq!(recorded(&fresh), (Some("claude-sonnet-5"), None)); + fixture.agent_task.abort(); + fixture.session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn session_reconciliation_never_ensures_the_agent_sandbox() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = + SandboxService::new(backend).with_network_backend(Rc::new(sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ))); + let mut record = ready_record("worker", agent_id); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: Vec::new(), + }); + observe_all_harnesses(&mut record); + database.put(record, 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + + let ensure_calls = Rc::new(Cell::new(0)); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: ensure_calls.clone(), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + let sessions: Rc = Rc::new(database.clone()); + let agents: Rc = Rc::new(database); + let reconciler = agent::sessions::Reconciler::new( + sessions, + Rc::new(agent::sessions::AgentSandboxes::new(agents, sandboxes)), + tmux_runtime(), + "http://platform-api".into(), + ); + + let _result = reconciler.reconcile(session.id).await; + + assert_eq!( + ensure_calls.get(), + 0, + "Session reconciliation must not own Sandbox ensure effects" + ); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn idle_stop_uses_guest_activity_age_and_explicit_activation_relaunches() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = SandboxService::new(backend.clone()).with_network_backend(Rc::new( + sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ), + )); + let mut record = ready_record("worker", agent_id); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: Vec::new(), + }); + observe_all_harnesses(&mut record); + database.put(record, 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("idle").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let activation = database.activate_session(session.id).await.expect("activate Session"); + database + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::running(), activation) + .await + .expect("running status"); + database + .record_session_launch( + session.id, + agent::sessions::LaunchRecord { + token: "dddddddd-dddd-4ddd-8ddd-dddddddddddd".parse().expect("launch token"), + sandbox: sandbox.id().to_string(), + launched_at: time::OffsetDateTime::now_utc().unix_timestamp(), + attempts: 4, + }, + ) + .await + .expect("launch bookkeeping"); + + backend.queue_execution_events_matching( + is_session_observation, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("0 1900\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: Rc::new(Cell::new(0)), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + let sessions: Rc = Rc::new(database.clone()); + let agents: Rc = Rc::new(database.clone()); + let reconciler = agent::sessions::Reconciler::new( + sessions, + Rc::new(agent::sessions::AgentSandboxes::new(agents, sandboxes)), + tmux_runtime(), + "http://platform-api".into(), + ); + + reconciler.reconcile(session.id).await.expect("idle reconciliation"); + let idle = database.get_session(session.id).await.expect("Idle Session"); + assert_eq!(idle.status.lifecycle.state, agent::sessions::LifecycleState::Idle); + assert_eq!( + database + .session_launch_state(session.id) + .await + .expect("launch state") + .expect("recorded launch") + .attempts, + 0, + "an idle stop must not contribute to crash backoff" + ); + let after_idle = backend.execution_specs().len(); + reconciler + .reconcile(session.id) + .await + .expect("stable Idle reconciliation"); + assert_eq!( + backend.execution_specs().len(), + after_idle, + "periodic passes must leave Idle Sessions stopped" + ); + + database + .activate_session(session.id) + .await + .expect("explicit reactivation"); + backend.queue_execution_events_matching( + is_session_observation, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code: 10 }), + ], + ); + reconciler + .reconcile(session.id) + .await + .expect("reactivation reconciliation"); + assert_eq!( + database + .get_session(session.id) + .await + .expect("running Session") + .status + .lifecycle + .state, + agent::sessions::LifecycleState::Running + ); + + let commands = backend.execution_specs(); + assert!(commands.iter().any(|spec| matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/bin/sh" + && args.iter().any(|argument| { + argument.contains("/usr/bin/tmux list-sessions") + && argument.contains("/usr/bin/date +%s") + }) + ))); + assert!(commands.iter().any(|spec| matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/bin/sh" + && args.iter().any(|argument| argument.contains("/usr/bin/tmux kill-session")) + ))); + assert!(commands.iter().any(|spec| { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/tmux" + && args.windows(2).any(|arguments| arguments == [";", "new-session"]) + ) && spec + .working_directory() + .is_some_and(|path| path.as_str() == "/home/agent/code") + && spec.environment().get("LANG").map(String::as_str) == Some("C.UTF-8") + && matches!(spec.program(), Program::Command { args, .. } + if args.iter().any(|argument| argument == "CONTAINER_HOST=unix:///run/podman/podman.sock")) + })); +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_persists_intent_before_waiting_for_agent_convergence() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_store: Rc = Rc::new(database.clone()); + let session_store: Rc = Rc::new(database.clone()); + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let agent_reconciler: Rc> = Rc::new(BlockingAgentReady { + database: database.clone(), + started: started.clone(), + release: release.clone(), + }); + let session_reconciler: Rc> = Rc::new(MarkSessionReady(database.clone())); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + agent_reconciler, + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + session_reconciler, + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + tokio::time::sleep(Duration::from_millis(20)).await; + + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + database + .put( + AgentRecord { + id: agent_id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent: resource, + }, + 0, + ) + .await + .expect("Agent"); + let service = Rc::new(agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new( + agent_store.clone(), + unused_sandboxes(), + )), + tmux_runtime(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let ensure_service = service.clone(); + let ensure = tokio::task::spawn_local(async move { + ensure_service + .ensure( + "worker", + &SessionName::new("s1").expect("name"), + SessionRequest::default(), + WaitPolicy::FirstPass, + ) + .await + }); + + started.notified().await; + let sessions = database.list_agent_sessions("worker").await.expect("Sessions"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].name.as_str(), "s1"); + assert_eq!( + sessions[0].status.lifecycle.state, + agent::sessions::LifecycleState::Starting + ); + release.notify_one(); + let target = ensure.await.expect("ensure task").expect("ready Session"); + + assert_eq!(target.session.name.as_str(), "s1"); + assert_eq!( + target.session.status.lifecycle.state, + agent::sessions::LifecycleState::Running + ); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn controller_is_concurrent_across_sessions_and_serial_per_session() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + database.put(ready_record("worker", agent_id), 0).await.expect("Agent"); + + let session_store: Rc = Rc::new(database.clone()); + let slow = database + .ensure_session( + "worker", + &SessionName::new("slow").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("slow Session"); + let slow_calls = Rc::new(Cell::new(0)); + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let reconciler: Rc> = Rc::new(BlockingReconcile { + slow: slow.id, + slow_calls: slow_calls.clone(), + active_slow: Rc::new(Cell::new(0)), + started: started.clone(), + release: release.clone(), + }); + let (controller, wakeup) = agent::sessions::Controller::new( + session_store, + reconciler, + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected reconciliation error: {error}")), + ); + let controller_task = tokio::task::spawn_local(controller.run()); + + // The startup scan performs the first slow pass and blocks it. + started.notified().await; + let first_wakeup = wakeup.clone(); + let first = tokio::task::spawn_local(async move { first_wakeup.reconcile(slow.id).await }); + let rerun_wakeup = wakeup.clone(); + let rerun = tokio::task::spawn_local(async move { rerun_wakeup.reconcile(slow.id).await }); + + let fast = database + .ensure_session( + "worker", + &SessionName::new("fast").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("fast Session"); + tokio::time::timeout(Duration::from_secs(1), wakeup.reconcile(fast.id)) + .await + .expect("fast Session should not wait for slow Session") + .expect("fast reconciliation"); + + release.notify_one(); + first.await.expect("first task").expect("first reconciliation"); + rerun.await.expect("rerun task").expect("rerun reconciliation"); + assert_eq!(slow_calls.get(), 2); + controller_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_wait_does_not_follow_a_replacement_session_with_the_same_name() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + let mut waiting = harness.prompt("continue"); + harness.await_delivery(&mut waiting).await; + // Ensure the waiter will reread on its next activity poll. + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + tokio::time::sleep(Duration::from_millis(50)).await; + harness.database.mark_deleting("worker").await.expect("delete"); + harness + .database + .finalize_deletion(harness.session.agent_id, 1) + .await + .expect("finalize"); + harness + .database + .put( + ready_record( + "worker", + "f50fbec8-03a9-43ea-b65d-c15a86e9eb65".parse().expect("Agent ID"), + ), + 0, + ) + .await + .expect("replacement"); + let replacement = harness + .database + .ensure_session( + "worker", + &harness.session.name, + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + harness + .database + .update_session_lifecycle(replacement.id, agent::sessions::Lifecycle::running(), 0) + .await + .expect("running"); + let error = tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("original Session removal ends wait") + .expect("task") + .expect_err("original Session disappeared"); + assert!(matches!(error, Error::NotFound)); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn completion_timeout_starts_after_delivery() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + harness.runtime.delivery_delay.set(Duration::from_millis(300)); + let started = tokio::time::Instant::now(); + let error = harness + .service + .prompt( + "worker", + &harness.session.name, + "go", + true, + Some(Duration::from_millis(100)), + ) + .await + .expect_err("completion timeout"); + assert!(error.to_string().contains("prompt was submitted")); + assert!(started.elapsed() >= Duration::from_millis(400)); + assert_eq!(harness.runtime.sent.borrow().as_slice(), ["go"]); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn an_unsupported_completion_timeout_is_rejected_before_delivery() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + + let error = harness + .service + .prompt( + "worker", + &harness.session.name, + "go", + true, + Some(Duration::from_mins(31)), + ) + .await + .expect_err("unsupported completion timeout"); + + assert!(error.to_string().contains("must not exceed 30m")); + assert!(harness.runtime.sent.borrow().is_empty()); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn queued_deliveries_do_not_expire_and_remain_serialized() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + harness.runtime.hold_completion.set(true); + let send = |text| { + let service = harness.service.clone(); + let name = harness.session.name.clone(); + tokio::task::spawn_local(async move { + service + .prompt("worker", &name, text, false, Some(Duration::from_millis(50))) + .await + }) + }; + let mut first = send("first"); + harness.await_delivery(&mut first).await; + let second = send("second"); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!first.is_finished()); + assert!(!second.is_finished()); + assert_eq!(harness.runtime.sent.borrow().as_slice(), ["first"]); + harness.runtime.hold_completion.set(false); + harness.runtime.release_completion.notify_one(); + first.await.expect("task").expect("first delivery"); + second.await.expect("task").expect("second delivery"); + assert_eq!(harness.runtime.sent.borrow().as_slice(), ["first", "second"]); + harness.finish(); +} + +/// A Session marked for deletion is released by the Session controller: the +/// harness is stopped first, and only a successful stop removes the Session. +#[tokio::test(flavor = "local")] +async fn deleting_a_session_stops_its_harness_before_the_session_is_removed() { + const TOKEN: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + + database + .mark_session_deleting("worker", &name) + .await + .expect("mark deleting"); + + runtime.fail_stop_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("a harness that cannot be stopped fails the release"); + assert_eq!(runtime.stop_calls.get(), 1); + assert!( + database + .get_session(session.id) + .await + .expect("the Session survives a failed release") + .is_deleting(), + "the request survives so a later pass retries it" + ); + + reconciler.reconcile(session.id).await.expect("release"); + assert_eq!(runtime.stop_calls.get(), 2); + assert!(matches!(database.get_session(session.id).await, Err(Error::NotFound))); + assert!(database.list_all_sessions().await.expect("sessions").is_empty()); + reconciler + .reconcile(session.id) + .await + .expect("reconciling a removed Session is a no-op"); + assert_eq!(runtime.stop_calls.get(), 2); +} + +/// Nothing is left to stop when the Sandbox that held the harness is gone, so +/// the Session is removed without touching a Sandbox. +#[tokio::test(flavor = "local")] +async fn deleting_a_session_whose_sandbox_is_gone_still_removes_it() { + const TOKEN: &str = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let owner = database.get(session.agent_id).await.expect("Agent record"); + database + .update_status(session.agent_id, owner.agent.metadata.generation, Status::default()) + .await + .expect("Agent without a materialized Sandbox"); + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + + database + .mark_session_deleting("worker", &SessionName::new("s1").expect("name")) + .await + .expect("mark deleting"); + reconciler.reconcile(session.id).await.expect("release"); + + assert_eq!(runtime.stop_calls.get(), 0); + assert!(matches!(database.get_session(session.id).await, Err(Error::NotFound))); +} + +/// `delete` hides the Session immediately and returns once the controller has +/// released it, and the operations that address a Session by name stop finding it. +#[tokio::test(flavor = "local")] +async fn deleting_a_session_through_the_service_releases_it_and_hides_it_at_once() { + const TOKEN: &str = "abababab-abab-4bab-8bab-abababababab"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + sandboxes.clone(), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store.clone(), + sandboxes, + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + let name = SessionName::new("s1").expect("name"); + + assert_eq!(service.list(None).await.expect("sessions").len(), 1); + service.delete("worker", &name).await.expect("delete Session"); + + assert_eq!(runtime.stop_calls.get(), 1, "the harness is stopped, not left running"); + assert!(matches!(service.get("worker", &name).await, Err(Error::NotFound))); + assert!(service.list(None).await.expect("sessions").is_empty()); + assert!(matches!( + service.turns("worker", &name, None).await, + Err(Error::NotFound) + )); + assert!(matches!(database.get_session(session.id).await, Err(Error::NotFound))); + assert!(matches!(service.delete("worker", &name).await, Err(Error::NotFound))); + + agent_task.abort(); + session_task.abort(); +} + +/// A delete whose harness cannot be stopped yet reports that it is still +/// pending, rather than looking like it was refused. +#[tokio::test(flavor = "local")] +async fn a_delete_that_cannot_stop_the_harness_yet_reports_that_it_is_pending() { + const TOKEN: &str = "bcbcbcbc-bcbc-4cbc-8cbc-bcbcbcbcbcbc"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + runtime.stops_failing.set(true); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + sandboxes.clone(), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, _| {}), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store.clone(), + sandboxes, + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + let name = SessionName::new("s1").expect("name"); + + let pending = service.delete("worker", &name).await.expect_err("the stop failed"); + assert!( + pending + .to_string() + .contains("is marked for deletion and will be retried"), + "{pending}" + ); + assert!( + database + .get_session(session.id) + .await + .expect("still marked") + .is_deleting() + ); + + agent_task.abort(); + session_task.abort(); +} + +async fn turn_completed(database: &persistence::Database, session: &agent::sessions::Session, token: &str) { + database + .apply_session_activity_for_launch( + session.id, + &token.parse().expect("launch token"), + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::WaitingForInput, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("turn completed"); +} + +/// Archiving waits for the turn in progress, then stops the harness and keeps +/// it stopped; unarchiving leaves it stopped until the next attach. +#[tokio::test(flavor = "local")] +async fn archiving_waits_for_the_turn_and_keeps_the_harness_stopped_until_attached() { + const TOKEN: &str = "acacacac-acac-4cac-8cac-acacacacacac"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + let state = || async { database.get_session(session.id).await.expect("Session").status.state }; + + assert_eq!(state().await, agent::sessions::State::Working); + database + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + reconciler.reconcile(session.id).await.expect("archive pass"); + assert_eq!(runtime.stop_calls.get(), 0, "a turn in progress is not cut short"); + assert_eq!( + state().await, + agent::sessions::State::Archiving, + "archived, with its harness still finishing the turn" + ); + + turn_completed(&database, &session, TOKEN).await; + reconciler.reconcile(session.id).await.expect("archive pass"); + assert_eq!(runtime.stop_calls.get(), 1, "the harness stops once the turn has ended"); + assert_eq!(state().await, agent::sessions::State::Archived); + reconciler.reconcile(session.id).await.expect("periodic pass"); + assert_eq!(runtime.stop_calls.get(), 1, "an archived Session is left alone"); + + database + .set_session_archived("worker", &name, false) + .await + .expect("unarchive"); + assert_eq!( + state().await, + agent::sessions::State::Idle, + "Idle at once, before the reconciler settles its lifecycle" + ); + reconciler.reconcile(session.id).await.expect("unarchive pass"); + assert_eq!(state().await, agent::sessions::State::Idle); + assert!(runtime.launches.borrow().is_empty(), "unarchiving launches nothing"); + + runtime.ready_without_report.set(true); + database.activate_session(session.id).await.expect("attach"); + reconciler.reconcile(session.id).await.expect("attach pass"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(Some("native-0".to_owned()), None)], + "the first attach after unarchiving resumes the conversation" + ); +} + +/// An archive whose harness could not be stopped stays archived, so +/// unarchiving it never falls through to relaunching the harness. +#[tokio::test(flavor = "local")] +async fn unarchiving_after_a_failed_archive_does_not_relaunch_the_harness() { + const TOKEN: &str = "adadadad-adad-4dad-8dad-adadadadadad"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + turn_completed(&database, &session, TOKEN).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + + database + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + runtime.fail_stop_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("a harness that cannot be stopped fails the pass"); + let failed = database.get_session(session.id).await.expect("Session"); + assert_eq!( + failed.status.state, + agent::sessions::State::Archiving, + "archived, with a harness that may still run" + ); + assert!(failed.status.lifecycle.failure.is_some()); + + database + .set_session_archived("worker", &name, false) + .await + .expect("unarchive"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput, + "until the pass, the harness the failed stop left running reports for itself, not Idle" + ); + reconciler.reconcile(session.id).await.expect("unarchive pass"); + let adopted = database.get_session(session.id).await.expect("Session"); + assert_eq!( + adopted.status.lifecycle.state, + agent::sessions::LifecycleState::Running, + "a harness the failed stop left running is adopted" + ); + assert!(runtime.launches.borrow().is_empty()); + + database + .set_session_archived("worker", &name, true) + .await + .expect("archive again"); + runtime.fail_stop_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("the stop fails again"); + runtime.present.set(false); + database + .set_session_archived("worker", &name, false) + .await + .expect("unarchive again"); + reconciler.reconcile(session.id).await.expect("unarchive pass"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Idle, + "with the harness gone, the Session is Idle" + ); + assert!(runtime.launches.borrow().is_empty()); +} + +/// A Session that only looks mid-turn is archived at once: its harness has +/// exited, or it has been quiet too long to be working. +#[tokio::test(flavor = "local")] +async fn archiving_does_not_wait_for_a_turn_that_is_not_happening() { + const TOKEN: &str = "afafafaf-afaf-4faf-8faf-afafafafafaf"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + let state = || async { database.get_session(session.id).await.expect("Session").status.state }; + // Working, as a never-prompted Claude Code Session reads after its start report. + database + .apply_session_activity_for_launch( + session.id, + &TOKEN.parse().expect("launch token"), + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnStarted, + time::OffsetDateTime::now_utc() - time::Duration::hours(1), + ) + .await + .expect("an old report"); + assert_eq!(state().await, agent::sessions::State::Working); + + runtime.idle_seconds.set(3_600); + database + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + reconciler.reconcile(session.id).await.expect("archive pass"); + assert_eq!(runtime.stop_calls.get(), 1, "a quiet harness is not mid-turn"); + assert_eq!(state().await, agent::sessions::State::Archived); +} + +/// Through the service, an archived Session cannot be attached until it is +/// unarchived, and the first attach after that works. +#[tokio::test(flavor = "local")] +async fn an_archived_session_is_attached_again_only_after_unarchiving() { + const TOKEN: &str = "aeaeaeae-aeae-4eae-8eae-aeaeaeaeaeae"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + turn_completed(&database, &session, TOKEN).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + sandboxes.clone(), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store.clone(), + sandboxes, + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + let name = SessionName::new("s1").expect("name"); + let attach = || { + service.ensure( + "worker", + &name, + agent::sessions::SessionRequest::default(), + WaitPolicy::FirstPass, + ) + }; + + let archived = service.set_archived("worker", &name, true).await.expect("archive"); + assert_eq!(archived.status.state, agent::sessions::State::Archived); + let refused = attach().await.expect_err("archived Sessions are not attached"); + assert!(refused.to_string().contains("is archived"), "{refused}"); + service + .set_archived("worker", &name, true) + .await + .expect("archiving again is safe"); + + let unarchived = service.set_archived("worker", &name, false).await.expect("unarchive"); + assert_eq!(unarchived.status.state, agent::sessions::State::Idle); + runtime.ready_without_report.set(true); + attach().await.expect("the first attach after unarchiving works"); + + agent_task.abort(); + session_task.abort(); +} + +/// A failed archive pass records the Session as archived, but a retry still +/// waits for the harness's turn rather than reading that as the turn's end. +#[tokio::test(flavor = "local")] +async fn a_retried_archive_pass_still_waits_for_the_turn() { + const TOKEN: &str = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + database + .set_session_archived("worker", &SessionName::new("s1").expect("name"), true) + .await + .expect("archive"); + runtime.fail_observe_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("a failed observation fails the pass"); + + reconciler.reconcile(session.id).await.expect("retry"); + assert_eq!(runtime.stop_calls.get(), 0, "the turn in progress is not cut short"); + turn_completed(&database, &session, TOKEN).await; + reconciler.reconcile(session.id).await.expect("retry after the turn"); + assert_eq!(runtime.stop_calls.get(), 1); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Archived + ); +} + +/// A prompt waiting for its turn keeps waiting while an archive has not yet +/// stopped the harness, and completes with the turn. +#[tokio::test(flavor = "local")] +async fn a_prompt_wait_outlasts_an_archive_that_has_not_stopped_the_harness() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "a2a2a2a2-a2a2-4a2a-8a2a-a2a2a2a2a2a2").await; + let service = harness.service.clone(); + let name = harness.session.name.clone(); + let mut waiting = tokio::task::spawn_local(async move { + service + .prompt("worker", &name, "go", true, Some(Duration::from_secs(5))) + .await + }); + harness.await_delivery(&mut waiting).await; + harness + .database + .set_session_archived("worker", &harness.session.name, true) + .await + .expect("archive"); + harness + .database + .update_session_lifecycle( + harness.session.id, + agent::sessions::Lifecycle::archived_with("injected stop failure"), + 0, + ) + .await + .expect("a failed archive pass"); + tokio::time::sleep(Duration::from_millis(600)).await; + assert!(!waiting.is_finished(), "the turn is still running"); + + harness + .database + .apply_session_activity_for_launch( + harness.session.id, + &harness.token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnCompleted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("turn completed"); + waiting.await.expect("task").expect("the wait ends with the turn"); + harness.finish(); +} + +/// Records the run state of the Agent `worker` as `agentctl stop` and `start` do. +async fn set_worker_run_state(database: &persistence::Database, state: agent::RunState) { + agent::control_plane::ControlPlane::new(Rc::new(database.clone()), Rc::new(support::IgnoreNotifications)) + .set_run_state("worker", state) + .await + .expect("run state"); +} + +#[tokio::test(flavor = "local")] +async fn work_in_a_stopped_agent_is_refused_without_creating_a_session() { + const TOKEN: &str = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; + let directory = TempDir::new().expect("temporary directory"); + let harness = ServiceHarness::start(&directory, TOKEN).await; + set_worker_run_state(&harness.database, agent::RunState::Stopped).await; + let stored = harness.database.get_by_name("worker").await.expect("Agent"); + assert!( + stored.agent.spec.is_stopped(), + "the run state is stored with the desired state" + ); + let s1 = SessionName::new("s1").expect("name"); + let is_stopped = |error: &Error| matches!(error, Error::Stopped(name) if name == "worker"); + + let error = harness + .service + .prompt("worker", &s1, "hello", false, None) + .await + .expect_err("nothing runs to prompt"); + assert!(is_stopped(&error), "{error:?}"); + let error = harness + .service + .turns("worker", &s1, None) + .await + .expect_err("no guest to read"); + assert!(is_stopped(&error), "{error:?}"); + for name in ["s1", "s2"] { + let name = SessionName::new(name).expect("name"); + let error = harness + .service + .ensure("worker", &name, SessionRequest::default(), WaitPolicy::UntilConverged) + .await + .expect_err("nothing runs to attach to"); + assert!(is_stopped(&error), "{error:?}"); + } + assert!(matches!( + harness + .database + .get_agent_session("worker", &SessionName::new("s2").expect("name")) + .await, + Err(Error::NotFound) + )); + assert!(harness.runtime.sent.borrow().is_empty()); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn a_session_of_a_stopped_agent_goes_idle_and_the_next_attach_resumes_it() { + const TOKEN: &str = "b2b2b2b2-b2b2-4b2b-8b2b-b2b2b2b2b2b2"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + set_worker_run_state(&database, agent::RunState::Stopped).await; + + reconciler.reconcile(session.id).await.expect("idle"); + let state = |database: persistence::Database| async move { + database.get_session(session.id).await.expect("Session").status.state + }; + assert_eq!(state(database.clone()).await, agent::sessions::State::Idle); + assert!( + runtime.launch_tokens.borrow().is_empty(), + "no harness launches in a stopped Agent" + ); + + // A start alone launches nothing; the Session waits for its next attach. + set_worker_run_state(&database, agent::RunState::Running).await; + reconciler.reconcile(session.id).await.expect("still idle"); + assert_eq!(state(database.clone()).await, agent::sessions::State::Idle); + assert!(runtime.launch_tokens.borrow().is_empty()); + + database.activate_session(session.id).await.expect("attach"); + runtime.ready_without_report.set(true); + reconciler.reconcile(session.id).await.expect("resumed"); + assert_eq!(runtime.launch_tokens.borrow().len(), 1); + assert_eq!( + runtime + .launches + .borrow() + .last() + .and_then(|(resume, _)| resume.as_deref()), + Some("native-0"), + "the harness resumes its conversation" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_session_pass_in_flight_across_a_stop_and_start_launches_no_harness() { + const TOKEN: &str = "c3c3c3c3-c3c3-4c3c-8c3c-c3c3c3c3c3c3"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let session_store: Rc = Rc::new(database.clone()); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes.clone(), + )), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, _| {}), + ); + let agents = Rc::new( + agent::control_plane::Reconciler::new( + Rc::new(database.clone()), + sandboxes, + agent::progress::ProvisioningState::default(), + ) + .with_session_notifier(Rc::new(agent::sessions::AgentNotifier::new( + session_store, + session_wakeup.clone(), + Rc::new(|_| {}), + ))), + ); + let task = tokio::task::spawn_local(session_controller.run()); + session_wakeup.reconcile(session.id).await.expect("startup pass"); + + // A pass observing the running harness is held while the Agent stops. + runtime.hold_observe.set(true); + session_wakeup.notify(session.id); + runtime.observe_started.notified().await; + set_worker_run_state(&database, agent::RunState::Stopped).await; + let stopping = tokio::task::spawn_local({ + let agents = agents.clone(); + let id = session.agent_id; + async move { agents.reconcile(id).await } + }); + while !database + .get(session.agent_id) + .await + .expect("Agent") + .agent + .status + .is_stopped() + { + tokio::task::yield_now().await; + } + // The VM took the harness with it, and nothing launches into a stopped VM. + runtime.present.set(false); + runtime.fail_start.set(true); + // A start is asked for while the stop still waits for its Sessions. + set_worker_run_state(&database, agent::RunState::Running).await; + runtime.release_observe.notify_one(); + stopping.await.expect("stop task").expect("stop pass"); + runtime.fail_start.set(false); + let started = runtime.launch_tokens.borrow().len(); + agents.reconcile(session.agent_id).await.expect("start pass"); + session_wakeup + .reconcile(session.id) + .await + .expect("a pass after the start"); + + assert_eq!( + runtime.launch_tokens.borrow().len(), + started, + "a start launches no harness; the next attach does" + ); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Idle + ); + task.abort(); +} diff --git a/agentctl/tests/sessions.rs b/agentctl/tests/sessions.rs new file mode 100644 index 0000000..daa5330 --- /dev/null +++ b/agentctl/tests/sessions.rs @@ -0,0 +1,22 @@ +#![allow(clippy::expect_used)] + +use agent::sessions::{LaunchToken, SessionName}; + +#[test] +fn session_names_are_validated_at_construction_and_deserialization() { + let name = SessionName::new("review_1").expect("portable Session name"); + assert_eq!(name.as_str(), "review_1"); + + assert!(SessionName::new("contains spaces").is_err()); + assert!(serde_json::from_str::(r#""contains spaces""#).is_err()); +} + +#[test] +fn launch_tokens_are_typed_and_redacted() { + let raw = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let token = raw.parse::().expect("UUID launch token"); + + assert_eq!(token, raw.parse::().expect("same UUID launch token")); + assert_eq!(format!("{token:?}"), "LaunchToken([redacted])"); + assert!("not-a-token".parse::().is_err()); +} diff --git a/agentctl/tests/ssh_access.rs b/agentctl/tests/ssh_access.rs new file mode 100644 index 0000000..a1c71d3 --- /dev/null +++ b/agentctl/tests/ssh_access.rs @@ -0,0 +1,717 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{path::PathBuf, rc::Rc}; + +use agent::{ + AccessSpec, AgentId, Error, FailureKind, ReconcileFailure, + control_plane::{AgentRecord, AgentStore as _, memory::InMemoryAgentStore}, + local::home::ControlPlaneHome, + ssh::{self, Access, memory::InMemoryHostKeyStore}, +}; +use sandbox::{ + EnsureSandboxRequest, Platform, SandboxHandle, SandboxPath, SandboxService, + execution::{ExecutionEvent, ExecutionSpec, ExitStatus, Program}, + memory, +}; +use tempfile::TempDir; +use tokio::io::AsyncReadExt as _; + +const AGENTCTL: &str = "/usr/local/bin/agentctl"; + +fn command(spec: &ExecutionSpec) -> Option<(&str, Vec<&str>)> { + match spec.program() { + Program::Command { executable, args } => Some((executable.as_str(), args.iter().map(String::as_str).collect())), + Program::ImageEntrypoint => None, + } +} + +fn is_command(spec: &ExecutionSpec, executable: &str, expected: &[&str]) -> bool { + command(spec).is_some_and(|(actual, args)| actual == executable && args == expected) +} + +fn is_server_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-x", "/usr/sbin/sshd"]) +} + +fn is_systemctl_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-x", "/usr/bin/systemctl"]) +} + +fn is_environment_policy_check(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &[ + "-n", + "/usr/sbin/sshd", + "-T", + "-f", + "/var/lib/agent/ssh/sshd_config", + "-C", + "user=agent,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=2222", + ], + ) +} + +fn is_environment_snapshot(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/env", &["-0"]) +} + +fn is_runtime_directory_install(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &[ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0755", + "-o", + "root", + "-g", + "root", + "/var/lib/agent/ssh", + "/run/sshd", + ], + ) +} + +fn is_systemd_running_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-d", "/run/systemd/system"]) +} + +fn is_disable(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "disable", "--now", "agent-ssh.service"], + ) +} + +fn is_state_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-e", "/var/lib/agent/ssh"]) +} + +fn exited(code: i32) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code }), + ] +} + +fn environment(contents: &'static [u8]) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout(contents.into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ] +} + +fn valid_environment_policy() -> Vec { + environment(b"permituserenvironment yes\nusepam no\n") +} + +fn queue_valid_environment_policy(backend: &memory::Provider) { + backend.queue_execution_events_matching(is_environment_policy_check, valid_environment_policy()); +} + +fn count_sudo(backend: &memory::Provider, expected: &[&str]) -> usize { + backend + .execution_specs() + .iter() + .filter(|spec| is_command(spec, "/usr/bin/sudo", expected)) + .count() +} + +fn assert_service_reconciled_idempotently(backend: &memory::Provider) { + assert_eq!( + count_sudo(backend, &["-n", "/usr/bin/systemctl", "enable", "agent-ssh.service"]), + 2 + ); + assert_eq!( + count_sudo(backend, &["-n", "/usr/bin/systemctl", "restart", "agent-ssh.service"]), + 1 + ); + assert_eq!( + count_sudo(backend, &["-n", "/usr/bin/systemctl", "start", "agent-ssh.service"]), + 1 + ); +} + +fn assert_runtime_created_before_policy(backend: &memory::Provider) { + let executions = backend.execution_specs(); + let runtime_directory = executions + .iter() + .position(is_runtime_directory_install) + .expect("OpenSSH runtime directory install"); + let policy_validation = executions + .iter() + .position(is_environment_policy_check) + .expect("effective-policy validation"); + assert!( + runtime_directory < policy_validation, + "OpenSSH's runtime directory exists before policy validation" + ); +} + +async fn read_guest_file(sandbox: &SandboxHandle, path: &str) -> Option> { + let mut reader = sandbox.read_file(&SandboxPath::new(path)).await.ok()?; + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await.expect("guest file bytes"); + Some(bytes) +} + +async fn assert_guest_environment(sandbox: &SandboxHandle) { + assert_eq!( + read_guest_file(sandbox, "/home/agent/.ssh/environment").await, + Some( + b"CONTAINER_HOST=unix:///run/podman/podman.sock\nGIT_USER_NAME=Agent #1 \"Reviewer\"\nLANG=C.UTF-8\nNODE_EXTRA_CA_CERTS=/.msb/tls/ca.pem\nPATH=/home/agent/.cargo/bin:/usr/local/go/bin:/usr/bin\n" + .to_vec() + ) + ); +} + +fn record(name: &str, id: &str, ssh: bool) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + if ssh { + resource.spec.access = vec![AccessSpec::Ssh {}]; + } + AgentRecord { + id: id.parse::().expect("Agent ID"), + source_directory: PathBuf::from("/source").join(name), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +struct Fixture { + _directory: TempDir, + home: ControlPlaneHome, + store: Rc, + keys: Rc, + access: Access, + backend: Rc, +} + +impl Fixture { + fn new() -> Self { + let directory = TempDir::new().expect("temporary directory"); + let home = ControlPlaneHome::resolve(Some(&directory.path().join("agent-home"))).expect("home"); + home.prepare().expect("prepare home"); + let store = Rc::new(InMemoryAgentStore::new()); + let keys = Rc::new(InMemoryHostKeyStore::new()); + let access = Access::new(&home, PathBuf::from(AGENTCTL), keys.clone(), store.clone()).with_user_home(None); + Self { + _directory: directory, + home, + store, + keys, + access, + backend: Rc::new(memory::Provider::new()), + } + } + + async fn store(&self, record: &AgentRecord, expected_generation: u64) { + self.store + .put(record.clone(), expected_generation) + .await + .expect("store record"); + } + + async fn sandbox(&self, record: &AgentRecord) -> SandboxHandle { + let service = SandboxService::new(self.backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox") + } + + fn ssh_home(&self) -> ssh::SshHome { + ssh::SshHome::new(&self.home) + } + + fn config(&self) -> String { + std::fs::read_to_string(self.ssh_home().config_path()).expect("generated config") + } + + fn known_hosts(&self) -> String { + std::fs::read_to_string(self.ssh_home().known_hosts_path()).unwrap_or_default() + } +} + +#[tokio::test(flavor = "local")] +async fn access_is_idempotent_and_only_public_material_enters_the_guest() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + for _ in 0..2 { + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(0)); + queue_valid_environment_policy(&fixture.backend); + fixture.backend.queue_execution_events_matching( + is_environment_snapshot, + environment( + b"PATH=/home/agent/.cargo/bin:/usr/local/go/bin:/usr/bin\0NODE_EXTRA_CA_CERTS=/.msb/tls/ca.pem\0GIT_USER_NAME=Agent #1 \"Reviewer\"\0TERM=dumb\0HOME=/image-home\0", + ), + ); + } + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("first pass")); + assert_runtime_created_before_policy(&fixture.backend); + let host_key = read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key") + .await + .expect("host key in guest"); + let host_public = read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key.pub") + .await + .expect("host public key in guest"); + let authorized = read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .expect("authorized_keys in guest"); + let ssh_home = fixture.ssh_home(); + let client_private = std::fs::read_to_string(ssh_home.identity_path(record.id)).expect("client private key"); + let client_public = std::fs::read_to_string(ssh_home.public_identity_path(record.id)).expect("client public key"); + + assert!(fixture.keys.contains(record.id)); + assert!(host_key.starts_with(b"-----BEGIN OPENSSH PRIVATE KEY-----")); + assert!(client_private.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----")); + assert_ne!(host_key, client_private.as_bytes(), "host and client keys differ"); + assert_eq!(authorized, client_public.as_bytes()); + assert_guest_environment(&sandbox).await; + assert!(client_public.starts_with("ssh-ed25519 AAAA")); + let host_public = String::from_utf8(host_public).expect("UTF-8 public key"); + assert_eq!( + fixture.known_hosts(), + format!("agent-{id} {host_public}agentctl-worker {host_public}", id = record.id), + "known_hosts is pre-seeded under the incarnation alias and, for clients without HostKeyAlias, the Host alias" + ); + let expected_config = format!( + "\nHost agentctl-worker\n User agent\n ProxyCommand {AGENTCTL} ssh-proxy agent/worker\n HostKeyAlias agent-{id}\n IdentityFile {identity}\n UserKnownHostsFile {known_hosts}\n IdentitiesOnly yes\n", + id = record.id, + // The same renderer the config uses: on Windows the paths are quoted with escaped backslashes. + identity = ssh::render_path(&ssh_home.identity_path(record.id), None), + known_hosts = ssh::render_path(&ssh_home.known_hosts_path(), None), + ); + assert!(fixture.config().ends_with(&expected_config), "{}", fixture.config()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = |path: &std::path::Path| std::fs::metadata(path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode(&ssh_home.identity_path(record.id)), 0o600); + assert_eq!(mode(&ssh_home.agent_directory(record.id)), 0o700); + assert_eq!(mode(ssh_home.root()), 0o700); + } + let info = fixture.access.describe("worker").await.expect("descriptor"); + assert_eq!(info.alias, "agentctl-worker"); + assert_eq!(info.identity_file, ssh_home.identity_path(record.id)); + assert_eq!(info.proxy_command, format!("{AGENTCTL} ssh-proxy agent/worker")); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("second pass")); + assert_eq!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key").await, + Some(host_key), + "the incarnation keeps its host key" + ); + assert_eq!( + std::fs::read_to_string(ssh_home.identity_path(record.id)).expect("client key"), + client_private, + "the incarnation keeps its client key" + ); + assert_eq!(fixture.known_hosts().lines().count(), 2); + assert_service_reconciled_idempotently(&fixture.backend); + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/bin/chmod", "0600", "/var/lib/agent/ssh/ssh_host_ed25519_key"] + ), + 2 + ); + let guest_files = [ + "/var/lib/agent/ssh/ssh_host_ed25519_key", + "/var/lib/agent/ssh/ssh_host_ed25519_key.pub", + "/var/lib/agent/ssh/authorized_keys", + "/var/lib/agent/ssh/sshd_config", + "/etc/systemd/system/agent-ssh.service", + "/home/agent/.ssh/environment", + ]; + for path in guest_files { + let contents = read_guest_file(&sandbox, path).await.expect("guest file"); + assert!( + !String::from_utf8_lossy(&contents).contains(client_private.trim()), + "{path} must not carry the client private key" + ); + } +} + +#[tokio::test(flavor = "local")] +async fn an_image_without_a_server_fails_permanently_before_any_key_exists() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(1)); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("missing server"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("cannot provide SSH access") && message.contains("/usr/sbin/sshd is missing") && message.contains("re-apply")) + ); + assert_eq!(ReconcileFailure::classify(&error).kind, FailureKind::Invalid); + assert!(!fixture.keys.contains(record.id)); + assert!(!fixture.ssh_home().agent_directory(record.id).exists()); + assert!(!fixture.ssh_home().known_hosts_path().exists()); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .is_none() + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_without_systemd_support_fails_permanently() { + for (predicate, expected) in [ + (is_systemctl_check as fn(&ExecutionSpec) -> bool, "systemctl is missing"), + (is_systemd_running_check, "systemd is not the running init"), + ] { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture.backend.queue_execution_events_matching(predicate, exited(1)); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("incomplete image contract"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains(expected)), + "{error}" + ); + assert!(!fixture.keys.contains(record.id)); + } +} + +#[tokio::test(flavor = "local")] +async fn an_image_that_blocks_the_managed_environment_fails_permanently() { + for response in [environment(b"permituserenvironment yes\nusepam yes\n"), exited(1)] { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_environment_policy_check, response); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("environment policy"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("cannot provide SSH access")), + "{error}" + ); + assert!( + fixture.keys.contains(record.id), + "the real host key is retained for retry" + ); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key") + .await + .is_some(), + "the effective policy is evaluated with the real host key" + ); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .is_none(), + "login state is not installed before the policy passes" + ); + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/usr/bin/systemctl", "enable", "agent-ssh.service"] + ), + 0 + ); + } +} + +#[tokio::test(flavor = "local")] +async fn a_failed_server_stop_keeps_the_state_for_the_next_pass() { + let fixture = Fixture::new(); + let mut record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_valid_environment_policy(&fixture.backend); + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + + let known_hosts_before = fixture.known_hosts(); + record.agent.spec.access.clear(); + record.agent.metadata.generation = 2; + fixture.store(&record, 1).await; + fixture.backend.queue_execution_events_matching( + is_disable, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stderr("Failed to stop agent-ssh.service: Connection timed out\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a running server is not forgotten"); + assert!( + matches!(&error, Error::SandboxSetup(message) if message.contains("Connection timed out")), + "{error}" + ); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 0 + ); + assert!( + fixture.keys.contains(record.id), + "the host key stays while the server that holds it may still run" + ); + assert_eq!( + fixture.known_hosts(), + known_hosts_before, + "known_hosts keeps matching that server" + ); + assert!(fixture.ssh_home().identity_path(record.id).is_file()); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .is_some(), + "guest state stays until the server is confirmed stopped" + ); + + // A unit the image never shipped is the one failure that is not a running server. + fixture.backend.queue_execution_events_matching( + is_disable, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stderr("Failed to disable unit: Unit file agent-ssh.service does not exist.\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("withdraw")); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 1 + ); +} + +#[tokio::test(flavor = "local")] +async fn withdrawing_access_without_systemd_removes_only_the_files() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_state_check, exited(0)); + fixture + .backend + .queue_execution_events_matching(is_systemd_running_check, exited(1)); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("withdraw")); + assert!(!fixture.backend.execution_specs().iter().any(is_disable)); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 1 + ); +} + +#[tokio::test(flavor = "local")] +async fn withdrawing_access_removes_guest_and_host_state() { + let fixture = Fixture::new(); + let mut record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(0)); + queue_valid_environment_policy(&fixture.backend); + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + + record.agent.spec.access.clear(); + record.agent.metadata.generation = 2; + fixture.store(&record, 1).await; + fixture + .backend + .queue_execution_events_matching(is_state_check, exited(0)); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("withdraw")); + + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/usr/bin/systemctl", "disable", "--now", "agent-ssh.service"] + ), + 1 + ); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 1 + ); + assert!(!fixture.keys.contains(record.id)); + assert!(!fixture.ssh_home().agent_directory(record.id).exists()); + assert_eq!(fixture.known_hosts(), ""); + assert!(!fixture.config().contains("Host ")); + + // A later pass finds no guest state and leaves systemd alone. + fixture + .backend + .queue_execution_events_matching(is_state_check, exited(1)); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("steady")); + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/usr/bin/systemctl", "disable", "--now", "agent-ssh.service"] + ), + 1 + ); +} + +#[tokio::test(flavor = "local")] +async fn deletion_removes_host_material_and_config_lists_only_active_ssh_agents() { + let fixture = Fixture::new(); + let worker = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + let reviewer = record("reviewer", "5c1f4a1e-0ad5-4a37-9c94-4c0f2e6d7a10", true); + let plain = record("plain", "9e2d6b5a-3d3a-4a2b-8a3c-1f9d2c3b4a55", false); + let mut leaving = record("leaving", "0b7e2f31-6a94-4d0e-9d61-3ac7d1a2b3c4", true); + leaving.agent.metadata.deletion_timestamp = Some(time::OffsetDateTime::now_utc()); + for record in [&worker, &reviewer, &plain, &leaving] { + fixture.store(record, 0).await; + } + let sandbox = fixture.sandbox(&worker).await; + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(0)); + queue_valid_environment_policy(&fixture.backend); + assert!(fixture.access.reconcile(&worker, &sandbox).await.expect("grant")); + + let aliases = fixture + .config() + .lines() + .filter_map(|line| line.strip_prefix("Host ")) + .map(str::to_owned) + .collect::>(); + assert_eq!(aliases, ["agentctl-reviewer", "agentctl-worker"]); + + assert!(matches!( + fixture.access.describe("plain").await, + Err(Error::Invalid(message)) if message.contains("does not declare SSH access") + )); + assert!(matches!(fixture.access.describe("nobody").await, Err(Error::NotFound))); + + fixture.access.remove(&worker).await.expect("remove on deletion"); + assert!(!fixture.keys.contains(worker.id)); + assert!(!fixture.ssh_home().agent_directory(worker.id).exists()); + assert_eq!(fixture.known_hosts(), ""); + fixture.access.remove(&worker).await.expect("removal is idempotent"); +} + +#[tokio::test(flavor = "local")] +async fn descriptor_json_is_the_documented_shape() { + let fixture = Fixture::new(); + let worker = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&worker, 0).await; + let info = fixture.access.describe("worker").await.expect("descriptor"); + let value = serde_json::to_value(&info).expect("JSON"); + let object = value.as_object().expect("object"); + let mut expected = [ + "type", + "agent", + "agentId", + "alias", + "user", + "identityFile", + "knownHostsFile", + "configFile", + "proxyCommand", + "workingDirectory", + ]; + expected.sort_unstable(); + assert_eq!(object.keys().map(String::as_str).collect::>(), expected); + assert_eq!(value["type"], "ssh"); + assert_eq!(value["agent"], "worker"); + assert_eq!(value["agentId"], "38f41de4-6ff7-4679-ae46-678bc61e4dcb"); + assert_eq!(value["alias"], "agentctl-worker"); + assert_eq!(value["user"], "agent"); + assert_eq!(value["proxyCommand"], format!("{AGENTCTL} ssh-proxy agent/worker")); + assert_eq!(value["workingDirectory"], "/home/agent/code"); + let decoded: ssh::AccessInfo = serde_json::from_value(value).expect("round trip"); + assert_eq!(decoded, info); +} + +fn repository_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") +} + +#[test] +fn images_supply_ssh_prerequisites_without_owning_platform_policy() { + let root = repository_root(); + let published = root.join("agents/common"); + let self_dev = root.join("src/experimental/agent/examples/self-dev"); + + let dockerfile = std::fs::read_to_string(root.join("agents/Dockerfile")).expect("Dockerfile"); + let base_stage = dockerfile.split("FROM base AS minimal").next().expect("base stage"); + assert!(base_stage.contains("openssh-server")); + assert!(base_stage.contains("passwd --delete agent")); + assert!(base_stage.contains("systemctl mask ssh.service ssh.socket")); + assert!(!base_stage.contains("sshd_config")); + assert!(!published.join("sshd_config").exists()); + assert!(!published.join("ssh.service").exists()); + assert!(!published.join("ssh-tmpfiles.conf").exists()); + + let self_dev_dockerfile = std::fs::read_to_string(self_dev.join("Dockerfile")).expect("self-dev Dockerfile"); + assert!(self_dev_dockerfile.contains("openssh-server")); + assert!(self_dev_dockerfile.contains("passwd --delete agent")); + assert!(self_dev_dockerfile.contains("systemctl mask ssh.service ssh.socket")); + assert!(!self_dev_dockerfile.contains("sshd_config")); + assert!(!self_dev.join("sshd_config").exists()); + assert!(!self_dev.join("ssh.service").exists()); + assert!(!self_dev.join("ssh-tmpfiles.conf").exists()); + + for manifest in [ + "agents/full/agent.yaml", + "agents/full/agent.nested.yaml", + "agents/full/agent.nested-build.yaml", + "agents/full/agent.worktree.yaml", + "agents/minimal/agent.yaml", + "agents/desktop/agent.yaml", + "agents/desktop/agent.nested.yaml", + "agents/desktop/agent.nested-build.yaml", + "agents/desktop/agent.worktree.yaml", + "src/experimental/agent/examples/self-dev/agent.yaml", + "src/experimental/agent/examples/self-dev/agent.nested.yaml", + "src/experimental/agent/examples/self-dev/agent.worktree.yaml", + ] { + let decoded = agent::manifest::resolve(&root.join(manifest)) + .unwrap_or_else(|error| panic!("{manifest}: {error}")) + .agent; + assert!(decoded.spec.ssh_access(), "{manifest} declares SSH access"); + } +} diff --git a/agentctl/tests/support/mod.rs b/agentctl/tests/support/mod.rs new file mode 100644 index 0000000..2b8442f --- /dev/null +++ b/agentctl/tests/support/mod.rs @@ -0,0 +1,99 @@ +#![allow(dead_code)] + +use std::path::PathBuf; + +use agent::{ + API_VERSION, Agent, Harness, HarnessAuthMode, HarnessSpec, HomeSpec, InstructionsSpec, KIND, Metadata, + ModelSelection, NetworkAllow, NetworkMode, NetworkSpec, PlatformManifestSpec, SandboxManifestSpec, Spec, Status, +}; +use sandbox::{ + ByteQuantity, CpuQuantity, Platform, RetentionPolicy, RootFilesystem, SandboxResources, image::ImageSource, +}; +/// Drops reconciliation wake-ups, for tests that reconcile by hand or not at all. +pub(crate) struct IgnoreNotifications; + +impl agent::control_plane::Notifier for IgnoreNotifications { + fn notify(&self, _id: agent::AgentId) {} +} + +pub(crate) fn agent(name: &str) -> Agent { + Agent { + api_version: API_VERSION.into(), + kind: KIND.into(), + metadata: Metadata { + name: name.into(), + generation: 0, + deletion_timestamp: None, + }, + spec: Spec { + run_state: None, + sandbox: SandboxManifestSpec { + image: ImageSource::Build { + context: PathBuf::from("image"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: PlatformManifestSpec { + os: "linux".into(), + architecture: Some(Platform::native("linux").architecture), + variant: None, + os_version: None, + os_features: std::collections::BTreeSet::new(), + }, + resources: SandboxResources::new( + "2".parse::().expect("test CPU should be valid"), + "1Gi".parse::().expect("test memory should be valid"), + RootFilesystem::layered( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: Some(RetentionPolicy::Retain), + mounts: Vec::new(), + }, + home: HomeSpec { + source: PathBuf::from("home"), + }, + instructions: vec![InstructionsSpec { + source: PathBuf::from("instructions.md"), + }], + skills: vec![], + harnesses: vec![HarnessSpec { + kind: Harness::ClaudeCode, + version: Some("2.1.266".into()), + auth: HarnessAuthMode::Mediated, + optional: false, + default: false, + defaults: ModelSelection::default(), + }], + environment: Vec::new(), + secrets: Vec::new(), + access: Vec::new(), + network: NetworkSpec { + mode: NetworkMode::Mediated, + allow: NetworkAllow::All, + deny: Vec::new(), + }, + }, + status: Status::default(), + } +} + +pub(crate) struct TempDirectory(tempfile::TempDir); + +impl TempDirectory { + pub(crate) fn new(label: &str) -> Self { + Self( + tempfile::Builder::new() + .prefix(&format!("agent-platform-{label}-")) + .tempdir() + .expect("temporary directory should be created"), + ) + } + + pub(crate) fn path(&self) -> &std::path::Path { + self.0.path() + } +} diff --git a/agentctl/tests/tmux_delivery.mjs b/agentctl/tests/tmux_delivery.mjs new file mode 100644 index 0000000..94e7f6f --- /dev/null +++ b/agentctl/tests/tmux_delivery.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { access, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +const script = await readFile(process.argv[2], "utf8"); +const observationScript = await readFile(process.argv[3], "utf8"); +const stopScript = await readFile(process.argv[4], "utf8"); +const directory = await mkdtemp(join(tmpdir(), "tmux-delivery-")); +const socket = join(directory, "socket"); +const received = join(directory, "received"); +const terminal = join(directory, "terminal.mjs"); +const env = { ...process.env, TMUX: `${socket},0,0` }; +const tmux = (...args) => execFileSync("/usr/bin/tmux", ["-S", socket, ...args], { encoding: "utf8" }); +await writeFile(terminal, `import { createInterface } from "node:readline"; +import { appendFileSync } from "node:fs"; +for await (const line of createInterface({ input: process.stdin })) appendFileSync(${JSON.stringify(received)}, line + "\\n");`); +tmux("new-session", "-d", "-s", "input", `node ${terminal}`); +async function deliver(text) { + const file = join(directory, text); + await writeFile(file, text); + const child = spawn("/bin/sh", ["-c", script, "deliver", file, text, "=input:"], { env, stdio: "inherit" }); + return new Promise((resolve) => child.on("exit", resolve)); +} +try { + await setTimeout(100); + assert.equal(await deliver("first"), 0); + assert.equal(await deliver("second"), 0); + await setTimeout(100); + assert.equal(await readFile(received, "utf8"), "first\nsecond\n"); + for (const file of ["first", "second"]) { + await assert.rejects(access(join(directory, file)), { code: "ENOENT" }); + } + assert.equal(tmux("list-buffers"), ""); + + const observe = (transcript) => execFileSync("/bin/sh", ["-c", observationScript, "observe", "input", transcript], { env, encoding: "utf8" }).trim(); + const transcript = join(directory, "transcript with spaces.jsonl"); + await setTimeout(1200); + const baseline = observe(""); + assert.ok(Number(baseline.split(" ")[1]) >= 1, baseline); + assert.ok(Number(observe(transcript).split(" ")[1]) >= 1, "missing transcript uses terminal activity"); + await writeFile(transcript, "not parsed as JSON"); + await utimes(transcript, 1, 1); + assert.ok(Number(observe(transcript).split(" ")[1]) >= 1, "old transcript does not mask terminal activity"); + // A timestamp ahead of the guest clock also exercises the zero-age clamp. + const future = Date.now() / 1000 + 60; + await utimes(transcript, future, future); + assert.equal(observe(transcript), "0 0", "recent transcript keeps a quiet terminal active"); + + const stop = (target, environment = env) => + spawn("/bin/sh", ["-c", stopScript, "stop", target], { env: environment, stdio: "inherit" }); + const exited = (child) => new Promise((resolve) => child.on("exit", resolve)); + tmux("new-session", "-d", "-s", "stoppable", "sleep 600"); + assert.equal(await exited(stop("=stoppable")), 0); + assert.equal(tmux("list-sessions", "-F", "#{session_name}").trim(), "input", "the stopped Session is gone"); + assert.equal(await exited(stop("=stoppable")), 0, "an already stopped Session is stopped"); + const noServer = { ...process.env, TMUX: `${join(directory, "no-server")},0,0` }; + assert.equal(await exited(stop("=stoppable", noServer)), 0, "a stopped server has no Session left"); + console.log("tmux: submissions stay separate, cleanup succeeds, transcript freshness counts as activity, and stopping is idempotent"); +} finally { + tmux("kill-server"); + await rm(directory, { recursive: true, force: true }); +} diff --git a/agentctl/tests/tmux_scrollback.mjs b/agentctl/tests/tmux_scrollback.mjs new file mode 100644 index 0000000..e44e790 --- /dev/null +++ b/agentctl/tests/tmux_scrollback.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +// Arguments come from the runtime's actual command builders, not a copy of its policy. +const options = JSON.parse(process.argv[2]); +const attach = JSON.parse(process.argv[3]); +const session = process.argv[4]; +const directory = await mkdtemp(join(tmpdir(), "tmux-scrollback-")); +const socket = join(directory, "socket"); +const env = { ...process.env, TERM: "xterm-256color", TMUX: "" }; +const tmux = (...args) => execFileSync("tmux", ["-S", socket, "-f", "/dev/null", ...args], { encoding: "utf8", env }).trim(); +const format = (target, value) => tmux("display-message", "-p", "-t", target, `#{${value}}`); +async function until(predicate) { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await setTimeout(50); + } + assert.fail("terminal condition timed out"); +} +let client; +try { + const program = join(directory, "output.sh"); + await writeFile(program, `#!/bin/sh +[ "$1" != alternate ] || printf '\\033[?1049h' +i=1 +while [ "$i" -le 100 ]; do printf 'retained-line-%03d\\n' "$i"; i=$((i+1)); done +printf 'OUTPUT-READY' +if [ "$1" = alternate ]; then + stty raw -echo + printf '\\033[?1000h\\033[?1006h' + exec cat > ${directory}/mouse-events +fi +exec sleep 120 +`); + tmux(...options, "new-session", "-d", "-x", "80", "-y", "10", "-s", session, `sh ${program} normal`); + const pane = `=${session}:`; + await until(() => tmux("capture-pane", "-p", "-t", pane).includes("OUTPUT-READY")); + assert.equal(format(pane, "history_limit"), "50000", "limit must precede pane creation"); + assert.equal(tmux("show-options", "-gv", "mouse"), "on"); + assert.equal(tmux("show-options", "-sv", "focus-events"), "on"); + assert.equal(tmux("show-options", "-sv", "extended-keys"), "on"); + const features = tmux("show-options", "-s", "terminal-features"); + tmux(...options, "display-message", "-p", "configured"); + assert.equal(tmux("show-options", "-s", "terminal-features"), features, "reconfiguration must not append entries"); + assert.ok(features.includes('terminal-features[99] xterm*:extkeys')); + assert.ok(Number(format(pane, "history_size")) >= 90); + const history = tmux("capture-pane", "-p", "-S", "-", "-t", pane); + assert.ok(history.includes("retained-line-001")); + console.log("PASS: runtime options precede pane creation; repeated configuration is idempotent"); + console.log("PASS: normal-screen output is retained in 50,000-line pane history"); + + tmux("set-option", "-g", "history-limit", "2000", ";", "new-session", "-d", "-s", "legacy", "sleep 120"); + tmux(...options, "new-session", "-d", "-s", "after-upgrade", "sleep 120"); + assert.equal(format("=legacy:", "history_limit"), "2000"); + assert.equal(format("=after-upgrade:", "history_limit"), "50000"); + console.log("PASS: new panes on an existing server get the new limit; old pane limits remain unchanged"); + + tmux("new-session", "-d", "-x", "80", "-y", "10", "-s", "alternate", `sh ${program} alternate`); + await until(() => tmux("capture-pane", "-p", "-t", "=alternate:").includes("OUTPUT-READY")); + assert.equal(format("=alternate:", "alternate_on"), "1"); + assert.equal(format("=alternate:", "history_size"), "0"); + console.log("PASS: alternate-screen applications retain their own scrolling responsibility"); + + // Exercise real PTY clients, including upgrading a session-local mouse override. + const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; + const command = ["tmux", "-S", socket, ...attach].map(quote).join(" "); + for (let attempt = 0; attempt < 2; attempt++) { + tmux("set-option", "-t", pane, "mouse", "off"); + client = spawn("script", ["-q", "-c", command, "/dev/null"], { env, stdio: ["pipe", "ignore", "pipe"] }); + let errors = ""; + client.stderr.on("data", (data) => { errors += data; }); + await until(() => format(pane, "session_attached") === "1"); + assert.equal(tmux("show-options", "-v", "-t", pane, "mouse"), "on", errors); + client.stdin.write("\x1b[<64;5;5M"); + await until(() => format(pane, "pane_in_mode") === "1"); + tmux("send-keys", "-X", "-t", pane, "cancel"); + const exited = new Promise((resolve) => client.once("exit", resolve)); + tmux("detach-client", "-s", session); + assert.equal(await exited, 0, errors); + client = undefined; + assert.ok(tmux("capture-pane", "-p", "-S", "-", "-t", pane).includes("retained-line-001")); + } + console.log("PASS: attach repairs mouse mode; wheel enters copy mode; history survives detach/reattach"); + + const alternateAttach = attach.map((arg) => arg.replace(session, "alternate")); + const alternateCommand = ["tmux", "-S", socket, ...alternateAttach].map(quote).join(" "); + client = spawn("script", ["-q", "-c", alternateCommand, "/dev/null"], { env, stdio: ["pipe", "ignore", "ignore"] }); + await until(() => format("=alternate:", "session_attached") === "1"); + const wheel = "\x1b[<64;5;5M"; + client.stdin.write(wheel); + const received = join(directory, "mouse-events"); + await until(() => existsSync(received) && readFileSync(received, "utf8").includes(wheel)); + assert.equal(format("=alternate:", "pane_in_mode"), "0"); + const exited = new Promise((resolve) => client.once("exit", resolve)); + tmux("detach-client", "-s", "alternate"); + assert.equal(await exited, 0); + client = undefined; + console.log("PASS: wheel events reach a fullscreen application that requests mouse input"); + +} finally { + client?.kill(); + try { tmux("kill-server"); } finally { await rm(directory, { recursive: true, force: true }); } +} diff --git a/agentctl/tests/tmux_suspend.mjs b/agentctl/tests/tmux_suspend.mjs new file mode 100644 index 0000000..0ba0dca --- /dev/null +++ b/agentctl/tests/tmux_suspend.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +// Arguments come from the runtime's actual command builders, not a copy of its policy. +const options = JSON.parse(process.argv[2]); +const attach = JSON.parse(process.argv[3]); +const session = process.argv[4]; +const directory = await mkdtemp(join(tmpdir(), "tmux-suspend-")); +const socket = join(directory, "socket"); +const env = { ...process.env, TERM: "xterm-256color", TMUX: "" }; +const tmux = (...args) => execFileSync("tmux", ["-S", socket, "-f", "/dev/null", ...args], { encoding: "utf8", env }).trim(); +const format = (target, value) => tmux("display-message", "-p", "-t", target, `#{${value}}`); +const stopped = (pid) => execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).trim().startsWith("T"); +const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; +async function until(predicate) { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await setTimeout(50); + } + assert.fail("terminal condition timed out"); +} + +// `cat -v` stands in for a harness or a foreground job and shows a delivered Ctrl-Z as `^Z`; +// `stty -isig` keeps the byte from raising a signal, so the capture reveals whether it arrived. +const HARNESS = "stty -isig; exec cat -v"; +let client; +// Attachment is complete only once typed input reaches the pane: a key sent before the client +// has put its own terminal into raw mode is eaten by that terminal instead. The runtime's attach +// arguments name one session; retarget them by name to drive a second session with the same server. +async function attached(name, target, probe) { + const args = attach.map((arg) => arg.replaceAll(session, name)); + const command = ["tmux", "-S", socket, ...args].map(quote).join(" "); + client = spawn("script", ["-q", "-c", command, "/dev/null"], { env, stdio: ["pipe", "ignore", "ignore"] }); + await until(() => format(`=${name}:`, "session_attached") === "1"); + client.stdin.write(`${probe}\n`); + await until(() => tmux("capture-pane", "-p", "-t", target).includes(probe)); +} +async function detach(name) { + const exited = new Promise((resolve) => client.once("exit", resolve)); + tmux("detach-client", "-s", name); + await exited; + client = undefined; +} +try { + // 1. The Session's own harness pane: Ctrl-Z is swallowed and the harness keeps reading input. + tmux(...options, "new-session", "-d", "-x", "80", "-y", "10", "-s", session, HARNESS); + const pane = `=${session}:`; + assert.notEqual(format(pane, "pane_start_command"), "", "a harness pane starts with a command"); + const paneOut = () => tmux("capture-pane", "-p", "-t", pane); + await attached(session, pane, "probe-one"); + client.stdin.write("\x1a"); + client.stdin.write("first-line\n"); + await until(() => paneOut().includes("first-line")); + assert.ok(!paneOut().includes("^Z"), `Ctrl-Z must not reach the harness pane:\n${paneOut()}`); + await detach(session); + console.log("PASS: Ctrl-Z in the harness pane is swallowed and the harness keeps reading input"); + + // 2. A window opened with Ctrl-b c runs a shell; job control makes suspension recoverable there. + tmux("new-window", "-d", "-t", pane); + const shell = `=${session}:1`; + assert.equal(format(shell, "pane_start_command"), ""); + tmux("send-keys", "-t", shell, "cat", "Enter"); + let job; + await until(() => { + try { + job = Number(execFileSync("pgrep", ["-P", format(shell, "pane_pid"), "-x", "cat"], { encoding: "utf8" })); + return true; + } catch { + return false; + } + }); + tmux("select-window", "-t", shell); + await attached(session, shell, "probe-two"); + client.stdin.write("\x1a"); + await until(() => stopped(job)); + await detach(session); + tmux("select-window", "-t", `=${session}:0`); + console.log("PASS: Ctrl-Z still suspends a foreground job in a shell window"); + + // 3. The binding is server-wide but scoped by session name, so a non-Agent session with a + // command-started pane still receives Ctrl-Z (proves the swallow above is not global). + const other = "user-shell-x"; + tmux("new-session", "-d", "-x", "80", "-y", "10", "-s", other, HARNESS); + const otherPane = `=${other}:`; + const otherOut = () => tmux("capture-pane", "-p", "-t", otherPane); + await attached(other, otherPane, "probe-three"); + client.stdin.write("\x1a"); + client.stdin.write("third-line\n"); + await until(() => otherOut().includes("third-line")); + assert.ok(otherOut().includes("^Z"), `Ctrl-Z must reach a non-Agent pane:\n${otherOut()}`); + await detach(other); + console.log("PASS: Ctrl-Z reaches a command pane in a non-Agent session (binding is scoped)"); +} finally { + client?.kill(); + try { tmux("kill-server"); } finally { await rm(directory, { recursive: true, force: true }); } +} diff --git a/agentctl/tests/variants.rs b/agentctl/tests/variants.rs new file mode 100644 index 0000000..d1b94ae --- /dev/null +++ b/agentctl/tests/variants.rs @@ -0,0 +1,283 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::Path; + +use agent::{AgentVariantName, manifest}; + +fn agent_directory() -> support::TempDirectory { + let directory = support::TempDirectory::new("variants"); + let yaml = serde_yaml_ng::to_string(&support::agent("base")).expect("base YAML"); + std::fs::write(directory.path().join("agent.yaml"), yaml).expect("base manifest"); + directory +} + +fn write(directory: &Path, name: &str, body: &str) { + std::fs::write(directory.join(name), body).expect("variant manifest"); +} + +fn variant(extends: &str, name: &str, spec: &str) -> String { + format!( + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: {extends}\nmetadata:\n name: {name}\n{spec}" + ) +} + +#[test] +fn resolves_one_level_and_multilevel_variants_from_the_base_outward() { + let directory = agent_directory(); + write( + directory.path(), + "agent.nested.yaml", + &variant( + "agent.yaml", + "nested", + "spec:\n sandbox:\n resources:\n cpu: '1'\n memory: 2Gi\n", + ), + ); + write( + directory.path(), + "agent.mine.yaml", + &variant( + "agent.nested.yaml", + "mine", + "spec:\n sandbox:\n resources:\n cpu: '3'\n", + ), + ); + + let resolved = manifest::resolve(&directory.path().join("agent.mine.yaml")).expect("resolved variant"); + let value = serde_json::to_value(&resolved.agent).expect("Agent JSON"); + assert_eq!(resolved.agent.metadata.name, "mine"); + assert_eq!(value["spec"]["sandbox"]["resources"]["cpu"], "3"); + assert_eq!(value["spec"]["sandbox"]["resources"]["memory"], "2Gi"); + assert_eq!( + resolved + .chain + .iter() + .filter_map(|path| path.file_name().and_then(|name| name.to_str())) + .collect::>(), + ["agent.mine.yaml", "agent.nested.yaml", "agent.yaml"] + ); +} + +#[test] +fn mappings_merge_while_arrays_replace_and_empty_arrays_clear() { + let directory = agent_directory(); + write( + directory.path(), + "agent.arrays.yaml", + &variant( + "agent.yaml", + "arrays", + "spec:\n sandbox:\n mounts:\n - type: tmpfs\n target: /tmp\n capacity: 2Gi\n instructions: []\n harnesses:\n - type: codex\n auth: mediated\n", + ), + ); + + let agent = manifest::resolve(&directory.path().join("agent.arrays.yaml")) + .expect("array variant") + .agent; + assert_eq!(agent.spec.sandbox.mounts.len(), 1); + assert!(agent.spec.instructions.is_empty()); + assert_eq!(agent.spec.harnesses.len(), 1); + assert_eq!(agent.spec.harnesses[0].kind, agent::Harness::Codex); + assert_eq!(agent.spec.home.source, Path::new("home")); +} + +#[test] +fn changing_a_tagged_mapping_type_replaces_the_previous_variant() { + let directory = agent_directory(); + write( + directory.path(), + "agent.reference.yaml", + &variant( + "agent.yaml", + "reference", + "spec:\n sandbox:\n image:\n type: reference\n reference: example.invalid/agent:latest\n", + ), + ); + + let agent = manifest::resolve(&directory.path().join("agent.reference.yaml")) + .expect("tagged mapping replacement") + .agent; + assert!(matches!( + agent.spec.sandbox.image, + sandbox::image::ImageSource::Reference { .. } + )); +} + +#[test] +fn null_removes_optional_fields_and_required_removal_fails_final_validation() { + let directory = agent_directory(); + write( + directory.path(), + "agent.optional.yaml", + &variant( + "agent.yaml", + "optional", + "spec:\n sandbox:\n retentionPolicy: null\n", + ), + ); + let optional = manifest::resolve(&directory.path().join("agent.optional.yaml")).expect("optional removal"); + assert_eq!(optional.agent.spec.sandbox.retention_policy, None); + + write( + directory.path(), + "agent.required.yaml", + &variant("agent.yaml", "required", "spec:\n sandbox:\n resources: null\n"), + ); + let error = manifest::resolve(&directory.path().join("agent.required.yaml")).expect_err("required removal"); + assert!(error.to_string().contains("resources")); + assert!(error.to_string().contains("agent.required.yaml")); +} + +#[test] +fn rejects_unknown_variant_fields_even_when_null() { + let directory = agent_directory(); + for (name, spec) in [ + ("unknown", "spec:\n mystery: null\n"), + ( + "nested-unknown", + "spec:\n sandbox:\n resources:\n mystery: null\n", + ), + ] { + let filename = format!("agent.{name}.yaml"); + write(directory.path(), &filename, &variant("agent.yaml", name, spec)); + let error = manifest::resolve(&directory.path().join(filename)).expect_err("unknown field"); + assert!(error.to_string().contains("unknown field"), "{error}"); + } +} + +#[test] +fn rejects_missing_bases_cross_directory_paths_and_absolute_paths() { + let directory = agent_directory(); + for (name, extends, expected) in [ + ("missing", "agent.absent.yaml", "could not read"), + ("parent", "../agent.yaml", "extends must name"), + ("absolute", "/tmp/agent.yaml", "extends must name"), + ] { + let filename = format!("agent.{name}.yaml"); + write(directory.path(), &filename, &variant(extends, name, "")); + let error = manifest::resolve(&directory.path().join(filename)).expect_err("invalid base"); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn reports_the_complete_cycle() { + let directory = agent_directory(); + write( + directory.path(), + "agent.one.yaml", + &variant("agent.two.yaml", "one", ""), + ); + write( + directory.path(), + "agent.two.yaml", + &variant("agent.one.yaml", "two", ""), + ); + let error = manifest::resolve(&directory.path().join("agent.one.yaml")).expect_err("cycle"); + assert!( + error + .to_string() + .contains("agent.one.yaml -> agent.two.yaml -> agent.one.yaml"), + "{error}" + ); +} + +#[test] +fn base_errors_include_the_complete_inheritance_chain() { + let directory = agent_directory(); + write( + directory.path(), + "agent.parent.yaml", + &variant("agent.missing.yaml", "parent", ""), + ); + write( + directory.path(), + "agent.leaf.yaml", + &variant("agent.parent.yaml", "leaf", ""), + ); + + let error = manifest::resolve(&directory.path().join("agent.leaf.yaml")).expect_err("missing base"); + let message = error.to_string(); + assert!(message.contains("inheritance chain:"), "{message}"); + assert!(message.contains("agent.leaf.yaml\n extends agent.parent.yaml\n extends agent.missing.yaml")); +} + +#[test] +fn limits_inheritance_to_sixteen_manifests() { + let directory = agent_directory(); + for index in 1..=16 { + let extends = if index == 16 { + "agent.yaml".to_owned() + } else { + format!("agent.v{}.yaml", index + 1) + }; + write( + directory.path(), + &format!("agent.v{index}.yaml"), + &variant(&extends, &format!("v{index}"), ""), + ); + } + let error = manifest::resolve(&directory.path().join("agent.v1.yaml")).expect_err("depth limit"); + assert!(error.to_string().contains("exceeds 16 manifests"), "{error}"); +} + +#[test] +fn requires_names_and_matching_api_versions_in_every_variant() { + let directory = agent_directory(); + write( + directory.path(), + "agent.nameless.yaml", + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: agent.yaml\nmetadata: {}\n", + ); + let error = manifest::resolve(&directory.path().join("agent.nameless.yaml")).expect_err("name required"); + assert!(error.to_string().contains("metadata.name")); + + write( + directory.path(), + "agent.version.yaml", + "apiVersion: agents.platform/v2\nkind: AgentVariant\nextends: agent.yaml\nmetadata:\n name: version\n", + ); + let error = manifest::resolve(&directory.path().join("agent.version.yaml")).expect_err("version mismatch"); + assert!(error.to_string().contains("apiVersion")); +} + +#[test] +fn validates_filename_grammar() { + let nested_build: AgentVariantName = "nested-build".parse().expect("variant name"); + assert_eq!(nested_build.as_str(), "nested-build"); + assert_eq!(nested_build.filename(), "agent.nested-build.yaml"); + assert_eq!( + serde_json::to_string(&nested_build).expect("serialized name"), + r#""nested-build""# + ); + assert_eq!( + serde_json::from_str::(r#""nested-build""#).expect("deserialized name"), + nested_build + ); + + for accepted in [ + "agent.nested.yaml", + "agent.nested-build.yaml", + "agent.worktree.yaml", + "agent.mine.yaml", + "agent.large-local.yaml", + ] { + assert!(manifest::is_manifest_filename(Path::new(accepted)), "{accepted}"); + } + assert!(manifest::is_manifest_filename(Path::new("agent.yaml"))); + for rejected in [ + "agent-copy.yaml", + "agent..yaml", + "agent.Nested.yaml", + "my-agent.yaml", + "agent.yaml.bak", + "agent.trailing-.yaml", + ] { + assert!(!manifest::is_manifest_filename(Path::new(rejected)), "{rejected}"); + } + for rejected in ["", "Nested", "nested_build", "nested-", "../nested"] { + assert!(rejected.parse::().is_err(), "{rejected}"); + } +} diff --git a/agentctl/tests/vnc_access.rs b/agentctl/tests/vnc_access.rs new file mode 100644 index 0000000..8c5efba --- /dev/null +++ b/agentctl/tests/vnc_access.rs @@ -0,0 +1,517 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{path::PathBuf, rc::Rc}; + +use agent::{ + AccessSpec, AgentId, Error, FailureKind, ReconcileFailure, + control_plane::{AgentRecord, AgentStore as _, memory::InMemoryAgentStore}, + vnc::Access, +}; +use sandbox::{ + EnsureSandboxRequest, Platform, SandboxHandle, SandboxService, + execution::{ExecutionEvent, ExecutionSpec, ExitStatus, Program}, + memory, +}; + +const AGENTCTL: &str = "/usr/local/bin/agentctl"; +const DESCRIPTOR: &str = "/etc/agent-access.d/vnc.conf"; +const UNITS: [&str; 2] = ["agent-vnc.socket", "agent-vnc-web.service"]; + +fn command(spec: &ExecutionSpec) -> Option<(&str, Vec<&str>)> { + match spec.program() { + Program::Command { executable, args } => Some((executable.as_str(), args.iter().map(String::as_str).collect())), + Program::ImageEntrypoint => None, + } +} + +fn is_command(spec: &ExecutionSpec, executable: &str, expected: &[&str]) -> bool { + command(spec).is_some_and(|(actual, args)| actual == executable && args == expected) +} + +fn is_test(path: &'static str, test: &'static str) -> impl Fn(&ExecutionSpec) -> bool { + move |spec| is_command(spec, "/usr/bin/test", &[test, path]) +} + +fn is_descriptor_read(spec: &ExecutionSpec) -> bool { + is_command(spec, "/bin/cat", &[DESCRIPTOR]) +} + +fn is_listener_check(port: u16) -> impl Fn(&ExecutionSpec) -> bool { + move |spec| { + command(spec).is_some_and(|(executable, args)| { + executable == "/usr/bin/ss" && args == ["-ltnH", "sport", "=", &format!(":{port}")] + }) + } +} + +fn is_enable(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "enable", "--now", UNITS[0], UNITS[1]], + ) +} + +fn is_disable(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "disable", "--now", UNITS[0], UNITS[1]], + ) +} + +fn is_active_check(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "is-active", UNITS[0], UNITS[1]], + ) +} + +fn is_any_listener_check(spec: &ExecutionSpec) -> bool { + command(spec).is_some_and(|(executable, _)| executable == "/usr/bin/ss") +} + +fn exited(code: i32) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code }), + ] +} + +fn output(contents: &'static [u8]) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout(contents.into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ] +} + +const VALID_DESCRIPTOR: &[u8] = b"units=agent-vnc.socket agent-vnc-web.service\nport=5900\nweb-port=6080\n"; + +/// Answers the image-contract probes as an image with systemd and `ss` whose VNC descriptor is +/// `descriptor`, or which has none. +fn queue_image(backend: &memory::Provider, descriptor: Option<&'static [u8]>) { + for path in ["/usr/bin/systemctl", "/usr/bin/ss"] { + backend.queue_execution_events_matching(is_test(path, "-x"), exited(0)); + } + backend.queue_execution_events_matching(is_test("/run/systemd/system", "-d"), exited(0)); + backend.queue_execution_events_matching(is_test(DESCRIPTOR, "-f"), exited(i32::from(descriptor.is_none()))); + if let Some(descriptor) = descriptor { + backend.queue_execution_events_matching(is_descriptor_read, output(descriptor)); + } +} + +fn queue_desktop_image(backend: &memory::Provider) { + queue_image(backend, Some(VALID_DESCRIPTOR)); +} + +fn queue_listening(backend: &memory::Provider, listening: bool) { + for port in [5900u16, 6080] { + let events = if listening { + output(b"LISTEN 0 0 127.0.0.1:port 0.0.0.0:*\n") + } else { + output(b"") + }; + backend.queue_execution_events_matching(is_listener_check(port), events); + } +} + +fn record(name: &str, id: &str, vnc: bool) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + if vnc { + resource.spec.access = vec![AccessSpec::Vnc {}]; + } + AgentRecord { + id: id.parse::().expect("Agent ID"), + source_directory: PathBuf::from("/source").join(name), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +struct Fixture { + store: Rc, + access: Access, + backend: Rc, +} + +impl Fixture { + fn new() -> Self { + let store = Rc::new(InMemoryAgentStore::new()); + let access = Access::new(PathBuf::from(AGENTCTL), store.clone()); + Self { + store, + access, + backend: Rc::new(memory::Provider::new()), + } + } + + async fn store(&self, record: &AgentRecord, expected_generation: u64) { + self.store + .put(record.clone(), expected_generation) + .await + .expect("store record"); + } + + async fn sandbox(&self, record: &AgentRecord) -> SandboxHandle { + let service = SandboxService::new(self.backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox") + } +} + +#[tokio::test(flavor = "local")] +async fn granting_access_enables_the_units_the_image_declared() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_desktop_image(&fixture.backend); + queue_listening(&fixture.backend, true); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + + assert_eq!( + fixture + .access + .describe("worker") + .await + .expect("described") + .web_guest_port, + Some(6080), + "a granted Agent reports the viewer port its image declared" + ); + assert!( + fixture.backend.execution_specs().iter().any(is_enable), + "the units named by the image are enabled, and no unit file is written" + ); + assert!( + !fixture + .backend + .execution_specs() + .iter() + .any(|spec| command(spec).is_some_and(|(executable, _)| executable == "/bin/chmod")), + "the units belong to the image, so nothing here installs or chmods one" + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_offering_no_browser_viewer_is_granted_and_described_without_one() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image(&fixture.backend, Some(b"units=agent-vnc.socket\nport=5900\n")); + fixture.backend.queue_execution_events_matching( + is_listener_check(5900), + output(b"LISTEN 0 0 127.0.0.1:5900 0.0.0.0:*\n"), + ); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + assert!( + !fixture + .backend + .execution_specs() + .iter() + .any(|spec| is_listener_check(6080)(spec)), + "a port the image never promised is not checked" + ); + let info = fixture.access.describe("worker").await.expect("declared access"); + assert_eq!( + info.web_guest_port, None, + "the descriptor reports the absence rather than a port that serves nothing" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_viewer_that_binds_its_port_after_being_enabled_is_waited_for() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_desktop_image(&fixture.backend); + fixture.backend.queue_execution_events_matching( + is_listener_check(5900), + output(b"LISTEN 0 0 127.0.0.1:5900 0.0.0.0:*\n"), + ); + // systemd reports a simple service started once it has forked, before it has bound its port. + fixture + .backend + .queue_execution_events_matching(is_listener_check(6080), output(b"")); + fixture.backend.queue_execution_events_matching( + is_listener_check(6080), + output(b"LISTEN 0 0 127.0.0.1:6080 0.0.0.0:*\n"), + ); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + assert_eq!( + fixture + .backend + .execution_specs() + .iter() + .filter(|spec| is_listener_check(6080)(spec)) + .count(), + 2, + "the viewer port is checked again rather than failing the pass" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_grant_that_leaves_a_declared_port_silent_is_a_failure() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_desktop_image(&fixture.backend); + fixture + .backend + .queue_execution_events_matching(is_listener_check(5900), output(b"")); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a silent port is an error"); + assert!( + error + .to_string() + .contains("nothing is listening on guest port 5900 after 10s"), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn withdrawing_access_disables_the_units_and_proves_they_stopped() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + queue_withdrawable_image(&fixture.backend); + // The Agent's own server on the viewer port is none of withdrawal's business. + queue_listening(&fixture.backend, true); + + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("withdraw")); + let specs = fixture.backend.execution_specs(); + assert!( + specs.iter().any(is_disable), + "the declared units are disabled and stopped" + ); + assert!(specs.iter().any(is_active_check), "the units are confirmed inactive"); + assert!( + !specs.iter().any(is_any_listener_check), + "once access is withdrawn the declared ports are ordinary guest ports" + ); +} + +/// Answers a withdrawal's probes: systemd running and a desktop image's descriptor. +fn queue_withdrawable_image(backend: &memory::Provider) { + queue_desktop_image(backend); + backend.queue_execution_events_matching(is_active_check, output(b"inactive\ninactive\n")); +} + +#[tokio::test(flavor = "local")] +async fn a_withdrawal_that_succeeded_is_not_repeated_until_the_incarnation_is_forgotten() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + let disables = || { + fixture + .backend + .execution_specs() + .iter() + .filter(|spec| is_disable(spec)) + .count() + }; + + queue_withdrawable_image(&fixture.backend); + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("withdraw")); + let probes = fixture.backend.execution_specs().len(); + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("resync")); + assert_eq!( + fixture.backend.execution_specs().len(), + probes, + "a resync after a successful withdrawal runs nothing in the guest" + ); + assert_eq!(disables(), 1); + + fixture.access.forget(withdrawn.id); + queue_withdrawable_image(&fixture.backend); + assert!( + !fixture + .access + .reconcile(&withdrawn, &sandbox) + .await + .expect("withdraw again") + ); + assert_eq!(disables(), 2, "a forgotten incarnation is withdrawn again"); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_failed_grant_forgets_an_earlier_withdrawal() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + let granted = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + let disables = || { + fixture + .backend + .execution_specs() + .iter() + .filter(|spec| is_disable(spec)) + .count() + }; + + queue_withdrawable_image(&fixture.backend); + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("withdraw")); + assert_eq!(disables(), 1); + + // The units are enabled, but the RFB port never listens, so the grant fails partway. + queue_desktop_image(&fixture.backend); + fixture + .access + .reconcile(&granted, &sandbox) + .await + .expect_err("a port that never listens fails the grant"); + assert!(fixture.backend.execution_specs().iter().any(is_enable)); + + queue_withdrawable_image(&fixture.backend); + assert!( + !fixture + .access + .reconcile(&withdrawn, &sandbox) + .await + .expect("withdraw again") + ); + assert_eq!( + disables(), + 2, + "the units a failed grant enabled are disabled rather than skipped as already withdrawn" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_unit_still_active_after_withdrawal_is_reported() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + fixture + .backend + .queue_execution_events_matching(is_active_check, output(b"inactive\nactive\n")); + queue_withdrawable_image(&fixture.backend); + + let error = fixture + .access + .reconcile(&withdrawn, &sandbox) + .await + .expect_err("a surviving unit is an error"); + assert!( + error.to_string().contains("agent-vnc-web.service is active"), + "withdrawal is verified rather than assumed: {error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_that_declares_no_vnc_access_is_reported_as_an_image_problem() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image(&fixture.backend, None); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a missing descriptor is an error"); + assert!(matches!(error, Error::Invalid(_)), "{error:?}"); + let message = error.to_string(); + assert!(message.contains("/etc/agent-access.d/vnc.conf is missing"), "{message}"); + assert!( + message.contains("remove `vnc` from spec.access"), + "the message names the remedies: {message}" + ); + assert_eq!( + ReconcileFailure::classify(&error).kind, + FailureKind::Invalid, + "an immutable image cannot be fixed by retrying" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_descriptor_declaring_the_wrong_port_is_refused() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image( + &fixture.backend, + Some(b"units=agent-vnc.socket\nport=5901\nweb-port=6080\n"), + ); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a port the caller could not be told about is an error"); + assert!( + error.to_string().contains("but VNC access uses guest port 5900"), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_without_the_descriptor_is_left_alone_when_no_access_is_declared() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image(&fixture.backend, None); + + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("pass")); + assert!( + !fixture.backend.execution_specs().iter().any(is_disable), + "an image with no VNC access to withdraw is not touched" + ); +} + +#[tokio::test(flavor = "local")] +async fn describing_an_agent_without_declared_access_names_the_remedy() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&record, 0).await; + + let error = fixture.access.describe("worker").await.expect_err("no declared access"); + assert!(error.to_string().contains("access: [{type: vnc}]"), "{error}"); + + let declared = self::record("viewer", "9a5b0a5a-6a4f-4f22-9f2a-2d1a3c4b5e6f", true); + fixture.store(&declared, 0).await; + let info = fixture.access.describe("viewer").await.expect("declared access"); + assert_eq!(info.kind, "vnc"); + assert_eq!(info.guest_port, 5900); + assert_eq!( + info.web_guest_port, None, + "which ports the image offers is observed, so it is unknown until a pass has looked" + ); + assert_eq!( + info.forward_command, + format!("{AGENTCTL} port-forward agent/viewer :5900") + ); +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..ae186a7 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +allow-expect-in-consts = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-consts = true +allow-unwrap-in-tests = true diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e4bf4ce --- /dev/null +++ b/deny.toml @@ -0,0 +1,26 @@ +# This policy intentionally contains no per-crate allowlist. New dependencies remain ordinary +# Cargo.toml and Cargo.lock changes. + +[graph] +all-features = true +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-gnu", + "aarch64-apple-darwin", + "x86_64-apple-darwin", +] + +[advisories] +yanked = "deny" +ignore = [ + # bincode 2 is unmaintained and has no maintained successor release. Microsandbox and its + # libkrun crates use it to serialize their own data; the advisory reports no vulnerability. + { id = "RUSTSEC-2025-0141", reason = "unmaintained bincode 2 used by Microsandbox; no known vulnerability or safe upgrade" }, +] + +[sources] +# crates.io is allowed by default. Additional registries fail until deliberately configured. +unknown-registry = "deny" +# Do not maintain a git repository allowlist, but require an explicit revision specifier. +unknown-git = "allow" +required-git-spec = "rev" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..9946197 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7530651 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/sandbox/MICROSANDBOX.md b/sandbox/MICROSANDBOX.md new file mode 100644 index 0000000..5f122dc --- /dev/null +++ b/sandbox/MICROSANDBOX.md @@ -0,0 +1,402 @@ +# Microsandbox downstream maintenance + +This runbook describes how to maintain the Microsandbox and libkrunfw forks used by +`sandbox-microsandbox`. It covers upstream synchronization, the Digdir patch queues, downstream +runtime publication and the exact source and artifact pins in this repository. + +The repositories have different release units. A Microsandbox source update is not complete until +the matching host runtime and guest agent have been published and Altinn Studio pins both the source +commit and the published artifact digests. + +## Repository and branch roles + +| Repository | Upstream mirror | Downstream integration | Consumer pin | +| --------------------------------------------------------------------- | --------------- | ------------------------------ | ------------------------------------------------------- | +| `superradcompany/microsandbox` forked as `martinothamar/microsandbox` | `main` | `main-digdir` | Root `Cargo.toml` and `Cargo.lock` | +| `superradcompany/libkrunfw` forked as `martinothamar/libkrunfw` | `krunfw` | `main-digdir` | Microsandbox `vendor/libkrunfw` submodule | +| `Altinn/altinn-studio` | Not applicable | The current development branch | Microsandbox source revision and runtime SHA-256 values | + +The mirror branches contain no Digdir changes. Update them with fast-forward-only pushes from their +corresponding upstream branches. Synchronizing a mirror branch does not select the next downstream +base: `main-digdir` is based on a stable Microsandbox release tag, not an arbitrary upstream `main` +commit. The `0.6.9-digdir.*` line predates this rule and sits two upstream commits past `v0.6.9`; the +first synchronization that follows this runbook moves the base onto the exact tag. + +The `main-digdir` branches are downstream patch queues. Rebuild them on temporary synchronization +branches and review each patch when moving to a new upstream base. Do not merge an upstream release +into a patch queue: the resulting conflict-resolution merge hides which downstream patches remain +necessary and makes later synchronization harder. + +## Required invariants + +- Before rewriting a published downstream branch, create an immutable tag for every commit still + pinned by a consumer. This keeps older checkouts reproducible and prevents Git hosting from + garbage-collecting the pinned commit. Today the only consumer is the `src/experimental/` stack: + the current development branch plus the `Cargo.lock` of every `experimental-agent/v*` release + tag, since Cargo fetches Git dependencies by revision. Revisit this list when `src/experimental/` + moves to its own repositories. +- The Microsandbox workspace version uses `-digdir.`. +- `digdir-v` points at the exact Microsandbox commit from which the downstream + runtime assets were built. +- All internal Microsandbox crates use the same exact workspace version, and `Cargo.lock` agrees. +- The Microsandbox commit pins the intended private libkrunfw commit through + `vendor/libkrunfw`. +- The `msb` host executable, embedded `agentd` and libkrunfw artifact come from the tagged + Microsandbox runtime release revision. Every published `msb` is built with the `embed-binaries` + feature: without it `msb --version` still works, but no guest agent is embedded and every sandbox + start fails. +- The embedded SDK launches only a runtime whose embedded version equals its own workspace version + exactly, so the SDK and the published `msb` always come from the same Digdir revision. +- Altinn Studio normally pins that same release revision. A source-only descendant may instead use + an immutable `digdir-source-v-` tag after verifying that it needs no new + host runtime, guest agent, firmware or protocol behavior (see the check in step 7). A source-only + descendant keeps the runtime tag's workspace version unchanged, because of the exact version match + above. Never pin an untagged follow-up commit. +- Altinn Studio records the SHA-256 digest of every supported host runtime bundle before the new + source revision is merged. +- Existing release tags and assets are immutable. A correction gets a new Digdir revision. + +The release dependency flows in one direction: + +```text +stable Microsandbox tag + + +Digdir libkrunfw patch queue + + +Digdir Microsandbox patch queue + | + v +digdir-v source tag and runtime assets + | + v +Altinn Cargo revision, lockfile and runtime SHA-256 pins +``` + +## Review gates + +Tags and release assets are immutable and a published `main-digdir` is shared, so human review +belongs on the fork before the tag, not on the Altinn pull request. + +1. **After triage (step 3).** Review the triage record before the rebase starts. Every wrong `drop` + or misdirected `adapt` decision found later costs a rebase pass. +2. **Before the tag (step 6).** Review the `sync/digdir-X.Y.Z` pull request on the fork: the + `range-diff` against the triage record, the complete tree diff from the upstream tag and the test + results. Approval authorizes the `digdir-v*` tag. + +A synchronization pull request rewrites history onto a new base, so GitHub marks it as conflicting +and never runs its `pull_request` checks. Start `check-digdir.yml` on the branch with +`gh workflow run check-digdir.yml --ref sync/digdir-X.Y.Z` after every push. Once the pull request +is approved, merge it by moving the integration branch, never with a merge, squash or rebase +button: `git push --force-with-lease=main-digdir: origin sync/digdir-X.Y.Z:main-digdir`. +GitHub then marks the pull request as merged. Do the same for libkrunfw first, because the +Microsandbox submodule pins its rewritten commit. + +The fork pull request description is a short summary for the reviewer: which triage decisions +changed during the rebase and why, what is not covered by tests, and what was verified where. Do +not paste the triage record, process notes, or anything the reviewer can read from the diff. + +Text written to the forks, in commit messages, pull request titles and descriptions, and code or +workflow comments, must never contain `#` references or GHSA identifiers. GitHub resolves a +bare `#123` in a fork against the upstream repository and records a cross-reference in that upstream +issue's or pull request's history, which is noise for the upstream maintainers. Cite an upstream +change by its short commit SHA, and refer to triage rows as "row 3", never "#3". Before pushing or +opening the pull request, check with `git log --format=%B ..HEAD | grep -E '#[0-9]+|GHSA'` +and the same grep over the description. + +The Altinn pull request (step 7) only needs a check that the revisions, lockfile and digests agree +with the published release and that first-run installation was exercised from an empty home. + +## 1. Refresh the upstream mirrors + +Configure `origin` as the personal fork and `upstream` as the Super Rad Company repository. Fetch +branches and tags before comparing histories. + +For Microsandbox: + +```bash +git fetch origin +git fetch upstream --tags +git switch main +git merge --ff-only upstream/main +git push origin main +``` + +For libkrunfw, whose upstream default branch is `krunfw`: + +```bash +git fetch origin +git fetch upstream --tags +git switch krunfw +git merge --ff-only upstream/krunfw +git push origin krunfw +``` + +Stop if either fast-forward fails. A mirror branch with fork-only commits must be inspected and +repaired rather than merged. + +## 2. Select the stable Microsandbox base + +Use the latest non-prerelease GitHub release unless a specific version has been selected for a +documented reason. Verify the release tag and record its commit. Do not use upstream `main` merely +because the mirror has been refreshed. + +```bash +target_msb_tag=vX.Y.Z +git rev-parse "$target_msb_tag^{commit}" +``` + +Inspect the libkrunfw submodule revision selected by that release: + +```bash +git ls-tree "$target_msb_tag" vendor/libkrunfw +git show "$target_msb_tag:.gitmodules" +``` + +That submodule revision is the libkrunfw base for a strict stable-release synchronization. Newer +commits on upstream libkrunfw are a separate upgrade decision and must not be included implicitly. + +## 3. Triage the upstream changes + +Most upstream commits do not affect this stack. Altinn Studio consumes the Rust SDK, the crates it +links (`image`, `network`, `filesystem` and, transitively, the rest of `crates/`), the guest agent, +the firmware submodule and the release workflow that produces the host artifacts. Documentation, +the other language SDKs, examples and package publishing are out of scope. Reduce the upstream +range to the commits that matter before touching the patch queue: + +```bash +old_msb_base=$(git merge-base origin/main-digdir "$target_msb_tag") +git log --oneline --no-merges "$old_msb_base".."$target_msb_tag" -- \ + crates sdk/rust vendor/libkrunfw Cargo.toml Cargo.lock \ + '.github/workflows/release*.yml' scripts/ci \ + ':!**/*.md' ':!crates/*/examples' ':!crates/*/benches' +``` + +The release workflow paths are in scope because they decide how the published binaries are built. +An upstream change to the build container, linker baseline or artifact validation does not make +the downstream workflow fail; it silently makes the downstream artifacts differ from upstream's. +The 0.6.18 synchronization missed the upstream move to a glibc 2.28 baseline for exactly this +reason. + +Then narrow further to the paths the downstream patches own, which forecasts the rebase conflicts +and reveals patches that upstream may have made redundant: + +```bash +git diff --name-only "$old_msb_base" origin/main-digdir -- crates sdk/rust | + xargs git log --oneline --no-merges "$old_msb_base".."$target_msb_tag" -- +``` + +Read each remaining commit and write the result down as a triage record before starting the +rebase. The record is the handoff between triage, rebase and release, which may be done by +different people or in separate sessions. It stays in the sync notes; it is not the pull request +description (see the review gates). It contains: + +- one row per downstream commit: subject, decision (`keep`, `adapt`, `drop`), the upstream commits + that motivate the decision, and for `adapt` the new upstream API or file location to target; +- the upstream commits in the narrowed list that touch no downstream patch but change behavior the + stack relies on, such as protocol, guest agent or firmware changes, or the image catalog semantics + that `sandbox-microsandbox` image removal relies on: `Image::remove_local` removes a manifest + with its last reference and refuses while a runtime pins it, `Image::persist` refreshes a + reference's last use, and `Image::prune_local` keeps pinned images (see `image_cache.rs`); and +- upstream refactors that moved or deleted files a patch touches, since `git rebase` reports those + as delete/modify conflicts and the patch must be re-applied by hand at the new location. + +For every upstream change to the release workflows, decide whether `release-digdir-runtime.yml` +must adopt it; it copies upstream's build steps and inherits none of their later fixes. + +## 4. Rebase the libkrunfw patch queue + +First compare the libkrunfw revision selected by the new tag with the base of the current downstream +queue: + +```bash +git ls-tree "$target_msb_tag" vendor/libkrunfw +git -C vendor/libkrunfw merge-base origin/main-digdir upstream/krunfw +``` + +Compare their trees rather than their commit IDs. An upstream tag can pin a libkrunfw commit that no +upstream branch reaches, for example a pull request head that was later squash-merged; `v0.7.4` +pins `cf4c22b9`, whose tree equals the squash-merged `49862475` on `krunfw`: + +```bash +git -C vendor/libkrunfw diff --quiet && echo identical +``` + +If the pinned commit is not on `upstream/krunfw`, rebase onto the `krunfw` commit with the identical +tree instead, so that the next synchronization's merge-base stays meaningful. + +If the trees are equal to the current base, upstream has not moved the firmware and this step is a +no-op: keep the existing `main-digdir` commit of libkrunfw and continue with step 5. This was the case +for every release from `v0.6.9` through `v0.6.18`; `v0.7.4` moved it. + +Otherwise create a temporary branch from the existing downstream branch. Tag the old consumed tip +before rewriting or moving any published reference. + +```bash +git switch -c sync/libkrunfw-X.Y.Z origin/main-digdir +target_libkrunfw_commit=REPLACE_WITH_RECORDED_COMMIT +old_libkrunfw_base=$(git merge-base origin/main-digdir "$target_libkrunfw_commit") +git rebase --interactive --onto "$target_libkrunfw_commit" "$old_libkrunfw_base" +``` + +During the rebase, retain only the kernel configuration and firmware behavior still required by the +Agent platform. Resolve generated kernel configuration changes deliberately; do not accept an entire +side of a conflict without checking every required option. + +Review the rewritten patch queue: + +```bash +git range-diff \ + "$old_libkrunfw_base"..origin/main-digdir \ + "$target_libkrunfw_commit"..sync/libkrunfw-X.Y.Z +``` + +Run libkrunfw's kernel configuration checks and builds for every supported guest architecture. The +Microsandbox release workflow builds firmware from this submodule, but it is not a substitute for +checking the rewritten libkrunfw commits themselves. + +Push the temporary branch for review. Move `main-digdir` only after every still-consumed commit has +an immutable tag and the new patch queue passes its checks. Use `--force-with-lease` rather than an +unguarded force push if the integration branch must be moved to rewritten history. + +## 5. Rebase the Microsandbox patch queue + +Work on a temporary branch based on the current downstream tip: + +```bash +git switch -c sync/digdir-X.Y.Z origin/main-digdir +git rebase --interactive --onto "$target_msb_tag" "$old_msb_base" +``` + +Apply the triage list from step 3 to every downstream commit. In particular: + +- drop behavior that the selected upstream release now implements; +- adapt patches when upstream provides a new API for the same purpose; +- keep Altinn-specific network control, prepared-root, runtime isolation and runtime publication + behavior separate where possible; +- keep functional patches separate from downstream version bumps and release plumbing; +- drop the previous `chore: bump downstream runtime` commit during the rebase and add a fresh one + at the tip once every functional patch is in place; and +- preserve the ordering between protocol changes, the embedded guest agent and host runtime changes. + +Update `vendor/libkrunfw` to the reviewed private libkrunfw commit from step 4. Do not +update the submodule to the tip of either libkrunfw branch without verifying its ancestry and content. + +Finish with one downstream version such as `X.Y.Z-digdir.1`. Update every internal exact version and +regenerate `Cargo.lock`. The version suffix increments for any correction published from the same +upstream release. + +Update the triage record when a decision changes during the rebase, so that the record and the +final `range-diff` agree. + +Compare the old and new patch queues before publishing: + +```bash +git range-diff \ + "$old_msb_base"..origin/main-digdir \ + "$target_msb_tag"..sync/digdir-X.Y.Z +``` + +Also inspect the complete tree difference from the upstream tag. `range-diff` explains rewritten +commits; it does not reveal an accidentally retained generated file or submodule pointer by itself. + +## 6. Validate and publish the downstream runtime + +Run the Microsandbox repository's focused checks for every touched crate, followed by its workspace +checks. Runtime, networking, filesystem, image and protocol changes require the hardware-backed +integration tests on supported hosts. A compile-only result does not establish that the host and +guest protocol still agree. + +Before tagging, verify all of the following: + +- the workspace and internal crate versions are identical; +- `Cargo.lock` is current; +- `vendor/libkrunfw` is initialized at the committed private revision; +- the downstream release workflow accepts the version and submodule remote; +- the runtime download helpers use the downstream release repository and tag scheme; and +- no build uses an old `agentd`, `msb` or libkrunfw artifact from a local cache. + +Create `digdir-v` at the reviewed source commit. The downstream release workflow +builds and publishes the host bundles, standalone guest agents and libkrunfw artifacts for Linux +x86_64, Linux aarch64, macOS aarch64, Windows x86_64 and Windows aarch64. It only builds and +checksums; it does not start a sandbox on any of them, so the runtime tests above are the only +execution coverage a release gets. Treat a partially +published or failed release as unusable and publish a corrected Digdir revision instead of replacing +assets. + +Download the published checksum manifest and independently verify each runtime bundle. For the +Linux bundles also confirm the glibc baseline by listing the `GLIBC_*` versions the binaries +import; the release workflow's validator gate must have run, but check the artifact itself. Record the +bundle SHA-256 values for Linux x86_64, Linux aarch64, macOS aarch64, Windows x86_64 and Windows +aarch64. If the supported platform matrix changes, update both the downstream release validation and +Altinn Studio's digest table in the same change. + +## 7. Update Altinn Studio + +Only update Altinn Studio after the downstream runtime release is complete and verified, or after a +source-only descendant has been audited as compatible with the already verified runtime release. + +1. Update every `microsandbox*` Git revision together in the root `Cargo.toml`. +2. Regenerate the root `Cargo.lock` and confirm every Git-sourced Microsandbox package resolves to + the same revision and downstream version. +3. Search the repository for every remaining reference to the previous pin. For a runtime release, + replace the previous version, revision and bundle digests. For a source-only update, replace the + source revision but keep the compatible runtime version and bundle digests unchanged. Do not rely + on a list of known files; search for the identifiers themselves: + + ```bash + git grep -n -e '' -e '' + git grep -n -F -f <(printf '%s\n' ) + ``` + + Runtime-release hits include the runtime bundle digest table in + `sandbox-microsandbox/src/client.rs`, container images that download the runtime bundle (CI fails + on any skew between such a pin and `Cargo.lock`), and comments that name the pinned downstream + version. For a source-only update, verify those runtime references still match the compatible + `digdir-v*` tag. Repeat the source-revision search until it returns only this runbook and changelog + history. +4. Confirm that the Cargo revision is tagged by either the corresponding `digdir-v*` release or an + explicitly runtime-compatible `digdir-source-v*` tag. Start a source-only audit with the complete + diff from the runtime tag: + + ```bash + git diff --stat digdir-v..digdir-source-v- -- . + ``` + + Changes confined to `sdk/rust` are normally source-only. Shared host libraries under `crates/` + may also qualify when the consumer pull request records that the changed production symbols are + reached only from the embedded host SDK, the existing runtime does not execute the changed path, + and protocol and artifact behavior are unchanged. This explicit audit is required because the + repository paths alone do not identify which binary executes shared library code. + + A new Digdir runtime revision is required for changes used by the published `msb`, embedded + `agentd`, libkrunfw or firmware artifacts, or for changes to the host/guest protocol, release + workflow or artifact composition. Tests alone may change without a runtime release when their + non-test code is untouched. + +5. Run the experimental formatting, lint, build and unit-test targets. +6. Run the ignored Microsandbox end-to-end tests on hosts with Docker, Internet access and hardware + virtualization. +7. Exercise first-run runtime installation from an empty provider home so stale local artifacts + cannot mask a release or checksum error, and separately exercise an upgrade from a provider home + and database populated by the previously pinned version, since migrations only run there. +8. Run `yarn spell:quick` for the changed documentation and source files. + +The Altinn change is internal maintenance unless it changes behavior visible to Agent users. Use the +`skip-changelog` label for internal-only synchronization; otherwise describe the user-visible effect +under `Unreleased` in `CHANGELOG.md`. + +## Rollback + +Rollback is an Altinn pin change, not a mutation of an existing downstream release. Restore the +previous tagged Microsandbox revision, lockfile resolution and matching runtime digest table +together. Do not combine source from one downstream version with runtime artifacts from another. + +Keep the failed downstream source tag and release for diagnosis. Publish a new Digdir revision when +the problem is corrected. + +## Future upstream contributions + +Upstream contribution branches are separate from downstream synchronization. Start them from the +current upstream default branch and cherry-pick one coherent downstream change at a time. Do not +merge those branches back into `main-digdir`; a later stable upstream release brings accepted work +back into the downstream base, where the corresponding patch can be dropped or reduced. diff --git a/sandbox/sandbox-authorization/Cargo.toml b/sandbox/sandbox-authorization/Cargo.toml new file mode 100644 index 0000000..9ede523 --- /dev/null +++ b/sandbox/sandbox-authorization/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sandbox-authorization" +description = "Context-aware authorization contracts for sandbox-originated operations" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio.workspace = true + +[lints] +workspace = true diff --git a/sandbox/sandbox-authorization/src/lib.rs b/sandbox/sandbox-authorization/src/lib.rs new file mode 100644 index 0000000..9cb9f38 --- /dev/null +++ b/sandbox/sandbox-authorization/src/lib.rs @@ -0,0 +1,343 @@ +//! Context-aware authorization contracts for sandbox-originated operations. +//! +//! Trusted enforcement points construct requests, policy engines make decisions, +//! and the component performing an operation enforces the result. This crate does +//! not perform operations or depend on an enforcement implementation. + +use std::{collections::BTreeMap, future::Future, pin::Pin}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Stable vocabulary shared by trusted authorization request producers. +pub mod vocabulary { + /// Built-in Sandbox-originated Actions. + pub mod action { + /// Resolve a DNS name. + pub const DNS_QUERY: &str = "dns.query"; + /// Send a complete HTTP request. + pub const HTTP_REQUEST: &str = "http.request"; + /// Open a transport connection. + pub const NETWORK_CONNECT: &str = "network.connect"; + /// Use host-owned secret material at an authorized location. + pub const SECRET_USE: &str = "secret.use"; + // Spawn another session in-sandbox + pub const SESSION_SPAWN: &str = "session.spawn"; + // Spawn another session in a different sandbox + pub const AGENT_SPAWN: &str = "agent.spawn"; + } + + /// Built-in Principal kinds. + pub mod principal_kind { + /// One materialized Sandbox. + pub const SANDBOX: &str = "sandbox"; + } + + /// Built-in Resource kinds. + pub mod resource_kind { + /// A DNS domain name. + pub const DOMAIN: &str = "domain"; + /// An external network service. + pub const EXTERNAL_SERVICE: &str = "externalService"; + /// Host-owned secret material. + pub const SECRET: &str = "secret"; + } + + /// Built-in trusted Context attribute names. + pub mod context { + /// Stable Sandbox name supplied by its trusted Network Backend. + pub const SANDBOX_NAME: &str = "sandbox.name"; + /// DNS record type. + pub const DNS_RECORD_TYPE: &str = "dns.recordType"; + /// DNS resolver socket address. + pub const DNS_RESOLVER: &str = "dns.resolver"; + /// HTTP authority. + pub const HTTP_AUTHORITY: &str = "http.authority"; + /// HTTP method. + pub const HTTP_METHOD: &str = "http.method"; + /// HTTP path without query data. + pub const HTTP_PATH: &str = "http.path"; + /// HTTP scheme. + pub const HTTP_SCHEME: &str = "http.scheme"; + /// HTTP/2 stream identifier. + pub const HTTP_STREAM_ID: &str = "http.streamId"; + /// HTTP protocol version. + pub const HTTP_VERSION: &str = "http.version"; + /// Network destination socket address. + pub const NETWORK_DESTINATION_ADDRESS: &str = "network.destinationAddress"; + /// Whether the destination is the host, reported by the trusted Network + /// Backend when it rewrites a gateway-bound flow to host loopback. + pub const NETWORK_DESTINATION_IS_HOST: &str = "network.destinationIsHost"; + /// Network hostname supplied by a trusted parser. + pub const NETWORK_HOSTNAME: &str = "network.hostname"; + /// Network source socket address. + pub const NETWORK_SOURCE_ADDRESS: &str = "network.sourceAddress"; + /// Network transport protocol. + pub const NETWORK_TRANSPORT: &str = "network.transport"; + /// Authorized secret injection locations. + pub const SECRET_LOCATIONS: &str = "secret.locations"; + } +} + +/// A non-`Send` future executed by a Tokio local runtime. +pub type LocalFuture<'a, T> = Pin + 'a>>; + +/// Authenticated sandbox entity on whose authority an Action is requested. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Principal { + /// Principal category, such as `agent`, `sandbox`, `session`, or `execution`. + pub kind: String, + /// Stable identifier within that category. + pub id: String, +} + +impl Principal { + /// Creates a Principal from trusted identity information. + #[must_use] + pub fn new(kind: impl Into, id: impl Into) -> Self { + Self { + kind: kind.into(), + id: id.into(), + } + } +} + +/// Stable name of a requested operation. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(transparent)] +pub struct Action(String); + +impl Action { + /// Creates an Action name. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the stable Action name. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Action { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Target against which an Action is requested. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Resource { + /// Resource category, such as `externalService`, `secret`, `repository`, or `tool`. + pub kind: String, + /// Stable identifier within that category. + pub id: String, +} + +impl Resource { + /// Creates a Resource identifier. + #[must_use] + pub fn new(kind: impl Into, id: impl Into) -> Self { + Self { + kind: kind.into(), + id: id.into(), + } + } +} + +/// One typed trusted Context value. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +#[non_exhaustive] +pub enum AuthorizationValue { + /// Textual value. + String(String), + /// Signed integer value. + Integer(i64), + /// Boolean value. + Boolean(bool), + /// Ordered textual values whose boundaries must be preserved. + Strings(Vec), +} + +impl AuthorizationValue { + /// Returns a textual value. + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + Self::Integer(_) | Self::Boolean(_) | Self::Strings(_) => None, + } + } + + /// Returns an integer value. + #[must_use] + pub const fn as_integer(&self) -> Option { + match self { + Self::Integer(value) => Some(*value), + Self::String(_) | Self::Boolean(_) | Self::Strings(_) => None, + } + } + + /// Returns a Boolean value. + #[must_use] + pub const fn as_bool(&self) -> Option { + match self { + Self::Boolean(value) => Some(*value), + Self::String(_) | Self::Integer(_) | Self::Strings(_) => None, + } + } + + /// Returns an ordered string-list value. + #[must_use] + pub fn as_strings(&self) -> Option<&[String]> { + match self { + Self::Strings(values) => Some(values), + Self::String(_) | Self::Integer(_) | Self::Boolean(_) => None, + } + } +} + +impl From for AuthorizationValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for AuthorizationValue { + fn from(value: &str) -> Self { + Self::String(value.to_string()) + } +} + +impl From for AuthorizationValue { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From for AuthorizationValue { + fn from(value: u32) -> Self { + Self::Integer(i64::from(value)) + } +} + +impl From for AuthorizationValue { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +impl From> for AuthorizationValue { + fn from(value: Vec) -> Self { + Self::Strings(value) + } +} + +/// Trusted facts relevant to an authorization decision. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AuthorizationContext { + /// Extensible attributes established by trusted enforcement components. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub attributes: BTreeMap, +} + +impl AuthorizationContext { + /// Creates an empty Context. + #[must_use] + pub const fn new() -> Self { + Self { + attributes: BTreeMap::new(), + } + } + + /// Adds one trusted attribute. + #[must_use] + pub fn with_attribute(mut self, name: impl Into, value: impl Into) -> Self { + self.attributes.insert(name.into(), value.into()); + self + } + + /// Inserts or replaces one trusted attribute. + pub fn insert(&mut self, name: impl Into, value: impl Into) { + self.attributes.insert(name.into(), value.into()); + } + + /// Returns one trusted attribute. + #[must_use] + pub fn get(&self, name: &str) -> Option<&AuthorizationValue> { + self.attributes.get(name) + } +} + +/// Complete input evaluated by an Authorization Policy Engine. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AuthorizationRequest { + /// Authenticated sandbox authority behind the request. + pub principal: Principal, + /// Requested operation. + pub action: Action, + /// Target of the operation. + pub resource: Resource, + /// Trusted facts relevant to this request. + pub context: AuthorizationContext, +} + +/// Result of policy evaluation before domain-specific enforcement. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AuthorizationDecision { + /// The Action may proceed. + Allow, + /// The Action must not proceed. + Deny, +} + +/// Policy-evaluation failures, distinct from an intentional deny decision. +#[derive(Debug, Error)] +pub enum Error { + /// The configured engine could not evaluate a request. + #[error("authorization policy engine failed: {0}")] + Engine(String), +} + +/// Evaluates Authorization Requests without performing their Actions. +pub trait PolicyEngine { + /// Evaluates one request. + fn evaluate(&self, request: AuthorizationRequest) -> LocalFuture<'_, Result>; +} + +/// Deterministic policy engine for local wiring and tests. +pub struct StaticPolicy { + decision: AuthorizationDecision, +} + +impl StaticPolicy { + /// Creates a policy that allows every request. + #[must_use] + pub const fn allow_all() -> Self { + Self { + decision: AuthorizationDecision::Allow, + } + } + + /// Creates a policy that denies every request. + #[must_use] + pub const fn deny_all() -> Self { + Self { + decision: AuthorizationDecision::Deny, + } + } +} + +impl PolicyEngine for StaticPolicy { + fn evaluate(&self, _request: AuthorizationRequest) -> LocalFuture<'_, Result> { + Box::pin(async move { Ok(self.decision) }) + } +} diff --git a/sandbox/sandbox-authorization/tests/architecture.rs b/sandbox/sandbox-authorization/tests/architecture.rs new file mode 100644 index 0000000..dbcf42e --- /dev/null +++ b/sandbox/sandbox-authorization/tests/architecture.rs @@ -0,0 +1,12 @@ +#![allow(clippy::expect_used)] + +use std::{fs, path::Path}; + +#[test] +fn sandbox_authorization_contracts_do_not_depend_on_enforcement_points() { + let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("Sandbox Authorization Cargo.toml should be readable"); + + assert!(!manifest.contains("agent =")); + assert!(!manifest.contains("sandbox =")); +} diff --git a/sandbox/sandbox-authorization/tests/policy.rs b/sandbox/sandbox-authorization/tests/policy.rs new file mode 100644 index 0000000..08886a2 --- /dev/null +++ b/sandbox/sandbox-authorization/tests/policy.rs @@ -0,0 +1,23 @@ +#![allow(clippy::expect_used)] + +use sandbox_authorization::{ + Action, AuthorizationContext, AuthorizationDecision, AuthorizationRequest, PolicyEngine as _, Principal, Resource, + StaticPolicy, +}; + +#[tokio::test(flavor = "local")] +async fn static_policy_returns_its_configured_decision() { + evaluate(&StaticPolicy::allow_all(), AuthorizationDecision::Allow).await; + evaluate(&StaticPolicy::deny_all(), AuthorizationDecision::Deny).await; +} + +async fn evaluate(policy: &StaticPolicy, expected: AuthorizationDecision) { + let request = AuthorizationRequest { + principal: Principal::new("agent", "worker"), + action: Action::new("network.connect"), + resource: Resource::new("externalService", "api.github.com"), + context: AuthorizationContext::default(), + }; + let decision = policy.evaluate(request).await.expect("policy evaluation"); + assert_eq!(decision, expected); +} diff --git a/sandbox/sandbox-microsandbox/Cargo.toml b/sandbox/sandbox-microsandbox/Cargo.toml new file mode 100644 index 0000000..6376566 --- /dev/null +++ b/sandbox/sandbox-microsandbox/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "sandbox-microsandbox" +description = "Microsandbox implementations of Sandbox SDK and enforcement contracts" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +bollard.workspace = true +bytes.workspace = true +futures-util.workspace = true +ignore.workspace = true +microsandbox.workspace = true +microsandbox-image.workspace = true +microsandbox-network.workspace = true +sandbox = { path = "../sandbox" } +sandbox-authorization = { path = "../sandbox-authorization" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tar.workspace = true +tempfile.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +uuid.workspace = true +zeroize.workspace = true + +[lints] +workspace = true diff --git a/sandbox/sandbox-microsandbox/src/backend.rs b/sandbox/sandbox-microsandbox/src/backend.rs new file mode 100644 index 0000000..bf94791 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/backend.rs @@ -0,0 +1,1052 @@ +use std::{ + cell::RefCell, + collections::{BTreeMap, HashMap}, + path::{Path, PathBuf}, + rc::Rc, +}; + +use microsandbox::sandbox::{PullPolicy, SandboxStatus}; +use sandbox::progress::SandboxProgress; +use sandbox::{ + Error, LocalFuture, PendingOperation, Platform, ResourceKind, RootFilesystemMode, RootFilesystemModeSet, Sandbox, + SandboxFeature, SandboxId, SandboxName, SandboxResources, SandboxState, + backend::{CreateSandboxRequest, SandboxBackend, SandboxBackendCapabilities}, + execution, file_transfer, + mount::{Mount, MountKind, MountKindSet}, + network, + provider::SandboxProvider, + terminal, volume, +}; + +use crate::{ + client::{Client, RuntimeResources}, + error, + execution::ExecutionControls, + heartbeat, + image::MicrosandboxImageBackend, + image_cache::ImageCache, + network_endpoint, platform, + state::{SandboxRecord, StateStore}, +}; + +const RECORD_SANDBOX: &str = "Record Sandbox state"; +const INSTALL_RUNTIME: &str = "Install Microsandbox runtime"; +const RESOLVE_RUNTIME_INPUTS: &str = "Resolve Microsandbox runtime inputs"; +const MATERIALIZE_DIRECT_ROOT_IMAGE: &str = "Materialize direct root image"; +const CREATE_RUNTIME: &str = "Create Microsandbox VM"; +const START_RUNTIME: &str = "Start Microsandbox VM"; +const UPDATE_RUNTIME_RESOURCES: &str = "Update Microsandbox VM resources"; +const UPDATE_RUNTIME_ENVIRONMENT: &str = "Update Microsandbox environment"; + +/// How long a stopping runtime may take to shut its guest down before it is +/// killed. Microsandbox's own `stop` waits indefinitely, so a wedged guest +/// would otherwise block stopping and deleting the Sandbox. +const STOP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Microsandbox Provider pairing its Sandbox Backend with its Image Backend. +pub struct MicrosandboxProvider { + pub(crate) client: Client, + images: ImageCache, + image_backend: MicrosandboxImageBackend, + pub(crate) state: StateStore, + pub(crate) executions: ExecutionControls, +} + +/// Configures the host storage used by a [`MicrosandboxProvider`]. +pub struct MicrosandboxProviderBuilder { + home: PathBuf, + cache_directory: Option, + unused_image_retention: Option, + registry_authentication: Option, + runtime_bundle: Option, +} + +#[derive(Clone)] +pub(crate) struct RuntimeBundle { + pub(crate) path: PathBuf, + pub(crate) sha256: String, +} + +impl MicrosandboxProvider { + /// Configures a Microsandbox Provider below its private data directory. + #[must_use] + pub fn builder(home: impl Into) -> MicrosandboxProviderBuilder { + MicrosandboxProviderBuilder { + home: home.into(), + cache_directory: None, + unused_image_retention: None, + registry_authentication: None, + runtime_bundle: None, + } + } + + /// Opens an isolated Microsandbox Provider below its data directory. + /// + /// # Errors + /// + /// Returns an error when the home cannot be initialized or Microsandbox + /// cannot open its local runtime. + pub async fn open(home: impl AsRef) -> Result { + Self::builder(home.as_ref().to_path_buf()).open().await + } + + async fn open_configured( + home: PathBuf, + cache_directory: Option, + unused_image_retention: Option, + registry_authentication: Option, + runtime_bundle: Option, + ) -> Result { + if home.as_os_str().is_empty() { + return Err(Error::invalid("provider.home", "must not be empty")); + } + if cache_directory.as_ref().is_some_and(|path| path.as_os_str().is_empty()) { + return Err(Error::invalid("provider.cacheDirectory", "must not be empty")); + } + if let Some(retention) = unused_image_retention { + if cache_directory.is_some() { + return Err(Error::invalid( + "provider.unusedImageRetention", + "cannot be combined with a cache directory, which other Providers may share", + )); + } + if retention < crate::image_cache::MINIMUM_RETENTION { + return Err(Error::invalid( + "provider.unusedImageRetention", + "must be at least an hour, to cover the time between resolving an image and creating its Sandbox", + )); + } + } + if let Some(bundle) = &runtime_bundle { + if !bundle.path.is_file() { + return Err(Error::invalid( + "provider.runtimeBundle.path", + "must identify a regular file", + )); + } + if bundle.sha256.len() != 64 || !bundle.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::invalid( + "provider.runtimeBundle.sha256", + "must be a 64-character hexadecimal SHA-256 digest", + )); + } + } + let state = StateStore::open(home.join("state")).await?; + let client = Client::open(home.join("runtime"), cache_directory, runtime_bundle).await?; + let images = ImageCache::new(client.clone(), state.clone(), unused_image_retention); + let image_backend = MicrosandboxImageBackend::new(client.clone(), images.clone(), registry_authentication); + let provider = Self { + client, + images, + image_backend, + state, + executions: Rc::new(RefCell::new(HashMap::new())), + }; + if unused_image_retention.is_some() { + if let Err(error) = Box::pin(provider.migrate_images()).await { + tracing::warn!(%error, "failed to migrate the Microsandbox image catalog; retrying when the Provider next opens"); + } + Box::pin(provider.images.remove_unused()).await; + } + Ok(provider) + } + + /// Migrates a catalog recorded before Sandboxes held their images, while the Provider opens + /// and before anything else runs. Every Sandbox holds its image; one with a runtime whose + /// image the old catalog lost fetches it again by digest. Image versions nothing holds are + /// then removed, but only once every Sandbox's image is protected from that removal, which + /// a Sandbox that never started, or whose first start was interrupted, is not. Until then + /// each open tries again. + async fn migrate_images(&self) -> Result<(), Error> { + if !self.images.migration_pending().await { + return Ok(()); + } + let mut every_image_pinned = true; + for record in self.state.sandbox_records().await? { + let held = if self.runtime_handle(&record.runtime_name).await?.is_some() { + match self.hold_image(&record).await { + Ok(_) => true, + Err(error) => { + tracing::warn!(sandbox = %record.id, %error, "failed to hold a Sandbox's image"); + false + } + } + } else { + self.images.hold(&record).await?.is_some() + }; + every_image_pinned &= held && self.images.is_pinned(&record).await?; + } + if !every_image_pinned { + tracing::info!("keeping image versions from before this release until every Sandbox's image is protected"); + return Ok(()); + } + self.images.finish_migration().await + } + + /// Makes a Sandbox hold its image and returns the name to create its runtime from. An image + /// no longer in the cache is fetched again from its registry by digest. + pub(crate) async fn hold_image(&self, record: &SandboxRecord) -> Result { + use sandbox::image::ImageBackend as _; + + if let Some(entry) = self.images.hold(record).await? { + return Ok(entry); + } + let manifest_digest = &record.image.manifest_digest; + if let sandbox::image::ImageSource::Reference { reference } = &record.image.source { + let reference = reference + .parse::() + .map_err(error::backend)?; + let pinned = microsandbox_image::Reference::with_digest( + reference.registry().to_string(), + reference.repository().to_string(), + manifest_digest.clone(), + ); + self.image_backend + .resolve(&sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: pinned.to_string(), + }, + platform: record.image.platform.clone(), + root_filesystem_mode: record.resources.root_filesystem().mode(), + }) + .await?; + if let Some(entry) = self.images.hold(record).await? { + return Ok(entry); + } + } + Err(Error::Backend(format!( + "image manifest digest {manifest_digest} is not present in this Microsandbox cache" + ))) + } + + /// Removes unused images now, as the Provider also does when it opens, after each image is + /// resolved or imported and after each Sandbox is deleted. Only a Provider opened with + /// [`MicrosandboxProviderBuilder::remove_unused_images_after`] removes any. + pub async fn remove_unused_images(&self) { + self.images.remove_unused().await; + } + + #[cfg(test)] + pub(crate) const fn images(&self) -> &ImageCache { + &self.images + } + + async fn create_record(&self, request: CreateSandboxRequest) -> Result { + platform::require_supported(&request.image.platform)?; + RuntimeResources::try_from(request.resources)?; + RuntimeNetwork::for_attachment(request.network.as_ref())?; + match self.state.sandbox_by_name(&request.name).await { + Ok(_) => return Err(Error::Backend(format!("Sandbox '{}' already exists", request.name))), + Err(error) if error.is_not_found() => {} + Err(error) => return Err(error), + } + let record = SandboxRecord::new(request); + self.state.save_sandbox(&record).await?; + // The record comes first, so an image entry without a record is always a deleted + // Sandbox's, which removal passes clean up. + if let Err(error) = self.hold_image(&record).await { + if let Err(cleanup) = self.state.remove_sandbox(&record).await { + tracing::warn!(sandbox = %record.id, error = %cleanup, "failed to remove the record of a Sandbox without its image"); + } + return Err(error); + } + Ok(record.to_sandbox(SandboxState::Stopped)) + } + + async fn inspect_record(&self, record: &SandboxRecord) -> Result { + let Some(handle) = self.runtime_handle(&record.runtime_name).await? else { + return Ok(record.to_sandbox(SandboxState::Stopped)); + }; + let status = handle.status_snapshot(); + // A starting, draining or paused guest is not expected to beat, so its + // stale heartbeat is no evidence either way. + let guest_heartbeat = if status == SandboxStatus::Running { + heartbeat::read(&self.runtime_directory(&record.runtime_name)).await + } else { + None + }; + Ok(Sandbox { + guest_heartbeat, + ..record.to_sandbox(map_state(status)) + }) + } + + /// Host-side directory the runtime shares with its guest. + fn runtime_directory(&self, runtime_name: &str) -> PathBuf { + self.client.local().sandboxes_dir().join(runtime_name).join("runtime") + } + + async fn update_sandbox_resources( + &self, + id: &SandboxId, + resources: SandboxResources, + progress: &SandboxProgress, + ) -> Result { + let mut record = self.state.sandbox_by_id(id).await?; + if record.resources == resources { + return self.inspect_record(&record).await; + } + if record.resources.root_filesystem().mode() != resources.root_filesystem().mode() { + return Err(Error::Immutable("resources.rootFilesystem.mode")); + } + + let desired = RuntimeResources::try_from(resources)?; + + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? { + let config = handle.config().map_err(error::microsandbox)?; + let recorded_root_filesystem_mib = RuntimeResources::try_from(record.resources)?.root_filesystem_mib; + let current_root_filesystem_mib = config + .spec + .image + .oci_root_disk() + .and_then(microsandbox::sandbox::RootDisk::size_mib) + .unwrap_or(recorded_root_filesystem_mib); + if desired.root_filesystem_mib < current_root_filesystem_mib { + return Err(Error::UnsupportedResourceChange { + resource: "rootFilesystem", + current: format!("{current_root_filesystem_mib}Mi"), + requested: resources.root_filesystem().capacity().to_string(), + reason: "Microsandbox layered and direct root filesystems can only grow", + }); + } + + let mut modification = handle.modify(); + let mut runtime_change = config.spec.resources.cpus != desired.cpus; + if runtime_change { + modification = modification + .cpus(desired.cpus) + .max_cpus(config.spec.resources.max_cpus.max(desired.cpus)); + } + if config.spec.resources.memory_mib != desired.memory_mib { + modification = modification + .memory(desired.memory_mib) + .max_memory(config.spec.resources.max_memory_mib.max(desired.memory_mib)); + runtime_change = true; + } + if current_root_filesystem_mib < desired.root_filesystem_mib { + modification = modification.root_disk_size(desired.root_filesystem_mib); + runtime_change = true; + } + if runtime_change { + self.prepare_runtime_network(&record)?; + let step = progress.start_step(UPDATE_RUNTIME_RESOURCES).await; + // A running VM is restarted here rather than by Microsandbox, + // whose restart stops without a deadline and relaunches with + // whatever runtime the home holds. The change is persisted for + // the next start first, so a rejected change leaves the VM + // running, and the root disk grows before that start boots. + // The runtime is installed first, since a resource change can + // come before the first start after an upgrade. + let running = map_state(handle.status_snapshot()) == SandboxState::Running; + if running { + self.client.ensure_installed().await?; + modification = modification.next_start(); + } + modification.apply().await.map_err(error::microsandbox)?; + if running { + stop_runtime(&handle, &record.runtime_name).await?; + self.runtime_handle(&record.runtime_name) + .await? + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, &record.id))? + .start_detached() + .await + .map_err(error::microsandbox)?; + } + step.complete().await; + } + } + + record.resources = resources; + self.state.update_sandbox(&record).await?; + self.inspect_record(&record).await + } + + async fn update_sandbox_environment( + &self, + id: &SandboxId, + environment: BTreeMap, + progress: &SandboxProgress, + ) -> Result { + let mut record = self.state.sandbox_by_id(id).await?; + if record.environment == environment { + return self.inspect_record(&record).await; + } + let sandbox = self.inspect_record(&record).await?; + if sandbox.state != SandboxState::Stopped { + return Err(Error::invalid("sandbox.state", "must be stopped")); + } + + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? { + let mut modification = handle.modify().next_start(); + for name in record.environment.keys() { + if !environment.contains_key(name) { + modification = modification.remove_env(name); + } + } + for (name, value) in &environment { + modification = modification.env(name, value); + } + let step = progress.start_step(UPDATE_RUNTIME_ENVIRONMENT).await; + modification.apply().await.map_err(error::microsandbox)?; + step.complete().await; + } + + record.environment = environment; + self.state.update_sandbox(&record).await?; + self.inspect_record(&record).await + } + + async fn start_sandbox(&self, id: &SandboxId, progress: &SandboxProgress) -> Result<(), Error> { + let record = self.state.sandbox_by_id(id).await?; + self.prepare_runtime_network(&record)?; + let step = progress.start_step(INSTALL_RUNTIME).await; + self.client.ensure_installed().await?; + step.complete().await; + let _running = match self.runtime_handle(&record.runtime_name).await? { + Some(handle) if map_state(handle.status_snapshot()) == SandboxState::Running => return Ok(()), + Some(handle) => { + let step = progress.start_step(START_RUNTIME).await; + let running = handle.start_detached().await.map_err(error::microsandbox)?; + step.complete().await; + running + } + None => Box::pin(self.create_runtime(&record, progress)).await?, + }; + Ok(()) + } + + async fn stop_sandbox(&self, id: &SandboxId) -> Result<(), Error> { + let record = self.state.sandbox_by_id(id).await?; + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? + && map_state(handle.status_snapshot()) == SandboxState::Running + { + stop_runtime(&handle, &record.runtime_name).await?; + } + self.executions + .borrow_mut() + .retain(|(sandbox_id, _), _| sandbox_id != id); + Ok(()) + } + + async fn delete_sandbox(&self, id: &SandboxId) -> Result<(), Error> { + let record = self.state.sandbox_by_id(id).await?; + self.stop_sandbox(id).await?; + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? { + handle.remove().await.map_err(error::microsandbox)?; + } + self.client.local().set_network_controlled(&record.runtime_name, false); + // Releasing first keeps a removal pass from taking the image as left behind before its + // cache entry is refreshed. + self.images.release(&record).await; + self.state.remove_sandbox(&record).await?; + self.images.remove_unused().await; + Ok(()) + } + + /// Tells Microsandbox whether this runtime must start under host network + /// control. Call it before every operation that can start the runtime: the + /// setting lives only in this process, while a runtime can outlive the + /// process that started it. + fn prepare_runtime_network(&self, record: &SandboxRecord) -> Result { + let network = RuntimeNetwork::for_attachment(record.network.as_ref())?; + self.client + .local() + .set_network_controlled(&record.runtime_name, network == RuntimeNetwork::Controlled); + Ok(network) + } + + async fn runtime_handle(&self, name: &str) -> Result, Error> { + match self.client.scope(microsandbox::Sandbox::get(name)).await { + Ok(handle) => Ok(Some(handle)), + Err(microsandbox::MicrosandboxError::SandboxNotFound(_)) => Ok(None), + Err(error) => Err(error::microsandbox(error)), + } + } + + pub(crate) async fn connect_running(&self, record: &SandboxRecord) -> Result { + let handle = self + .runtime_handle(&record.runtime_name) + .await? + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, &record.id))?; + handle.connect().await.map_err(error::microsandbox) + } + + async fn create_runtime( + &self, + record: &SandboxRecord, + progress: &SandboxProgress, + ) -> Result { + // Applying the attachment here, not trusting a caller's decision, keeps + // a runtime from being created with the controlled network policy but + // without host network control. + let network = self.prepare_runtime_network(record)?; + let step = progress.start_step(RESOLVE_RUNTIME_INPUTS).await; + let mounts = self.resolve_mounts(&record.mounts).await?; + // Holding the image again also covers a record saved before it held its image, and an + // image the cache no longer has. + let image = self.hold_image(record).await?; + step.complete().await; + if record.resources.root_filesystem().mode() == RootFilesystemMode::Direct { + let step = progress.start_step(MATERIALIZE_DIRECT_ROOT_IMAGE).await; + self.materialize_direct_root_image(&image).await?; + step.complete().await; + } + let mut builder = Client::sandbox_builder(&record.runtime_name, image, record.resources)? + .pull_policy(PullPolicy::Never) + .hostname(record.hostname().as_str()); + builder = builder.envs(record.environment.clone()); + if network == RuntimeNetwork::Controlled { + builder = + builder.network(|network| network.policy(microsandbox::NetworkPolicy::allow_all()).tls(|tls| tls)); + } + if record.init_system == sandbox::init::InitSystem::Image { + builder = builder.init("auto"); + } + for mount in mounts { + builder = mount.apply(builder); + } + let step = progress.start_step(CREATE_RUNTIME).await; + let runtime = Box::pin(self.client.scope(builder.create_detached())) + .await + .map_err(error::microsandbox)?; + step.complete().await; + Ok(runtime) + } + + // Image resolution prepares Microsandbox's layered cache, while a direct root + // filesystem requires a cached flat ext4 artifact. PullPolicy::Never will only + // consume that artifact during sandbox creation, so materialize it here first; + // Microsandbox then clones it into the sandbox-owned root disk. + async fn materialize_direct_root_image(&self, reference: &str) -> Result<(), Error> { + let reference = reference + .parse::() + .map_err(error::backend)?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let metadata = cache + .read_image_metadata(&reference) + .map_err(error::backend)? + .ok_or_else(|| Error::Backend(format!("Microsandbox image metadata is missing for {reference}")))?; + let manifest_digest = metadata.manifest_digest.parse().map_err(error::backend)?; + let layer_diff_ids = metadata + .layers + .iter() + .map(|layer| layer.diff_id.parse().map_err(error::backend)) + .collect::, _>>()?; + let registry = microsandbox_image::Registry::new(microsandbox_image::Platform::host_linux(), cache) + .map_err(error::backend)?; + registry + .materialize_flat_rootfs(&manifest_digest, &layer_diff_ids, false) + .await + .map_err(error::backend)?; + Ok(()) + } + + async fn resolve_mounts(&self, mounts: &[Mount]) -> Result, Error> { + let mut resolved = Vec::with_capacity(mounts.len()); + for mount in mounts { + resolved.push(match mount { + Mount::Volume { id, target, read_only } => { + let volume = self.state.volume_by_id(id).await?; + self.ensure_volume_runtime(&volume).await?; + RuntimeMount::Volume { + name: volume.runtime_name, + target: target.as_str().to_string(), + read_only: *read_only, + } + } + Mount::Bind { + source, + target, + read_only, + } => RuntimeMount::Bind { + source: source.clone(), + target: target.as_str().to_string(), + read_only: *read_only, + }, + Mount::Tmpfs { target, capacity } => RuntimeMount::Tmpfs { + target: target.as_str().to_string(), + capacity_mib: crate::client::exact_mib("mount.tmpfs.capacity", *capacity)?, + }, + }); + } + Ok(resolved) + } +} + +impl MicrosandboxProviderBuilder { + /// Places reusable Microsandbox cache artifacts in this directory. + /// + /// Separate Provider instances may share this directory. Sandbox state, + /// writable roots and other mutable runtime data remain below the private + /// Provider home. + #[must_use] + pub fn cache_directory(mut self, path: impl Into) -> Self { + self.cache_directory = Some(path.into()); + self + } + + /// Removes cached images no Sandbox uses once `retention`, at least an hour, has passed + /// since each was last resolved, imported or released by a deleted Sandbox. A Sandbox keeps + /// its image until it is deleted, running or not. + /// + /// Enable this only for the Provider that owns its home. It cannot be combined with + /// [`Self::cache_directory`], since another Provider may use a shared cache. + #[must_use] + pub const fn remove_unused_images_after(mut self, retention: std::time::Duration) -> Self { + self.unused_image_retention = Some(retention); + self + } + + /// Supplies transient credentials used to resolve OCI registry references. + #[must_use] + pub fn registry_authentication(mut self, authentication: sandbox::image::RegistryAuthentication) -> Self { + self.registry_authentication = Some(authentication); + self + } + + /// Installs the Microsandbox host runtime from a verified local release bundle. + /// + /// The path must identify a platform-compatible Microsandbox `tar.gz` + /// runtime bundle. The expected digest is checked before extraction. + #[must_use] + pub fn runtime_bundle(mut self, path: impl Into, sha256: impl Into) -> Self { + self.runtime_bundle = Some(RuntimeBundle { + path: path.into(), + sha256: sha256.into(), + }); + self + } + + /// Opens the configured Microsandbox Provider. + /// + /// # Errors + /// + /// Returns an error when a configured path is empty or cannot be + /// initialized by the Microsandbox runtime. + pub async fn open(self) -> Result { + MicrosandboxProvider::open_configured( + self.home, + self.cache_directory, + self.unused_image_retention, + self.registry_authentication, + self.runtime_bundle, + ) + .await + } +} + +impl SandboxProvider for MicrosandboxProvider { + fn backend(&self) -> &dyn SandboxBackend { + self + } + + fn image_backend(&self) -> &dyn sandbox::image::ImageBackend { + &self.image_backend + } +} + +impl SandboxBackend for MicrosandboxProvider { + fn capabilities<'a>( + &'a self, + platform: &'a Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + platform::require_supported(platform)?; + Ok(SandboxBackendCapabilities::new( + [ + SandboxFeature::Execution, + SandboxFeature::TerminalExecution, + SandboxFeature::TerminalAttach, + SandboxFeature::FileTransfer, + SandboxFeature::PersistentVolumes, + SandboxFeature::NestedContainers, + SandboxFeature::ImageInit, + ] + .into(), + MountKindSet::from([MountKind::Volume, MountKind::Bind, MountKind::Tmpfs]), + RootFilesystemModeSet::from([RootFilesystemMode::Layered, RootFilesystemMode::Direct]), + network::NetworkEndpointCapabilities::new().with_control_protocol( + network::NetworkControlProtocolId::new(microsandbox_network::control::NETWORK_CONTROL_PROTOCOL), + ), + )) + }) + } + + fn create(&self, request: CreateSandboxRequest) -> PendingOperation<'_, Sandbox> { + PendingOperation::run(move |progress| { + Box::pin(async move { + let step = progress.start_step(RECORD_SANDBOX).await; + let sandbox = self.create_record(request).await?; + step.complete().await; + Ok(sandbox) + }) + }) + } + + fn update_resources<'a>(&'a self, id: &'a SandboxId, resources: SandboxResources) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |progress| { + Box::pin(async move { self.update_sandbox_resources(id, resources, &progress).await }) + }) + } + + fn update_environment<'a>( + &'a self, + id: &'a SandboxId, + environment: BTreeMap, + ) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |progress| { + Box::pin(async move { self.update_sandbox_environment(id, environment, &progress).await }) + }) + } + + fn find<'a>(&'a self, name: &'a SandboxName) -> LocalFuture<'a, Result> { + Box::pin(async move { + let record = self.state.sandbox_by_name(name).await?; + self.inspect_record(&record).await + }) + } + + fn inspect<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result> { + Box::pin(async move { + let record = self.state.sandbox_by_id(id).await?; + self.inspect_record(&record).await + }) + } + + fn start<'a>(&'a self, id: &'a SandboxId) -> PendingOperation<'a, ()> { + PendingOperation::run(move |progress| Box::pin(async move { self.start_sandbox(id, &progress).await })) + } + + fn stop<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.stop_sandbox(id)) + } + + fn delete<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.delete_sandbox(id)) + } + + fn open_network_endpoint<'a>( + &'a self, + id: &'a SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let record = self.state.sandbox_by_id(id).await?; + match self.prepare_runtime_network(&record)? { + RuntimeNetwork::Controlled => { + let controller = self.client.bind_network_controller(&record.runtime_name).await?; + network_endpoint::open(controller).map(network::NetworkEndpoint::Control) + } + RuntimeNetwork::Unattached => Err(Error::invalid("network", "Sandbox has no attachment")), + } + }) + } + + fn start_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: execution::StartExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(self.start_execution_stream(sandbox_id, request)) + } + + fn start_terminal_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(self.start_terminal_execution_stream(sandbox_id, request)) + } + + fn attach_terminal<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::AttachTerminalRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(self.attach_terminal_to_runtime(sandbox_id, request)) + } + fn terminate_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.control_execution(sandbox_id, execution_id, false)) + } + + fn kill_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.control_execution(sandbox_id, execution_id, true)) + } + + fn read_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a sandbox::SandboxPath, + ) -> LocalFuture<'a, Result> { + Box::pin(self.read_file_stream(sandbox_id, path)) + } + + fn write_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a sandbox::SandboxPath, + contents: file_transfer::ByteReader, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.write_file_stream(sandbox_id, path, contents)) + } + + fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> LocalFuture<'_, Result> { + Box::pin(self.ensure_volume_record(request)) + } + + fn find_volume<'a>(&'a self, name: &'a volume::VolumeName) -> LocalFuture<'a, Result> { + Box::pin(async move { Ok(self.state.volume_by_name(name).await?.to_volume()) }) + } + + fn delete_volume<'a>(&'a self, id: &'a volume::VolumeId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.delete_volume_record(id)) + } +} + +impl SandboxRecord { + fn to_sandbox(&self, state: SandboxState) -> Sandbox { + Sandbox { + image: self.image.clone(), + init_system: self.init_system, + id: self.id.clone(), + name: self.name.clone(), + hostname: self.hostname(), + resources: self.resources, + state, + guest_heartbeat: None, + mounts: self.mounts.clone(), + environment: self.environment.clone(), + network: self.network.clone(), + } + } +} + +enum RuntimeMount { + Volume { + name: String, + target: String, + read_only: bool, + }, + Bind { + source: std::path::PathBuf, + target: String, + read_only: bool, + }, + Tmpfs { + target: String, + capacity_mib: u32, + }, +} + +impl RuntimeMount { + fn apply(self, builder: microsandbox::sandbox::SandboxBuilder) -> microsandbox::sandbox::SandboxBuilder { + match self { + Self::Volume { + name, + target, + read_only, + } => builder.volume(target, |mount| { + let mount = mount.named(name); + if read_only { mount.readonly() } else { mount } + }), + Self::Bind { + source, + target, + read_only, + } => builder.volume(target, |mount| { + let mount = mount.bind(source); + if read_only { mount.readonly() } else { mount } + }), + Self::Tmpfs { target, capacity_mib } => builder.volume(target, |mount| mount.tmpfs().size(capacity_mib)), + } + } +} + +/// Stops a running VM gracefully within [`STOP_TIMEOUT`], and kills it when +/// that fails. A guest that stopped responding, a halted guest and a paused VM +/// cannot take the shutdown request, so the stop still ends with the VM gone. +async fn stop_runtime(handle: µsandbox::sandbox::SandboxHandle, name: &str) -> Result<(), Error> { + match handle.stop_with_timeout(STOP_TIMEOUT).await { + Ok(()) => Ok(()), + Err(graceful) => { + tracing::warn!(sandbox = %name, error = %graceful, "Microsandbox VM did not stop gracefully; killing it"); + handle + .kill() + .await + .map_err(|kill| error::backend(format!("{kill}, after a graceful stop failed: {graceful}"))) + } + } +} + +const fn map_state(status: SandboxStatus) -> SandboxState { + match status { + SandboxStatus::Starting | SandboxStatus::Running | SandboxStatus::Draining | SandboxStatus::Paused => { + SandboxState::Running + } + SandboxStatus::Created | SandboxStatus::Stopped | SandboxStatus::Crashed => SandboxState::Stopped, + } +} + +/// How a runtime's network is wired, decided once from the Sandbox's immutable +/// Network attachment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RuntimeNetwork { + /// No Network Backend is attached; the runtime keeps Microsandbox's own network. + Unattached, + /// The attached Network Backend authorizes traffic through the control + /// protocol this build implements. + Controlled, +} + +impl RuntimeNetwork { + /// Refuses every attachment this build cannot enforce. Microsandbox only + /// offers its own control protocol, so any other recorded endpoint, such as + /// a control protocol from a different version, would otherwise start + /// without host network control. + fn for_attachment(attachment: Option<&network::NetworkAttachment>) -> Result { + match attachment.map(|attachment| &attachment.endpoint) { + None => Ok(Self::Unattached), + Some(network::NetworkEndpointSelection::Control(protocol)) + if protocol.as_str() == microsandbox_network::control::NETWORK_CONTROL_PROTOCOL => + { + Ok(Self::Controlled) + } + Some(endpoint) => Err(Error::UnsupportedNetworkEndpoint(endpoint.clone())), + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::{collections::BTreeMap, path::PathBuf}; + + use microsandbox::sandbox::VolumeMount; + use sandbox::{ + ByteQuantity, CpuQuantity, Error, Hostname, Platform, RootFilesystem, SandboxId, SandboxName, SandboxResources, + backend::{CreateSandboxRequest, SandboxBackend as _}, + image, + init::InitSystem, + network::{ + NetworkAttachment, NetworkBackendId, NetworkControlProtocolId, NetworkEndpointSelection, PacketMedium, + }, + }; + + use super::{MicrosandboxProvider, RuntimeMount, RuntimeNetwork}; + use crate::state::SandboxRecord; + + fn record_with_network(id: &str, endpoint: NetworkEndpointSelection) -> SandboxRecord { + SandboxRecord::new(CreateSandboxRequest { + id: id.parse::().expect("test Sandbox ID should be a UUID"), + name: SandboxName::new("worker").expect("test Sandbox name should be valid"), + hostname: Hostname::new("worker").expect("test hostname should be valid"), + image: image::ResolvedImage { + source: image::ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: "sha256:1234".to_string(), + }, + resources: SandboxResources::new( + "1".parse::().expect("test CPU should be valid"), + "512Mi".parse::().expect("test memory should be valid"), + RootFilesystem::layered( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ), + init_system: InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: Some(NetworkAttachment { + backend: NetworkBackendId::new("microsandbox"), + endpoint, + }), + }) + } + + #[test] + fn only_an_absent_attachment_or_this_builds_control_protocol_is_accepted() { + assert_eq!( + RuntimeNetwork::for_attachment(None).ok(), + Some(RuntimeNetwork::Unattached) + ); + let controlled = record_with_network( + "00000000-0000-4000-8000-000000000010", + NetworkEndpointSelection::Control(NetworkControlProtocolId::new( + microsandbox_network::control::NETWORK_CONTROL_PROTOCOL, + )), + ); + assert_eq!( + RuntimeNetwork::for_attachment(controlled.network.as_ref()).ok(), + Some(RuntimeNetwork::Controlled) + ); + } + + // A persisted attachment that this build cannot enforce must never reach + // the runtime, where it would start without host network control. + #[tokio::test(flavor = "local")] + async fn start_refuses_a_recorded_endpoint_this_build_cannot_control() { + let home = tempfile::tempdir().expect("temporary home should be created"); + let provider = MicrosandboxProvider::open(PathBuf::from(home.path()).join("microsandbox")) + .await + .expect("Provider should open without starting a VM"); + let endpoints = [ + NetworkEndpointSelection::Control(NetworkControlProtocolId::new("microsandbox.network-control.v0")), + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + NetworkEndpointSelection::Intercepted, + ]; + for (index, endpoint) in endpoints.into_iter().enumerate() { + let mut record = + record_with_network(&format!("00000000-0000-4000-8000-00000000000{}", index + 1), endpoint); + record.name = SandboxName::new(format!("worker-{index}")).expect("test Sandbox name should be valid"); + provider + .state + .save_sandbox(&record) + .await + .expect("record should be saved"); + + let result = provider.start(&record.id).await; + + let expected = &record + .network + .as_ref() + .expect("record should have an attachment") + .endpoint; + assert!( + matches!(&result, Err(Error::UnsupportedNetworkEndpoint(actual)) if actual == expected), + "starting a Sandbox recorded with {expected:?} should be refused, got {result:?}" + ); + } + drop(provider); + } + + #[tokio::test(flavor = "local")] + async fn tmpfs_capacity_maps_to_microsandbox() { + let config = Box::pin(crate::client::build_in_client_scope( + RuntimeMount::Tmpfs { + target: "/tmp".to_string(), + capacity_mib: 4096, + } + .apply(microsandbox::sandbox::SandboxBuilder::new("sandbox").image("alpine")), + )) + .await; + + assert!(matches!( + config.spec.mounts.as_slice(), + [VolumeMount::Tmpfs { + guest, + size_mib: Some(4096), + .. + }] if guest == "/tmp" + )); + } +} diff --git a/sandbox/sandbox-microsandbox/src/client.rs b/sandbox/sandbox-microsandbox/src/client.rs new file mode 100644 index 0000000..0c17268 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/client.rs @@ -0,0 +1,467 @@ +#[cfg(unix)] +use std::{ + fmt::Write as _, + fs, + os::unix::ffi::OsStrExt, + os::unix::fs::{DirBuilderExt, MetadataExt}, +}; +use std::{ + future::Future, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; + +use microsandbox::LocalBackend; +use sandbox::Error; +#[cfg(unix)] +use sha2::{Digest, Sha256}; +use tokio::sync::OnceCell; + +use crate::{backend::RuntimeBundle, error}; + +// Published runtime bundle digests for Microsandbox 0.7.4-digdir.2. Update these +// together with the pinned Microsandbox revisions in the workspace manifest. +const LINUX_X86_64_RUNTIME_SHA256: &str = "7f684243b99c1be03111953b405b23c89776b3add168b265de01192502ee9e46"; +const LINUX_AARCH64_RUNTIME_SHA256: &str = "05bf90aa1e029c3a0e8e2d603ed2cd310c4799e8eb19c026715c2f01ce7fdaa2"; +const MACOS_AARCH64_RUNTIME_SHA256: &str = "2d791295ab9cae4f5d019d3d530029f2af4cce95525c09b9b6d8f73fc8b69d1f"; +const WINDOWS_X86_64_RUNTIME_SHA256: &str = "9be3e9b4b15a03465f8d078e26683d5cea61fe2501df00369f91e3b5c8956a1e"; +const WINDOWS_AARCH64_RUNTIME_SHA256: &str = "afedcd54bccdc5c0d335c3c25698b5e99ebd2626b280c9135c04796b6af264b0"; + +/// Keeps Microsandbox's thread-safe ownership model at the SDK boundary. +#[derive(Clone)] +pub(crate) struct Client { + backend: Arc, + runtime_bundle: Option, + installation: Rc>, +} + +#[derive(Clone, Copy)] +pub(crate) struct RuntimeResources { + pub(crate) cpus: u8, + pub(crate) memory_mib: u32, + pub(crate) root_filesystem_mib: u32, +} + +impl TryFrom for RuntimeResources { + type Error = Error; + + fn try_from(resources: sandbox::SandboxResources) -> Result { + let cpus = resources.cpu().whole_cpus().ok_or_else(|| { + unsupported_resource( + "cpu", + resources.cpu(), + "Microsandbox requires a whole number of virtual CPUs", + ) + })?; + let cpus = u8::try_from(cpus).map_err(|_| { + unsupported_resource( + "cpu", + resources.cpu(), + "Microsandbox virtual CPU count must fit in an unsigned 8-bit integer", + ) + })?; + let memory_mib = exact_mib("memory", resources.memory())?; + let root_filesystem_mib = exact_mib("rootFilesystem.capacity", resources.root_filesystem().capacity())?; + Ok(Self { + cpus, + memory_mib, + root_filesystem_mib, + }) + } +} + +pub(crate) fn exact_mib(resource: &'static str, quantity: sandbox::ByteQuantity) -> Result { + let mebibytes = quantity.whole_mebibytes().ok_or_else(|| { + unsupported_resource( + resource, + quantity, + "Microsandbox requires an exact whole number of mebibytes", + ) + })?; + u32::try_from(mebibytes).map_err(|_| { + unsupported_resource( + resource, + quantity, + "Microsandbox mebibyte value must fit in an unsigned 32-bit integer", + ) + }) +} + +fn unsupported_resource(resource: &'static str, value: impl std::fmt::Display, reason: &'static str) -> Error { + Error::UnsupportedResourceValue { + resource, + value: value.to_string(), + reason, + } +} + +impl Client { + pub(crate) async fn open( + microsandbox_home: PathBuf, + cache_directory: Option, + runtime_bundle: Option, + ) -> Result { + if let Some(cache_directory) = &cache_directory { + tokio::fs::create_dir_all(cache_directory) + .await + .map_err(|source| error::io("create Microsandbox cache directory", source))?; + } + #[cfg(unix)] + let run_directory = run_directory(µsandbox_home)?; + #[cfg(not(unix))] + let run_directory = microsandbox_home.join("run"); + // The Client owns this home, so its user configuration lives there + // rather than in the process user's Microsandbox configuration. + let mut builder = LocalBackend::builder() + .config_path(microsandbox_home.join("config.json")) + .home(µsandbox_home) + .run_dir(run_directory) + .disable_metrics_sample(true) + .deployment_profile(microsandbox::sandbox::DeploymentProfile::SingleTenant); + if let Some(cache_directory) = cache_directory { + builder = builder.cache_dir(cache_directory); + } + let backend = builder.build().await.map_err(error::microsandbox)?; + Ok(Self { + backend: Arc::new(backend), + runtime_bundle, + installation: Rc::new(OnceCell::new()), + }) + } + + pub(crate) fn local(&self) -> &LocalBackend { + &self.backend + } + + pub(crate) async fn bind_network_controller( + &self, + name: &str, + ) -> Result { + self.backend + .bind_network_controller(name) + .await + .map_err(error::microsandbox) + } + + /// Installs the pinned host runtime into the Client's home, replacing any + /// other version the SDK would refuse to launch. Runtime path overrides + /// would bypass the pinned digest or embedded guest agent and are refused. + pub(crate) async fn ensure_installed(&self) -> Result<(), Error> { + self.installation + .get_or_try_init(|| async { + let config = self.backend.config(); + let paths = &config.paths; + if let Some(path) = [&paths.msb, &paths.libkrunfw, &paths.agentd] + .into_iter() + .flatten() + .next() + { + return Err(Error::Backend(format!( + "Microsandbox runtime override {} is not supported", + path.display() + ))); + } + if let Ok(runtime) = microsandbox::setup::resolve_runtime(config) + && is_pinned_runtime(&runtime.msb_path) + { + return Ok(()); + } + let (source, sha256) = match &self.runtime_bundle { + Some(bundle) => ( + microsandbox::setup::InstallSource::Archive(bundle.path.clone()), + bundle.sha256.clone(), + ), + None => ( + microsandbox::setup::InstallSource::ReleaseDownload, + released_runtime_sha256() + .ok_or_else(|| Error::UnsupportedPlatform(sandbox::Platform::native("linux")))? + .to_owned(), + ), + }; + microsandbox::setup::install_runtime( + config, + microsandbox::setup::InstallOptions { + source, + force: true, + expected_archive_sha256: Some(sha256), + ..Default::default() + }, + ) + .await + .map(drop) + .map_err(error::microsandbox) + }) + .await?; + Ok(()) + } + + /// Builds a sandbox whose unset settings come from this Client's backend + /// when it is created inside [`Self::scope`], never from the process + /// user's Microsandbox configuration. + pub(crate) fn sandbox_builder( + name: impl Into, + image: impl Into, + resources: sandbox::SandboxResources, + ) -> Result { + let root_filesystem_mode = resources.root_filesystem().mode(); + let resources = RuntimeResources::try_from(resources)?; + let builder = microsandbox::sandbox::SandboxBuilder::new(name) + .image(image.into()) + .cpus(resources.cpus) + .memory(resources.memory_mib); + Ok(match root_filesystem_mode { + sandbox::RootFilesystemMode::Layered => builder.root_disk(resources.root_filesystem_mib), + sandbox::RootFilesystemMode::Direct => { + builder.root_disk_with(|disk| disk.flat().size(resources.root_filesystem_mib)) + } + mode => return Err(Error::UnsupportedRootFilesystemMode(mode)), + }) + } + + pub(crate) async fn scope(&self, future: F) -> T + where + F: Future, + { + let backend: Arc = self.backend.clone(); + microsandbox::with_backend(backend, future).await + } +} + +/// Builds within a Client whose home is private to the test, so neither the +/// host user's Microsandbox configuration nor another test leaks in. +#[cfg(test)] +#[allow(clippy::expect_used)] +pub(crate) async fn build_in_client_scope( + builder: microsandbox::sandbox::SandboxBuilder, +) -> microsandbox::sandbox::SandboxConfig { + let home = tempfile::tempdir().expect("temporary home should be created"); + let client = Client::open(home.path().join("microsandbox"), None, None) + .await + .expect("Client should open"); + Box::pin(client.scope(builder.build())) + .await + .expect("Sandbox configuration should build") +} + +#[cfg(unix)] +fn run_directory(home: &Path) -> Result { + let default = home.join("run"); + if microsandbox::runtime::run_directory_fits(&default) { + return Ok(default); + } + let digest = Sha256::digest(home.as_os_str().as_bytes()); + let mut id = String::with_capacity(32); + for byte in &digest[..16] { + let _ = write!(&mut id, "{byte:02x}"); + } + let path = PathBuf::from(format!("/tmp/microsandbox-{id}")); + if !microsandbox::runtime::run_directory_fits(&path) { + return Err(error::io( + "select private Microsandbox runtime directory", + std::io::Error::new(std::io::ErrorKind::InvalidInput, path.display().to_string()), + )); + } + let home_uid = fs::metadata(home.parent().unwrap_or(home)) + .map_err(|source| error::io("inspect Microsandbox home", source))? + .uid(); + match fs::symlink_metadata(&path) { + Ok(metadata) + if !metadata.file_type().is_dir() || metadata.uid() != home_uid || metadata.mode() & 0o077 != 0 => + { + return Err(error::io( + "validate private Microsandbox runtime directory", + std::io::Error::new(std::io::ErrorKind::PermissionDenied, path.display().to_string()), + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .map_err(|source| error::io("create private Microsandbox runtime directory", source))?; + } + Err(source) => return Err(error::io("inspect private Microsandbox runtime directory", source)), + } + Ok(path) +} + +/// Whether an installed `msb` is the runtime this SDK build launches. +fn is_pinned_runtime(msb: &Path) -> bool { + matches!( + microsandbox::setup::resolve_runtime_version(msb), + Ok(Some(version)) if version.to_string() == microsandbox::setup::InstallOptions::default().version + ) +} + +fn released_runtime_sha256() -> Option<&'static str> { + runtime_sha256(std::env::consts::OS, std::env::consts::ARCH) +} + +pub(crate) fn runtime_sha256(os: &str, architecture: &str) -> Option<&'static str> { + match (os, architecture) { + ("linux", "x86_64") => Some(LINUX_X86_64_RUNTIME_SHA256), + ("linux", "aarch64") => Some(LINUX_AARCH64_RUNTIME_SHA256), + ("macos", "aarch64") => Some(MACOS_AARCH64_RUNTIME_SHA256), + ("windows", "x86_64") => Some(WINDOWS_X86_64_RUNTIME_SHA256), + ("windows", "aarch64") => Some(WINDOWS_AARCH64_RUNTIME_SHA256), + _ => None, + } +} + +#[cfg(test)] +// Test Clients live for the whole test; tightening their drop adds nothing. +#[allow(clippy::expect_used, clippy::significant_drop_tightening)] +mod tests { + use sandbox::{ByteQuantity, CpuQuantity, RootFilesystem, SandboxResources}; + #[cfg(unix)] + use std::fmt::Write as _; + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use std::path::PathBuf; + + #[cfg(unix)] + use super::Sha256; + #[cfg(unix)] + use sha2::Digest; + + use crate::client::Client; + + #[cfg(unix)] + #[test] + fn run_directory_preserves_short_homes_and_shortens_long_socket_paths() { + let normal = PathBuf::from("/Users/alice/.agent/runtime"); + assert_eq!( + super::run_directory(&normal).expect("run directory"), + normal.join("run") + ); + + let root = tempfile::tempdir().expect("temporary root"); + let long = root.path().join("username".repeat(20)); + std::fs::create_dir_all(long.parent().expect("long home parent")).expect("provider home"); + let run = super::run_directory(&long).expect("run directory"); + assert!(microsandbox::runtime::run_directory_fits(&run)); + std::fs::remove_dir(&run).expect("remove fallback directory"); + assert_ne!(run, long.join("run")); + } + + #[cfg(unix)] + #[test] + fn run_directory_rejects_unsafe_existing_fallbacks() { + let root = tempfile::tempdir().expect("temporary root"); + let home = root.path().join("home").join("longusername".repeat(20)).join("runtime"); + std::fs::create_dir_all(&home).expect("runtime home"); + let digest = Sha256::digest(home.as_os_str().as_bytes()); + let mut id = String::with_capacity(32); + for byte in &digest[..16] { + write!(&mut id, "{byte:02x}").expect("writing to String cannot fail"); + } + let fallback = PathBuf::from(format!("/tmp/microsandbox-{id}")); + + std::os::unix::fs::symlink(root.path(), &fallback).expect("fallback symlink"); + assert!(super::run_directory(&home).is_err()); + std::fs::remove_file(&fallback).expect("remove fallback symlink"); + + std::fs::write(&fallback, b"not a directory").expect("fallback file"); + assert!(super::run_directory(&home).is_err()); + std::fs::remove_file(&fallback).expect("remove fallback file"); + + std::fs::create_dir(&fallback).expect("fallback directory"); + std::fs::set_permissions(&fallback, std::fs::Permissions::from_mode(0o755)).expect("fallback permissions"); + assert!(super::run_directory(&home).is_err()); + std::fs::remove_dir(&fallback).expect("remove fallback directory"); + } + + #[test] + fn every_supported_host_runtime_download_is_digest_pinned() { + for (os, architecture) in [ + ("linux", "x86_64"), + ("linux", "aarch64"), + ("macos", "aarch64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] { + let digest = super::runtime_sha256(os, architecture).expect("supported host digest"); + assert_eq!(digest.len(), 64); + assert!(digest.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + } + + #[tokio::test(flavor = "local")] + async fn runtime_paths_outside_the_home_are_refused() { + let home = tempfile::tempdir().expect("temporary home should be created"); + let microsandbox_home = home.path().join("microsandbox"); + std::fs::create_dir_all(µsandbox_home).expect("home should be created"); + std::fs::write( + microsandbox_home.join("config.json"), + br#"{"paths":{"agentd":"/opt/other/agentd"}}"#, + ) + .expect("configuration should be written"); + let client = Client::open(microsandbox_home, None, None) + .await + .expect("Client should open"); + + let error = client + .ensure_installed() + .await + .expect_err("a configured guest agent should be refused"); + + assert!(error.to_string().contains("/opt/other/agentd"), "{error}"); + } + + #[tokio::test(flavor = "local")] + async fn sandbox_builders_use_explicit_resources_without_ambient_defaults() { + let resources = SandboxResources::new( + "2".parse::().expect("CPU should parse"), + "768Mi".parse::().expect("memory should parse"), + RootFilesystem::layered("4Gi".parse::().expect("root filesystem should parse")), + ); + let config = Box::pin(super::build_in_client_scope( + Client::sandbox_builder("sandbox", "alpine", resources).expect("resources should map to Microsandbox"), + )) + .await; + + assert_eq!(config.spec.resources.cpus, 2); + assert_eq!(config.spec.resources.memory_mib, 768); + assert_eq!(config.spec.image.oci_managed_root_disk_size_mib(), Some(4 * 1024)); + assert_eq!(config.spec.runtime.workdir, None); + } + + #[tokio::test(flavor = "local")] + async fn direct_root_filesystems_map_to_flat_microsandbox_disks() { + let resources = SandboxResources::new( + "2".parse::().expect("CPU should parse"), + "768Mi".parse::().expect("memory should parse"), + RootFilesystem::direct("4Gi".parse::().expect("root filesystem should parse")), + ); + let config = Box::pin(super::build_in_client_scope( + Client::sandbox_builder("sandbox", "alpine", resources).expect("resources should map to Microsandbox"), + )) + .await; + + assert_eq!( + config.spec.image.oci_root_disk(), + Some(µsandbox::sandbox::RootDisk::flat(4 * 1024)) + ); + } + + #[test] + fn resource_conversion_rejects_values_microsandbox_cannot_represent_exactly() { + let fractional_cpu = SandboxResources::new( + "500m".parse::().expect("CPU should parse"), + "768Mi".parse::().expect("memory should parse"), + RootFilesystem::layered("4Gi".parse::().expect("root filesystem should parse")), + ); + let decimal_memory = SandboxResources::new( + "2".parse::().expect("CPU should parse"), + "1G".parse::().expect("memory should parse"), + RootFilesystem::layered("4Gi".parse::().expect("root filesystem should parse")), + ); + + assert!(Client::sandbox_builder("sandbox", "alpine", fractional_cpu).is_err()); + assert!(Client::sandbox_builder("sandbox", "alpine", decimal_memory).is_err()); + } +} diff --git a/sandbox/sandbox-microsandbox/src/encoding.rs b/sandbox/sandbox-microsandbox/src/encoding.rs new file mode 100644 index 0000000..715904a --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/encoding.rs @@ -0,0 +1,20 @@ +pub(crate) fn lower_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(DIGITS[usize::from(byte >> 4)])); + encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::lower_hex; + + #[test] + fn encodes_lowercase_hex() { + assert_eq!(lower_hex(&[0x00, 0x1f, 0xa5, 0xff]), "001fa5ff"); + } +} diff --git a/sandbox/sandbox-microsandbox/src/error.rs b/sandbox/sandbox-microsandbox/src/error.rs new file mode 100644 index 0000000..56dae69 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/error.rs @@ -0,0 +1,21 @@ +use sandbox::{Error, ResourceKind}; + +pub(crate) fn microsandbox(error: microsandbox::MicrosandboxError) -> Error { + match error { + microsandbox::MicrosandboxError::SandboxNotFound(name) => Error::not_found(ResourceKind::Sandbox, &name), + microsandbox::MicrosandboxError::ImageNotFound(reference) => Error::not_found(ResourceKind::Image, &reference), + microsandbox::MicrosandboxError::VolumeNotFound(name) => Error::not_found(ResourceKind::Volume, &name), + microsandbox::MicrosandboxError::ExecFailed(failure) => { + Error::Backend(format!("Microsandbox execution failed: {}", failure.message)) + } + error => Error::Backend(error.to_string()), + } +} + +pub(crate) fn backend(error: impl std::fmt::Display) -> Error { + Error::Backend(error.to_string()) +} + +pub(crate) const fn io(operation: &'static str, source: std::io::Error) -> Error { + Error::Io { operation, source } +} diff --git a/sandbox/sandbox-microsandbox/src/execution.rs b/sandbox/sandbox-microsandbox/src/execution.rs new file mode 100644 index 0000000..d9cfb4f --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/execution.rs @@ -0,0 +1,301 @@ +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use futures_util::stream; +use microsandbox::sandbox::{AttachOptionsBuilder, ExecOptionsBuilder}; +use microsandbox::{ExecControl, ExecEvent}; +use sandbox::{Error, LocalFuture, ResourceKind, SandboxId, execution, terminal}; + +use crate::{backend::MicrosandboxProvider, error}; + +type ExecutionKey = (SandboxId, execution::ExecutionId); +pub(crate) type ExecutionControls = Rc>>; + +// Microsandbox uses -1 when attachment ends before receiving a process exit. +const DETACHED_EXIT_CODE: i32 = -1; + +impl MicrosandboxProvider { + pub(crate) async fn start_execution_stream( + &self, + sandbox_id: &SandboxId, + request: execution::StartExecutionRequest, + ) -> Result { + let (execution_id, spec) = request.into_parts(); + let sandbox = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&sandbox).await?; + let handle = start_runtime_execution(&runtime, &spec) + .await + .map_err(error::microsandbox)?; + let key = (sandbox_id.clone(), execution_id.clone()); + self.executions.borrow_mut().insert(key.clone(), handle.control()); + let guard = ExecutionGuard { + controls: Rc::clone(&self.executions), + key, + }; + let events = stream::unfold((handle, guard), |(mut handle, guard)| async move { + handle.recv().await.map(|event| { + let event = map_event(event); + (event, (handle, guard)) + }) + }); + + Ok(execution::StartedExecution { + id: execution_id, + events: Box::pin(events), + }) + } + + pub(crate) async fn start_terminal_execution_stream( + &self, + sandbox_id: &SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> Result { + let (execution_id, spec, initial_size) = request.into_parts(); + let sandbox = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&sandbox).await?; + let mut handle = start_runtime_terminal_execution(&runtime, &spec, initial_size) + .await + .map_err(error::microsandbox)?; + let input = handle + .take_stdin() + .ok_or_else(|| Error::Backend("Microsandbox terminal Execution did not provide stdin".to_string()))?; + let runtime_control = handle.control(); + let key = (sandbox_id.clone(), execution_id.clone()); + self.executions + .borrow_mut() + .insert(key.clone(), runtime_control.clone()); + let guard = ExecutionGuard { + controls: Rc::clone(&self.executions), + key, + }; + let events = stream::unfold((handle, guard), |(mut handle, guard)| async move { + handle.recv().await.map(|event| { + let event = map_terminal_event(event); + (event, (handle, guard)) + }) + }); + + Ok(terminal::StartedTerminalExecution { + id: execution_id, + control: Rc::new(MicrosandboxTerminalControl { input, runtime_control }), + events: Box::pin(events), + }) + } + + pub(crate) async fn attach_terminal_to_runtime( + &self, + sandbox_id: &SandboxId, + request: terminal::AttachTerminalRequest, + ) -> Result { + let sandbox = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&sandbox).await?; + let (spec, detach_keys) = request.into_parts(); + let exit_code = match spec.program() { + execution::Program::ImageEntrypoint => { + runtime + .attach_default_with(|options| apply_attach_options(options, &spec, detach_keys.as_deref())) + .await + } + execution::Program::Command { executable, args } => { + runtime + .attach_with(executable.as_str(), |options| { + apply_attach_options(options.args(args.iter().cloned()), &spec, detach_keys.as_deref()) + }) + .await + } + } + .map_err(error::microsandbox)?; + + Ok(if exit_code == DETACHED_EXIT_CODE { + terminal::TerminalAttachOutcome::Detached + } else { + terminal::TerminalAttachOutcome::Exited(execution::ExitStatus { code: exit_code }) + }) + } + + pub(crate) async fn control_execution( + &self, + sandbox_id: &SandboxId, + execution_id: &execution::ExecutionId, + force: bool, + ) -> Result<(), Error> { + let key = (sandbox_id.clone(), execution_id.clone()); + let control = self + .executions + .borrow() + .get(&key) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Execution, execution_id))?; + if force { + control.kill().await + } else { + control.signal(15).await + } + .map_err(error::microsandbox) + } +} + +fn apply_attach_options( + mut options: AttachOptionsBuilder, + spec: &execution::ExecutionSpec, + detach_keys: Option<&str>, +) -> AttachOptionsBuilder { + if let Some(working_directory) = spec.working_directory() { + options = options.cwd(working_directory.as_str()); + } + if let Some(detach_keys) = detach_keys { + options = options.detach_keys(detach_keys); + } + options.envs( + spec.environment() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ) +} + +async fn start_runtime_execution( + runtime: µsandbox::Sandbox, + spec: &execution::ExecutionSpec, +) -> microsandbox::MicrosandboxResult { + let working_directory = spec.working_directory().map(|path| path.as_str().to_string()); + let environment = spec.environment().clone(); + match spec.program() { + execution::Program::ImageEntrypoint => { + runtime + .exec_default_stream_with(move |options| configure(options, working_directory, environment)) + .await + } + execution::Program::Command { executable, args } => { + let args = args.clone(); + runtime + .exec_stream_with(executable.as_str(), move |options| { + configure(options.args(args), working_directory, environment) + }) + .await + } + } +} + +async fn start_runtime_terminal_execution( + runtime: µsandbox::Sandbox, + spec: &execution::ExecutionSpec, + size: terminal::TerminalSize, +) -> microsandbox::MicrosandboxResult { + let working_directory = spec.working_directory().map(|path| path.as_str().to_string()); + let environment = spec.environment().clone(); + match spec.program() { + execution::Program::ImageEntrypoint => { + runtime + .exec_default_stream_with(move |options| { + configure( + options + .stdin_pipe() + .tty(true) + .terminal_size(size.rows(), size.columns()), + working_directory, + environment, + ) + }) + .await + } + execution::Program::Command { executable, args } => { + let args = args.clone(); + runtime + .exec_stream_with(executable.as_str(), move |options| { + configure( + options + .args(args) + .stdin_pipe() + .tty(true) + .terminal_size(size.rows(), size.columns()), + working_directory, + environment, + ) + }) + .await + } + } +} + +fn configure( + mut options: ExecOptionsBuilder, + working_directory: Option, + environment: std::collections::BTreeMap, +) -> ExecOptionsBuilder { + if let Some(directory) = working_directory { + options = options.cwd(directory); + } + options.envs(environment) +} + +fn map_event(event: ExecEvent) -> Result { + Ok(match event { + ExecEvent::Started { pid } => execution::ExecutionEvent::Started { process_id: Some(pid) }, + ExecEvent::Stdout(bytes) => execution::ExecutionEvent::Stdout(bytes), + ExecEvent::Stderr(bytes) => execution::ExecutionEvent::Stderr(bytes), + ExecEvent::Exited { code } => execution::ExecutionEvent::Exited(execution::ExitStatus { code }), + ExecEvent::Failed(failure) => execution::ExecutionEvent::Failed { + message: failure.message, + }, + ExecEvent::StdinError(failure) => { + return Err(Error::Backend(format!( + "Microsandbox Execution stdin failed: {failure:?}" + ))); + } + }) +} + +fn map_terminal_event(event: ExecEvent) -> Result { + Ok(match event { + ExecEvent::Started { pid } => terminal::TerminalEvent::Started { process_id: Some(pid) }, + ExecEvent::Stdout(bytes) | ExecEvent::Stderr(bytes) => terminal::TerminalEvent::Output(bytes), + ExecEvent::Exited { code } => terminal::TerminalEvent::Exited(execution::ExitStatus { code }), + ExecEvent::Failed(failure) => terminal::TerminalEvent::Failed { + message: failure.message, + }, + ExecEvent::StdinError(failure) => { + return Err(Error::Backend(format!( + "Microsandbox terminal Execution stdin failed: {failure:?}" + ))); + } + }) +} + +struct MicrosandboxTerminalControl { + input: microsandbox::sandbox::exec::ExecSink, + runtime_control: ExecControl, +} + +impl terminal::TerminalControl for MicrosandboxTerminalControl { + fn write_input(&self, bytes: bytes::Bytes) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + if bytes.is_empty() { + return Ok(()); + } + self.input.write(bytes).await.map_err(error::microsandbox) + }) + } + + fn close_input(&self) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.input.close().await.map_err(error::microsandbox) }) + } + + fn resize(&self, size: terminal::TerminalSize) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.runtime_control + .resize(size.rows(), size.columns()) + .await + .map_err(error::microsandbox) + }) + } +} + +struct ExecutionGuard { + controls: ExecutionControls, + key: ExecutionKey, +} + +impl Drop for ExecutionGuard { + fn drop(&mut self) { + self.controls.borrow_mut().remove(&self.key); + } +} diff --git a/sandbox/sandbox-microsandbox/src/files.rs b/sandbox/sandbox-microsandbox/src/files.rs new file mode 100644 index 0000000..aab9fe9 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/files.rs @@ -0,0 +1,163 @@ +use futures_util::stream; +use microsandbox::sandbox::{FsEntryKind, FsHandle, FsMetadata, FsOpenOptions, FsSetAttrs, SandboxFsOps}; +use sandbox::{Error, SandboxId, SandboxPath, file_transfer::ByteReader}; +use tokio::io::AsyncReadExt as _; +use tokio_util::io::StreamReader; + +use crate::backend::MicrosandboxProvider; + +impl MicrosandboxProvider { + pub(crate) async fn read_file_stream( + &self, + sandbox_id: &SandboxId, + path: &SandboxPath, + ) -> Result { + let record = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&record).await?; + let source = runtime + .fs() + .read_stream(path.as_str()) + .await + .map_err(crate::error::microsandbox)?; + let chunks = stream::try_unfold(source, |mut source| async move { + source + .recv() + .await + .map(|chunk| chunk.map(|bytes| (bytes, source))) + .map_err(std::io::Error::other) + }); + Ok(Box::pin(StreamReader::new(chunks))) + } + + /// Replaces the file atomically: the contents stream into a hidden sibling, which is renamed + /// over the destination once complete, so a concurrent reader sees the old or the new file + /// and never a truncated or partially written one. The sibling is created readable by the + /// guest supervisor only, so the contents are not exposed while they stream. A replaced + /// regular file keeps its mode and owner; a new file gets the guest's default mode once + /// complete, as an in-place write would have. + pub(crate) async fn write_file_stream( + &self, + sandbox_id: &SandboxId, + path: &SandboxPath, + contents: ByteReader, + ) -> Result<(), Error> { + let record = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&record).await?; + let fs = runtime.fs(); + let target = path.as_str(); + let existing = existing_regular_file(&fs, target).await?; + let staging = staging_path(target); + let replaced = match stage(&fs, &staging, existing.as_ref(), contents).await { + Ok(()) => fs.rename(&staging, target).await.map_err(crate::error::microsandbox), + Err(error) => Err(error), + }; + if replaced.is_err() { + // Best effort: the staging file is garbage once the write or rename failed, and the + // original error is what the caller needs to see. + let _ = fs.remove(&staging).await; + } + replaced + } +} + +async fn existing_regular_file(fs: &SandboxFsOps<'_>, path: &str) -> Result, Error> { + if !fs.exists(path).await.map_err(crate::error::microsandbox)? { + return Ok(None); + } + let metadata = fs.stat(path).await.map_err(crate::error::microsandbox)?; + Ok(matches!(metadata.kind, FsEntryKind::File).then_some(metadata)) +} + +/// Owner read and write only, for the sibling while its contents stream. +const STAGING_MODE: u32 = 0o600; +/// What the guest supervisor's default file creation yields; a new file ends up with this. +const DEFAULT_MODE: u32 = 0o644; + +async fn stage( + fs: &SandboxFsOps<'_>, + staging: &str, + existing: Option<&FsMetadata>, + contents: ByteReader, +) -> Result<(), Error> { + // The mode applies at creation, before any byte is written; a plain streamed write would + // create the sibling with the guest's default, world-readable mode. + let options = FsOpenOptions { + write: true, + create_new: true, + mode: Some(STAGING_MODE), + ..FsOpenOptions::default() + }; + let handle = fs + .open_file(staging, options) + .await + .map_err(crate::error::microsandbox)?; + let streamed = stream_into(fs, handle, contents).await; + let closed = fs.close_handle(handle).await.map_err(crate::error::microsandbox); + streamed?; + closed?; + let mode = if let Some(existing) = existing { + // Ownership first: chown clears set-user-ID and set-group-ID bits, so the mode must be + // applied afterwards for the replacement to keep them. + let ownership = FsSetAttrs { + uid: Some(existing.uid), + gid: Some(existing.gid), + ..FsSetAttrs::default() + }; + fs.set_stat(staging, false, ownership) + .await + .map_err(crate::error::microsandbox)?; + existing.mode + } else { + DEFAULT_MODE + }; + let attributes = FsSetAttrs { + mode: Some(mode), + ..FsSetAttrs::default() + }; + fs.set_stat(staging, false, attributes) + .await + .map_err(crate::error::microsandbox) +} + +async fn stream_into(fs: &SandboxFsOps<'_>, handle: FsHandle, mut contents: ByteReader) -> Result<(), Error> { + let destination = fs + .write_handle_stream(handle, 0, None) + .await + .map_err(crate::error::microsandbox)?; + let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice(); + loop { + let read = contents + .read(&mut buffer) + .await + .map_err(|source| crate::error::io("read runtime file-transfer input", source))?; + if read == 0 { + break; + } + destination + .write(&buffer[..read]) + .await + .map_err(crate::error::microsandbox)?; + } + destination.close().await.map_err(crate::error::microsandbox) +} + +/// A hidden sibling in the destination's directory, so the final rename stays on one file system +/// and directory listings that skip dotfiles never show the staging file. +fn staging_path(target: &str) -> String { + let (directory, name) = target.rsplit_once('/').unwrap_or(("", target)); + format!("{directory}/.{name}.agent-{}.tmp", uuid::Uuid::new_v4()) +} + +#[cfg(test)] +mod tests { + use super::staging_path; + + #[test] + fn staging_path_is_a_hidden_sibling() { + let staging = staging_path("/home/agent/.claude/skills/evidence/SKILL.md"); + let (directory, name) = staging.rsplit_once('/').expect("directory"); + assert_eq!(directory, "/home/agent/.claude/skills/evidence"); + assert!(name.starts_with(".SKILL.md.agent-")); + assert_eq!(std::path::Path::new(name).extension(), Some("tmp".as_ref())); + } +} diff --git a/sandbox/sandbox-microsandbox/src/guest_tcp.rs b/sandbox/sandbox-microsandbox/src/guest_tcp.rs new file mode 100644 index 0000000..32ac828 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/guest_tcp.rs @@ -0,0 +1,367 @@ +//! Host-dialed TCP streams into a running Microsandbox guest. +//! +//! The Microsandbox agent relay multiplexes `TcpConnect` streams over the +//! Sandbox's host socket, so a host process can open TCP connections that are +//! dialed from inside the guest network namespace without any configuration +//! on the Sandbox itself. + +use std::{cell::Cell, sync::Arc}; + +use microsandbox::{ + agent::AgentClient, + protocol::{ + message::{Message, MessageType}, + tcp::{TcpClose, TcpConnect, TcpConnected, TcpData, TcpEof, TcpFailed}, + }, +}; +use sandbox::{Error, SandboxId}; +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; + +use crate::{MicrosandboxProvider, error}; + +const RELAY_READ_BUFFER_BYTES: usize = 32 * 1024; + +/// Dials TCP connections from inside one running Sandbox. +/// +/// The dialer keeps a single multiplexed agent connection, so opening many +/// concurrent streams through one dialer is cheap. It stops working when the +/// Sandbox runtime restarts; create a replacement through +/// [`MicrosandboxProvider::guest_tcp_dialer`] when a connect fails. +pub struct GuestTcpDialer { + client: Arc, + // Keeps the connected runtime handle (and its relay session) alive. + _sandbox: microsandbox::Sandbox, +} + +/// One open TCP stream dialed from inside the guest. +pub struct GuestTcpStream { + id: u32, + client: Arc, + receiver: tokio::sync::mpsc::Receiver, +} + +impl MicrosandboxProvider { + /// Connects a TCP dialer to one running Sandbox. + /// + /// # Errors + /// + /// Returns an error when the Sandbox is unknown, not running, or its + /// runtime predates agent-relay TCP support. + pub async fn guest_tcp_dialer(&self, id: &SandboxId) -> Result { + let record = self.state.sandbox_by_id(id).await?; + let sandbox = self.connect_running(&record).await?; + let client = sandbox.client_arc(); + if !client.supports(MessageType::TcpConnect) { + return Err(Error::Backend( + "Sandbox runtime does not support agent-relay TCP forwarding; restart the Sandbox".into(), + )); + } + Ok(GuestTcpDialer { + client, + _sandbox: sandbox, + }) + } +} + +impl GuestTcpDialer { + /// Opens one TCP connection dialed from inside the guest. + /// + /// # Errors + /// + /// Returns an error when the relay stream cannot be opened or the guest + /// dial is rejected. + pub async fn connect(&self, host: &str, port: u16) -> Result { + GuestTcpStream::open(Arc::clone(&self.client), host, port).await + } +} + +impl GuestTcpStream { + async fn open(client: Arc, host: &str, port: u16) -> Result { + let request = TcpConnect { + host: host.to_owned(), + port, + bulk: None, + }; + let (id, mut receiver) = client + .stream(MessageType::TcpConnect, &request) + .await + .map_err(error::backend)?; + let Some(first) = receiver.recv().await else { + return Err(Error::Backend( + "Sandbox agent closed the TCP stream before replying to connect".into(), + )); + }; + match first.t { + MessageType::TcpConnected => { + let _: TcpConnected = first.payload().map_err(error::backend)?; + Ok(Self { id, client, receiver }) + } + MessageType::TcpFailed => { + let failed: TcpFailed = first.payload().map_err(error::backend)?; + Err(Error::Backend(format!( + "guest TCP connect to {host}:{port} failed: {}", + failed.error + ))) + } + other => Err(Error::Backend(format!( + "unexpected Sandbox agent reply {:?} to guest TCP connect", + other.as_str() + ))), + } + } +} + +impl Drop for GuestTcpStream { + /// Releases the guest socket and its agent session on every path — normal + /// completion, an error, and a cancelled relay task alike. + fn drop(&mut self) { + let client = Arc::clone(&self.client); + let id = self.id; + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let _ = client.send(id, MessageType::TcpClose, &TcpClose {}).await; + }); + } + } +} + +impl GuestTcpStream { + /// Pipes bytes between a host socket and the guest connection until both + /// directions have closed; dropping the stream releases the guest session. + /// + /// # Errors + /// + /// Returns an error when a host socket read or write fails, or a relay + /// message cannot be sent or decoded. + pub async fn relay(self, stream: tokio::net::TcpStream) -> Result<(), Error> { + let (host_reader, host_writer) = stream.into_split(); + self.relay_io(host_reader, host_writer).await + } + + /// Pipes bytes between an arbitrary host byte stream pair, such as a + /// process's standard input and output, and the guest connection until + /// both directions have closed; dropping the stream releases the guest + /// session. + /// + /// # Errors + /// + /// Returns an error when a host read or write fails, or a relay message + /// cannot be sent or decoded. + pub async fn relay_io(mut self, mut host_reader: R, mut host_writer: W) -> Result<(), Error> + where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + { + let client = Arc::clone(&self.client); + let id = self.id; + let host_closed = Cell::new(false); + let guest_closed = Cell::new(false); + + let host_to_guest = async { + let mut buffer = vec![0u8; RELAY_READ_BUFFER_BYTES]; + loop { + let read = host_reader + .read(&mut buffer) + .await + .map_err(|source| error::io("read forwarded host connection", source))?; + if read == 0 { + client + .send(id, MessageType::TcpEof, &TcpEof {}) + .await + .map_err(error::backend)?; + host_closed.set(true); + return Ok::<(), Error>(()); + } + let data = TcpData { + data: buffer[..read].to_vec(), + }; + client + .send(id, MessageType::TcpData, &data) + .await + .map_err(error::backend)?; + } + }; + + let guest_to_host = async { + while let Some(message) = self.receiver.recv().await { + match message.t { + MessageType::TcpData => { + let data: TcpData = message.payload().map_err(error::backend)?; + host_writer + .write_all(&data.data) + .await + .map_err(|source| error::io("write forwarded host connection", source))?; + // A buffered writer such as `tokio::io::stdout()`, used by `ssh-proxy`, + // holds bytes until the buffer fills; an interactive protocol stalls + // waiting for a reply that is sitting unflushed. Push whatever has + // arrived out once the guest has nothing more queued, which keeps a + // bulk transfer batched while never stranding an idle response. + if self.receiver.is_empty() { + host_writer + .flush() + .await + .map_err(|source| error::io("flush forwarded host connection", source))?; + } + } + MessageType::TcpEof => { + host_writer + .shutdown() + .await + .map_err(|source| error::io("shut down forwarded host connection", source))?; + guest_closed.set(true); + if host_closed.get() { + return Ok(()); + } + } + MessageType::TcpClosed => return Ok::<(), Error>(()), + other => { + return Err(Error::Backend(format!( + "unexpected Sandbox agent message {:?} on a guest TCP stream", + other.as_str() + ))); + } + } + } + Ok(()) + }; + + // A host-side EOF is a half-close: the guest may still be writing its + // response, so the relay ends when the guest side has closed too. + // Neither end sends a terminal frame after a mutual half-close, so + // waiting for one here would pin the closed socket forever. + tokio::pin!(guest_to_host); + tokio::select! { + result = &mut guest_to_host => result, + result = host_to_guest => match result { + Ok(()) if guest_closed.get() => Ok(()), + Ok(()) => guest_to_host.await, + Err(error) => Err(error), + }, + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::panic)] + + use std::{sync::Arc, time::Duration}; + + use microsandbox::{ + agent::AgentClient, + protocol::{ + codec, + core::Ready, + message::{Message, MessageType}, + tcp::{TcpConnected, TcpEof}, + }, + }; + use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _, DuplexStream}, + net::{TcpListener, TcpStream}, + }; + + use super::GuestTcpStream; + + const RELAY_ID_MIN: u32 = 1; + const RELAY_ID_MAX: u32 = 1 << 20; + + /// The guest end of the agent relay, driven by the test one frame at a time. + struct FakeGuestAgent { + wire: DuplexStream, + pending: Vec, + } + + impl FakeGuestAgent { + async fn handshake() -> (Arc, Self) { + let (host, guest) = tokio::io::duplex(64 * 1024); + let mut agent = Self { + wire: guest, + pending: Vec::new(), + }; + agent + .wire + .write_all(&RELAY_ID_MIN.to_be_bytes()) + .await + .expect("write relay id range start"); + agent + .wire + .write_all(&RELAY_ID_MAX.to_be_bytes()) + .await + .expect("write relay id range end"); + agent + .write(&Message::with_payload(MessageType::Ready, 0, &Ready::default()).expect("ready frame")) + .await; + let client = AgentClient::connect_stream_with_timeout(host, Duration::from_secs(5)) + .await + .expect("relay handshake"); + (Arc::new(client), agent) + } + + async fn write(&mut self, message: &Message) { + let mut frame = Vec::new(); + codec::encode_to_buf(message, &mut frame).expect("encode frame"); + self.wire.write_all(&frame).await.expect("write frame"); + } + + async fn expect(&mut self, expected: MessageType) -> Message { + loop { + if let Some(message) = codec::try_decode_from_buf(&mut self.pending).expect("decode frame") { + assert_eq!(message.t, expected, "unexpected frame from the host relay"); + return message; + } + let mut chunk = [0u8; 4096]; + let read = self.wire.read(&mut chunk).await.expect("read frame"); + assert!(read > 0, "host closed the relay before sending {expected:?}"); + self.pending.extend_from_slice(&chunk[..read]); + } + } + } + + #[tokio::test(flavor = "local")] + async fn relay_ends_once_both_directions_have_closed() { + let (client, mut agent) = FakeGuestAgent::handshake().await; + let (opened, ()) = tokio::join!(GuestTcpStream::open(client, "127.0.0.1", 80), async { + let connect = agent.expect(MessageType::TcpConnect).await; + agent + .write( + &Message::with_payload(MessageType::TcpConnected, connect.id, &TcpConnected {}) + .expect("connected frame"), + ) + .await; + }); + let stream = opened.expect("guest connect should succeed"); + let session = stream.id; + + let listener = TcpListener::bind(("127.0.0.1", 0)).await.expect("bind host listener"); + let mut browser = TcpStream::connect(listener.local_addr().expect("listener address")) + .await + .expect("connect browser side"); + let (forwarded, _) = listener.accept().await.expect("accept forwarded connection"); + let relay = tokio::task::spawn_local(stream.relay(forwarded)); + + browser.shutdown().await.expect("half-close the browser side"); + agent.expect(MessageType::TcpEof).await; + + agent + .write(&Message::with_payload(MessageType::TcpEof, session, &TcpEof {}).expect("eof frame")) + .await; + let mut sink = [0u8; 1]; + let read = browser + .read(&mut sink) + .await + .expect("read guest EOF on the browser side"); + assert_eq!(read, 0, "guest EOF should half-close the browser side"); + + tokio::time::timeout(Duration::from_secs(2), relay) + .await + .expect("relay should finish once both directions have closed") + .expect("relay task should not panic") + .expect("relay should end cleanly"); + let close = agent.expect(MessageType::TcpClose).await; + assert_eq!( + close.id, session, + "dropping the finished relay should release the guest session" + ); + } +} diff --git a/sandbox/sandbox-microsandbox/src/heartbeat.rs b/sandbox/sandbox-microsandbox/src/heartbeat.rs new file mode 100644 index 0000000..c8fc25b --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/heartbeat.rs @@ -0,0 +1,140 @@ +//! Guest heartbeats read from a running runtime's host-side directory. +//! +//! Microsandbox's guest agent replaces `heartbeat.json` in the runtime +//! directory about once a second from a dedicated thread, and the runtime +//! removes it before every boot. Reading the file needs no round trip to the +//! guest, so a guest that stopped responding keeps reporting the sequence it +//! last wrote. Only the sequence is read: the guest-written timestamps follow +//! the guest clock, which falls behind the host's while the guest is stalled. +//! +//! The guest writes the file through a directory it shares with the host, so +//! anything in the guest can replace it, with up to the runtime directory's +//! quota (16 MiB). It is read only as a small regular file, within a time +//! bound, whatever the shared directory makes of a guest's special files. + +use std::{io, path::Path, time::Duration}; + +use sandbox::GuestHeartbeat; +use serde::Deserialize; +use tokio::io::AsyncReadExt as _; + +/// Heartbeat file in a runtime directory, written by the guest agent. +const HEARTBEAT_FILE: &str = "heartbeat.json"; +/// Largest heartbeat file read; the guest agent writes a few hundred bytes. +const MAX_HEARTBEAT_BYTES: u64 = 4096; +/// Longest one read of the guest-controlled file may take. +const READ_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Deserialize)] +struct HeartbeatFile { + heartbeat_seq: u64, +} + +/// Reads the latest guest heartbeat, or none before the guest's first one. +/// +/// A missing or unreadable file reports no heartbeat rather than failing +/// inspection: it is evidence about the guest, not about the Sandbox. +pub(crate) async fn read(runtime_directory: &Path) -> Option { + let path = runtime_directory.join(HEARTBEAT_FILE); + let read = tokio::time::timeout(READ_TIMEOUT, read_bounded(&path)) + .await + .unwrap_or_else(|_| Err(io::Error::new(io::ErrorKind::TimedOut, "reading timed out"))); + let contents = match read { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return None, + Err(error) => { + tracing::debug!(%error, path = %path.display(), "could not read Microsandbox guest heartbeat"); + return None; + } + }; + match serde_json::from_slice::(&contents) { + Ok(file) => Some(GuestHeartbeat::new(file.heartbeat_seq)), + Err(error) => { + tracing::debug!(%error, path = %path.display(), "could not parse Microsandbox guest heartbeat"); + None + } + } +} + +/// Reads at most [`MAX_HEARTBEAT_BYTES`] of a regular file, without following +/// a symlink to it. The file is checked before it is opened, so a FIFO is not +/// opened, and again once open, in case it was replaced in between. +async fn read_bounded(path: &Path) -> io::Result> { + let not_heartbeat = || io::Error::new(io::ErrorKind::InvalidData, "not a small regular file"); + let metadata = tokio::fs::symlink_metadata(path).await?; + if !metadata.is_file() || metadata.len() > MAX_HEARTBEAT_BYTES { + return Err(not_heartbeat()); + } + let file = tokio::fs::File::open(path).await?; + if !file.metadata().await?.is_file() { + return Err(not_heartbeat()); + } + let mut contents = Vec::new(); + file.take(MAX_HEARTBEAT_BYTES).read_to_end(&mut contents).await?; + Ok(contents) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use sandbox::GuestHeartbeat; + + use super::{HEARTBEAT_FILE, read}; + + #[tokio::test(flavor = "local")] + async fn reads_the_sequence_of_the_guest_agents_heartbeat() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + std::fs::write( + directory.path().join(HEARTBEAT_FILE), + br#"{"heartbeat_seq":20,"activity_seq":217,"timestamp":"2026-09-30T13:41:12.662733257Z", +"last_activity":"2026-09-30T13:40:59.618201064Z","active_exec_sessions":0,"active_fs_streams":0, +"active_tcp_streams":0,"activity_counters":{"host_messages":134,"guest_messages":83, +"exec_output_bytes":0,"fs_bytes":4096,"tcp_bytes":0}}"#, + ) + .expect("heartbeat should be written"); + + assert_eq!(read(directory.path()).await, Some(GuestHeartbeat::new(20))); + } + + #[tokio::test(flavor = "local")] + async fn a_missing_or_malformed_heartbeat_is_not_evidence() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + assert_eq!(read(directory.path()).await, None); + + std::fs::write(directory.path().join(HEARTBEAT_FILE), b"{\"heartbeat_seq\":") + .expect("heartbeat should be written"); + assert_eq!(read(directory.path()).await, None); + } + + #[tokio::test(flavor = "local")] + async fn a_large_heartbeat_file_is_not_read() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let mut large = br#"{"heartbeat_seq":20,"padding":""#.to_vec(); + large.resize(64 * 1024, b' '); + large.extend_from_slice(br#""}"#); + std::fs::write(directory.path().join(HEARTBEAT_FILE), large).expect("heartbeat should be written"); + + assert_eq!(read(directory.path()).await, None); + } + + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn a_heartbeat_that_is_not_a_regular_file_is_not_read() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let target = directory.path().join("elsewhere.json"); + std::fs::write(&target, br#"{"heartbeat_seq":20}"#).expect("target should be written"); + std::os::unix::fs::symlink(&target, directory.path().join(HEARTBEAT_FILE)).expect("symlink"); + assert_eq!(read(directory.path()).await, None, "a symlink is not followed"); + + std::fs::remove_file(directory.path().join(HEARTBEAT_FILE)).expect("symlink should be removed"); + let made = std::process::Command::new("mkfifo") + .arg(directory.path().join(HEARTBEAT_FILE)) + .status() + .expect("mkfifo should run"); + assert!(made.success()); + let read = tokio::time::timeout(std::time::Duration::from_secs(5), read(directory.path())) + .await + .expect("a FIFO must not block the read"); + assert_eq!(read, None); + } +} diff --git a/sandbox/sandbox-microsandbox/src/image.rs b/sandbox/sandbox-microsandbox/src/image.rs new file mode 100644 index 0000000..6605183 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/image.rs @@ -0,0 +1,1202 @@ +use std::{ + collections::HashSet, + fs::File, + future::Future, + path::{Path, PathBuf}, +}; + +use bollard::{ + Docker, + query_parameters::{BuildImageOptionsBuilder, BuilderVersion, TagImageOptionsBuilder}, +}; +use futures_util::StreamExt as _; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use sandbox::progress::{MeasuredStep, ProgressStep, SandboxProgress}; +use sandbox::{Error, LocalFuture, OutputStream, PendingOperation, ProgressUnit, RootFilesystemMode, image}; +use sha2::{Digest as _, Sha256}; +use tokio::io::AsyncWriteExt as _; +use tokio_util::codec::{BytesCodec, FramedRead}; +use uuid::Uuid; + +use crate::{ + client::Client, + encoding::lower_hex, + error, + image_cache::{self, ImageCache}, + platform, +}; + +const CHECK_DOCKER: &str = "Check Docker Engine"; +const PREPARE_CONTEXT: &str = "Prepare Docker build context"; +const BUILD_IMAGE: &str = "Build Docker image"; +const PULL_IMAGE: &str = "Pull OCI image"; +const LOOKUP_IMPORTED_IMAGE: &str = "Look up imported Microsandbox image"; +const EXPORT_IMAGE: &str = "Export Docker image"; +const IMPORT_IMAGE: &str = "Import Microsandbox image"; +const DOWNLOAD_LAYERS: &str = "Download image layers"; +const MATERIALIZE_LAYERS: &str = "Materialize image layers"; +const ASSEMBLE_ROOT_DISK: &str = "Assemble root disk"; +const RETAIN_BUILD_CACHE: &str = "Retain Docker build cache"; +const REMOVE_TEMPORARY_IMAGE: &str = "Remove temporary Docker image"; +const EXPORT_PREPARED_ROOT: &str = "Export prepared root"; +const IMPORT_PREPARED_ROOT: &str = "Import prepared root"; +const EXPORT_PROGRESS_INTERVAL: u64 = 32 * 1024 * 1024; +const CACHE_REPOSITORY: &str = "sandbox-microsandbox-cache"; +const IMPORT_CACHE_REPOSITORY: &str = "sandbox-microsandbox-import"; + +/// Resolves Dockerfile builds and OCI references into the Microsandbox cache +/// used by its paired Backend. +pub(crate) struct MicrosandboxImageBackend { + client: Client, + images: ImageCache, + docker: Result, + registry_authentication: Option, +} + +impl MicrosandboxImageBackend { + pub(crate) fn new( + client: Client, + images: ImageCache, + registry_authentication: Option, + ) -> Self { + Self { + client, + images, + docker: Docker::connect_with_defaults().map_err(|failure| failure.to_string()), + registry_authentication, + } + } + + fn docker(&self) -> Result<&Docker, Error> { + self.docker.as_ref().map_err(|failure| Error::Backend(failure.clone())) + } + + /// Scratch directory for image and build-context archives, inside the Microsandbox cache. + /// + /// The system temporary directory is often a small tmpfs (a Sandbox guest gives `/tmp` + /// 512 MiB), while an exported image archive is as large as the image itself. + async fn scratch_dir(&self) -> Result { + let scratch = self.images.scratch_directory(); + tokio::fs::create_dir_all(&scratch) + .await + .map_err(|source| error::io("create image scratch directory", source))?; + Ok(scratch) + } + + async fn build_image( + &self, + request: &image::ResolveRequest, + context: &Path, + dockerfile: &Path, + target: Option<&str>, + progress: &SandboxProgress, + ) -> Result { + let platform = platform::require_supported(&request.platform)?; + self.check_docker(progress).await?; + let prepared = self + .prepare_context(context, dockerfile, target, &request.platform, progress) + .await?; + let build_id = Uuid::new_v4().simple().to_string(); + let temporary_tag = format!("sandbox-microsandbox-build:{build_id}"); + self.build_docker_image(&prepared, &temporary_tag, &build_id, &platform, progress) + .await?; + let resolution = self + .resolve_built_image(&temporary_tag, &prepared.cache_tag, progress) + .await; + let cleanup = self.remove_temporary_image(&temporary_tag, progress).await; + let metadata = resolution?; + cleanup?; + Ok(metadata) + } + + async fn check_docker(&self, progress: &SandboxProgress) -> Result<(), Error> { + let step = progress.start_step(CHECK_DOCKER).await; + self.docker()?.ping().await.map_err(error::backend)?; + step.complete().await; + Ok(()) + } + + async fn prepare_context( + &self, + source_context: &Path, + source_dockerfile: &Path, + target: Option<&str>, + platform: &sandbox::Platform, + progress: &SandboxProgress, + ) -> Result { + let step = progress.start_step(PREPARE_CONTEXT).await; + let context = tokio::fs::canonicalize(source_context) + .await + .map_err(|source| error::io("resolve Docker build context", source))?; + let dockerfile = tokio::fs::canonicalize(context.join(source_dockerfile)) + .await + .map_err(|source| error::io("resolve Dockerfile", source))?; + let relative_dockerfile = dockerfile + .strip_prefix(&context) + .map_err(|_| Error::invalid("image.dockerfile", "must stay within image.context"))? + .to_path_buf(); + let dockerfile_parameter = archive_path(&relative_dockerfile)?; + + let cache_tag = cache_tag(&context, &dockerfile_parameter, target, platform); + let archive = create_context_archive(self.scratch_dir().await?, context, relative_dockerfile).await?; + step.complete().await; + Ok(PreparedBuild { + archive, + dockerfile: dockerfile_parameter, + target: target.map(str::to_owned), + cache_tag, + }) + } + + async fn build_docker_image( + &self, + prepared: &PreparedBuild, + temporary_tag: &str, + build_id: &str, + platform: &sandbox::Platform, + progress: &SandboxProgress, + ) -> Result<(), Error> { + let context_file = tokio::fs::File::open(&prepared.archive) + .await + .map_err(|source| error::io("open Docker build context archive", source))?; + let context_stream = FramedRead::new(context_file, BytesCodec::new()) + .map(|result| result.map(tokio_util::bytes::BytesMut::freeze)); + let options = BuildImageOptionsBuilder::default() + .dockerfile(&prepared.dockerfile) + .t(temporary_tag) + .platform(&platform.to_string()) + .version(BuilderVersion::BuilderBuildKit) + .session(build_id) + .rm(true) + .forcerm(true); + let options = if let Some(target) = &prepared.target { + options.target(target) + } else { + options + }; + let options = options.build(); + + let step = progress + .start_measured_step(BUILD_IMAGE, ProgressUnit::Bytes, None) + .await; + let mut completed_vertices = HashSet::new(); + let mut transfers = Transfers::default(); + let mut responses = self + .docker()? + .build_image(options, None, Some(bollard::body_try_stream(context_stream))); + while let Some(response) = responses.next().await { + let response = response.map_err(error::backend)?; + if let Some(detail) = response.error_detail { + return Err(Error::Backend( + detail + .message + .unwrap_or_else(|| "Docker image build failed".to_string()), + )); + } + if let Some(stream) = response.stream { + step.output(OutputStream::Stdout, stream).await; + } + if let Some(status) = response.status { + let output = response.id.as_ref().map_or_else( + || format!("{status}\n"), + |identifier| format!("{identifier}: {status}\n"), + ); + step.output(OutputStream::Stdout, output).await; + } + if let Some(detail) = response.progress_detail + && let Some(completed) = detail.current.and_then(|value| u64::try_from(value).ok()) + { + let total = detail.total.and_then(|value| u64::try_from(value).ok()); + let key = response.id.clone().unwrap_or_default(); + let (completed, total) = transfers.record(key, completed, total); + step.report(completed, total).await; + } + if let Some(aux) = response.aux { + report_buildkit_status(&step, &mut completed_vertices, aux).await?; + } + } + step.complete().await; + Ok(()) + } + + async fn resolve_built_image( + &self, + temporary_tag: &str, + cache_tag: &str, + progress: &SandboxProgress, + ) -> Result { + let step = progress.start_step(RETAIN_BUILD_CACHE).await; + self.retain_build_cache(temporary_tag, cache_tag).await?; + step.complete().await; + + let import_reference = self.import_cache_reference(temporary_tag).await?; + if let Some(metadata) = self.cached_import(&import_reference, progress).await? { + return Ok(metadata); + } + + let image_archive = self.export_image_observed(temporary_tag, progress).await?; + self.import_image(&image_archive, &import_reference, progress).await + } + + async fn export_image_observed( + &self, + temporary_tag: &str, + progress: &SandboxProgress, + ) -> Result { + let step = progress + .start_measured_step(EXPORT_IMAGE, ProgressUnit::Bytes, None) + .await; + let archive = self.export_image(temporary_tag, &step).await?; + step.complete().await; + Ok(archive) + } + + async fn remove_temporary_image(&self, temporary_tag: &str, progress: &SandboxProgress) -> Result<(), Error> { + let step = progress.start_step(REMOVE_TEMPORARY_IMAGE).await; + self.docker()? + .remove_image( + temporary_tag, + None::, + None, + ) + .await + .map_err(error::backend)?; + step.complete().await; + Ok(()) + } + + async fn retain_build_cache(&self, image: &str, cache_tag: &str) -> Result<(), Error> { + let (repository, tag) = cache_tag + .split_once(':') + .ok_or(Error::invalid("image.cacheTag", "must contain a repository and tag"))?; + self.docker()? + .tag_image( + image, + Some(TagImageOptionsBuilder::default().repo(repository).tag(tag).build()), + ) + .await + .map_err(error::backend) + } + + async fn import_cache_reference(&self, image: &str) -> Result { + let image_id = self + .docker()? + .inspect_image(image) + .await + .map_err(error::backend)? + .id + .ok_or_else(|| Error::Backend("Docker did not report the built image ID".to_string()))?; + Ok(format!( + "{IMPORT_CACHE_REPOSITORY}:docker-{}", + lower_hex(&Sha256::digest(image_id.as_bytes())) + )) + } + + /// Returns the metadata of an image imported from the same Docker image before, while the + /// cache still has it. + async fn cached_import( + &self, + reference: &str, + progress: &SandboxProgress, + ) -> Result, Error> { + let step = progress.start_step(LOOKUP_IMPORTED_IMAGE).await; + let reference = reference.parse().map_err(error::backend)?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let Some(metadata) = cache.read_image_metadata(&reference).map_err(error::backend)? else { + return Ok(None); + }; + let entry = image_cache::cache_entry(&metadata.manifest_digest); + let cached = match microsandbox::Image::get_local(self.client.local(), &entry).await { + Ok(_) => true, + Err(microsandbox::MicrosandboxError::ImageNotFound(_)) => false, + Err(failure) => return Err(error::microsandbox(failure)), + }; + step.complete().await; + Ok(cached.then_some(metadata)) + } + + async fn import_image( + &self, + image_archive: &Path, + import_reference: &str, + progress: &SandboxProgress, + ) -> Result { + let step = progress.start_step(IMPORT_IMAGE).await; + let (mut import_events, import_progress) = microsandbox_image::progress_channel(); + let cache_dir = self.client.local().cache_dir(); + let load = microsandbox_image::load_archive( + &cache_dir, + image_archive, + microsandbox_image::ImageLoadOptions { + tags: vec![import_reference.to_string()], + progress: Some(import_progress), + }, + ); + let report = async { + let mut pull = PullReport::default(); + while let Some(event) = import_events.recv().await { + pull.report(progress, event).await; + } + pull + }; + let (loaded, pull) = tokio::join!(load, report); + let loaded = loaded.map_err(error::backend)?; + // The channel also closes when the import fails; its steps then stay + // open and end as failed with the operation. + pull.finish().await; + let image = loaded + .into_iter() + .find(|image| image.reference == import_reference) + .ok_or_else(|| Error::Backend(format!("Microsandbox did not return imported image {import_reference}")))?; + step.complete().await; + Ok(image.metadata) + } + + async fn export_image(&self, reference: &str, step: &MeasuredStep) -> Result { + let archive = tempfile::NamedTempFile::new_in(self.scratch_dir().await?) + .map_err(|source| error::io("create Docker image archive", source))? + .into_temp_path(); + let mut file = tokio::fs::File::create(&archive) + .await + .map_err(|source| error::io("open Docker image archive", source))?; + let mut chunks = self.docker()?.export_image(reference); + let mut written = 0_u64; + let mut reported = 0_u64; + while let Some(chunk) = chunks.next().await { + let chunk = chunk.map_err(error::backend)?; + let chunk_length = u64::try_from(chunk.len()) + .map_err(|_| Error::Backend("Docker image export exceeded the supported size".to_string()))?; + file.write_all(&chunk) + .await + .map_err(|source| error::io("write Docker image archive", source))?; + written = written.saturating_add(chunk_length); + if written.saturating_sub(reported) >= EXPORT_PROGRESS_INTERVAL { + step.report(written, None).await; + reported = written; + } + } + step.report(written, Some(written)).await; + file.sync_all() + .await + .map_err(|source| error::io("sync Docker image archive", source))?; + Ok(archive) + } + + async fn pull_reference( + &self, + request: &image::ResolveRequest, + reference: &str, + progress: &SandboxProgress, + ) -> Result { + platform::require_supported(&request.platform)?; + let parsed: microsandbox_image::Reference = reference + .parse() + .map_err(|failure| Error::Backend(format!("invalid OCI image reference '{reference}': {failure}")))?; + let step = progress.start_step(PULL_IMAGE).await; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let options = microsandbox_image::PullOptions { + pull_policy: reference_pull_policy(&parsed), + force: false, + materialization: match request.root_filesystem_mode { + RootFilesystemMode::Layered => microsandbox_image::RootfsMaterialization::Layered, + RootFilesystemMode::Direct => microsandbox_image::RootfsMaterialization::Flat, + mode => { + return Err(Error::UnsupportedImageRootFilesystemMode { + operation: image::ImageOperation::Resolve, + mode, + }); + } + }, + }; + + let metadata = if let Some((_, metadata)) = + microsandbox_image::Registry::pull_cached(&cache, &parsed, &options).map_err(error::backend)? + { + metadata + } else { + let config = self.client.local().config(); + let authentication = match &self.registry_authentication { + Some(sandbox::image::RegistryAuthentication::Anonymous) => microsandbox_image::RegistryAuth::Anonymous, + Some(sandbox::image::RegistryAuthentication::Basic { username, password }) => { + microsandbox_image::RegistryAuth::Basic { + username: username.clone(), + password: password.clone(), + } + } + None => config + .resolve_registry_auth(parsed.registry()) + .map_err(error::microsandbox)?, + }; + let registry = + microsandbox_image::Registry::builder(microsandbox_image::Platform::host_linux(), cache.clone()) + .auth(authentication) + .extra_ca_certs(config.resolve_ca_certs().await.map_err(error::microsandbox)?) + .add_insecure_registries(config.insecure_registries()) + .build() + .map_err(error::backend)?; + let (mut events, sender) = microsandbox_image::progress_channel(); + let pull = registry.pull_with_sender(&parsed, &options, sender); + let report = async { + let mut pull = PullReport::default(); + while let Some(event) = events.recv().await { + pull.report(progress, event).await; + } + pull + }; + let (result, report) = tokio::join!(pull, report); + result.map_err(error::backend)?.map_err(error::backend)?; + // The channel also closes when the pull fails; its steps then stay + // open and end as failed with the operation. + report.finish().await; + cache + .read_image_metadata(&parsed) + .map_err(error::backend)? + .ok_or_else(|| Error::Backend("Microsandbox did not retain pulled image metadata".to_string()))? + }; + + step.complete().await; + Ok(metadata) + } + + /// Records the image a fetch returns in the catalog and describes it as resolved for the + /// request. + async fn record_resolved( + &self, + request: &image::ResolveRequest, + fetch: impl Future>, + ) -> Result { + let fallback = platform::require_supported(&request.platform)?; + let (entry, ()) = self.images.record(async { Ok((fetch.await?, ())) }).await?; + let handle = microsandbox::Image::get_local(self.client.local(), &entry) + .await + .map_err(error::microsandbox)?; + let (manifest_digest, actual) = resolve_image_handle(&handle, &request.platform, &fallback)?; + Ok(image::ResolvedImage { + source: request.source.clone(), + platform: actual, + manifest_digest, + }) + } + + async fn export_prepared_root( + &self, + request: &image::ResolveRequest, + destination: &Path, + progress: &SandboxProgress, + ) -> Result { + let operation = image::ImageOperation::PreparedImageExport; + require_direct_prepared_root(request, operation)?; + let (_, reference) = prepared_root_reference(request, operation)?; + let resolved = self + .record_resolved(request, self.pull_reference(request, &reference.to_string(), progress)) + .await?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let step = progress.start_step(EXPORT_PREPARED_ROOT).await; + let prepared = microsandbox_image::export_prepared_root( + &cache, + &reference, + µsandbox_image::Platform::host_linux(), + destination, + ) + .await + .map_err(error::backend)?; + step.complete().await; + Ok(prepared_root(resolved, &prepared)) + } + + async fn import_prepared_root( + &self, + request: &image::ResolveRequest, + source: &Path, + progress: &SandboxProgress, + ) -> Result { + let operation = image::ImageOperation::PreparedImageImport; + require_direct_prepared_root(request, operation)?; + let actual = platform::require_supported(&request.platform)?; + let (_, reference) = prepared_root_reference(request, operation)?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let step = progress.start_step(IMPORT_PREPARED_ROOT).await; + // Record the imported root like a pulled image, so it is listed locally + // and removing it reclaims its flat artifacts. + let (_, prepared) = self + .images + .record(async { + let prepared = microsandbox_image::import_prepared_root( + &cache, + &reference, + µsandbox_image::Platform::host_linux(), + source, + ) + .await + .map_err(error::backend)?; + Ok((prepared.image.clone(), prepared)) + }) + .await?; + step.complete().await; + Ok(prepared_root( + image::ResolvedImage { + source: request.source.clone(), + platform: actual, + manifest_digest: prepared.image.manifest_digest.clone(), + }, + &prepared, + )) + } +} + +fn require_direct_prepared_root( + request: &image::ResolveRequest, + operation: image::ImageOperation, +) -> Result<(), Error> { + if request.root_filesystem_mode == RootFilesystemMode::Direct { + Ok(()) + } else { + Err(Error::UnsupportedImageRootFilesystemMode { + operation, + mode: request.root_filesystem_mode, + }) + } +} + +fn reference_pull_policy(reference: µsandbox_image::Reference) -> microsandbox_image::PullPolicy { + if reference.digest().is_some() { + microsandbox_image::PullPolicy::IfMissing + } else { + // A tag is mutable. Refresh its manifest when creating a Sandbox while + // retaining content-addressed layers and rootfs artifacts in the cache. + microsandbox_image::PullPolicy::Always + } +} + +/// The prepared root's reference as given, used as its catalog name like a +/// pulled image's, and parsed. +fn prepared_root_reference( + request: &image::ResolveRequest, + operation: image::ImageOperation, +) -> Result<(&str, microsandbox_image::Reference), Error> { + let image::ImageSource::Reference { reference } = &request.source else { + return Err(Error::UnsupportedImageSourceKind { + operation, + source_kind: request.source.kind(), + }); + }; + let parsed = reference + .parse::() + .map_err(error::backend)?; + if parsed.digest().is_none() { + return Err(Error::invalid( + "image.reference", + "prepared roots require an immutable digest-pinned OCI reference", + )); + } + Ok((reference, parsed)) +} + +fn prepared_root( + image: image::ResolvedImage, + prepared: µsandbox_image::PreparedRootMetadata, +) -> image::PreparedImage { + image::PreparedImage { + image, + root_filesystem_mode: RootFilesystemMode::Direct, + artifact_digest: prepared.root.artifact_digest.clone(), + virtual_size_bytes: prepared.root.virtual_size_bytes, + } +} + +struct PreparedBuild { + archive: tempfile::TempPath, + dockerfile: String, + target: Option, + cache_tag: String, +} + +fn cache_tag(context: &Path, dockerfile: &str, target: Option<&str>, platform: &sandbox::Platform) -> String { + let mut digest = Sha256::new(); + let platform = platform.to_string(); + for component in [ + context.as_os_str().as_encoded_bytes(), + dockerfile.as_bytes(), + target.unwrap_or_default().as_bytes(), + platform.as_bytes(), + ] { + digest.update(component); + digest.update([0]); + } + format!("{CACHE_REPOSITORY}:{}", lower_hex(&digest.finalize())) +} + +fn resolve_image_handle( + handle: µsandbox::ImageHandle, + requested: &sandbox::Platform, + fallback: &sandbox::Platform, +) -> Result<(String, sandbox::Platform), Error> { + let manifest_digest = handle + .manifest_digest() + .ok_or_else(|| Error::Backend("Microsandbox did not report the image manifest digest".to_string()))? + .to_string(); + let actual = sandbox::Platform::new( + handle.os().unwrap_or(fallback.os.as_str()), + handle.architecture().unwrap_or(fallback.architecture.as_str()), + ); + if !actual.satisfies(requested) { + return Err(Error::ImagePlatformMismatch { + requested: Box::new(requested.clone()), + actual: Box::new(actual), + }); + } + Ok((manifest_digest, actual)) +} + +async fn report_buildkit_status( + step: &MeasuredStep, + completed_vertices: &mut HashSet, + aux: bollard::models::BuildInfoAux, +) -> Result<(), Error> { + let bollard::models::BuildInfoAux::BuildKit(status) = aux else { + return Ok(()); + }; + for vertex in status.vertexes { + if !vertex.error.is_empty() { + return Err(Error::Backend(vertex.error)); + } + if vertex.completed.is_some() && completed_vertices.insert(vertex.digest) { + let outcome = if vertex.cached { "CACHED" } else { "DONE" }; + step.output(OutputStream::Stdout, format!("{outcome} {}\n", vertex.name)) + .await; + } + } + for log in status.logs { + let stream = if log.stream == 2 { + OutputStream::Stderr + } else { + OutputStream::Stdout + }; + step.output(stream, log.msg).await; + } + for warning in status.warnings { + let mut message = warning.short; + for detail in warning.detail { + message.extend_from_slice(b"\n"); + message.extend(detail); + } + message.extend_from_slice(b"\n"); + step.output(OutputStream::Stderr, message).await; + } + Ok(()) +} + +/// Sums concurrent transfers, such as layer downloads, into one quantity. +/// +/// The total is known once every transfer seen so far has announced its size. +#[derive(Default)] +struct Transfers { + transfers: std::collections::BTreeMap)>, +} + +impl Transfers { + fn record(&mut self, key: String, completed: u64, total: Option) -> (u64, Option) { + self.transfers.insert(key, (completed, total)); + self.totals(None) + } + + fn totals(&self, announced: Option) -> (u64, Option) { + let completed = self.transfers.values().map(|(completed, _)| completed).sum(); + let total = announced.or_else(|| { + self.transfers + .values() + .map(|(_, total)| *total) + .sum::>() + .filter(|_| !self.transfers.is_empty()) + }); + (completed, total) + } +} + +/// Translates registry pull events into the steps of one image pull or import. +/// +/// Layers download and materialize concurrently, so each activity is its own +/// measured step with one aggregated byte count, started when its first event +/// arrives. Assembling the root disk has no byte progress and is named through +/// its output so a long write is visibly in progress rather than silent. +#[derive(Default)] +struct PullReport { + layers: Option, + /// Total download size announced by the registry, when known up front. + total_download_bytes: Option, + downloads: Transfers, + materializations: Transfers, + download: Option, + materialize: Option, + assemble: Option, +} + +impl PullReport { + async fn report(&mut self, progress: &SandboxProgress, event: microsandbox_image::PullProgress) { + use microsandbox_image::PullProgress; + match event { + PullProgress::Resolved { + layer_count, + total_download_bytes, + .. + } => { + self.layers = u64::try_from(layer_count).ok(); + self.total_download_bytes = total_download_bytes; + } + PullProgress::LayerDownloadProgress { + layer_index, + downloaded_bytes, + total_bytes, + .. + } => { + self.downloads + .record(layer_index.to_string(), downloaded_bytes, total_bytes); + self.report_download(progress).await; + } + PullProgress::LayerDownloadComplete { + layer_index, + downloaded_bytes, + .. + } => { + self.downloads + .record(layer_index.to_string(), downloaded_bytes, Some(downloaded_bytes)); + self.report_download(progress).await; + } + PullProgress::LayerMaterializeStarted { layer_index, .. } => { + let line = self.layer_line("Materializing layer", layer_index); + if let Some(step) = self.materialize_step(progress).await { + step.output(OutputStream::Stdout, line).await; + } + } + PullProgress::LayerMaterializeProgress { + layer_index, + bytes_read, + total_bytes, + } => { + let (completed, total) = + self.materializations + .record(layer_index.to_string(), bytes_read, Some(total_bytes)); + if let Some(step) = self.materialize_step(progress).await { + step.report(completed, total).await; + } + } + PullProgress::StitchMergingTrees { layer_count } => { + self.assemble(progress, format!("Merging {layer_count} layer trees\n")) + .await; + } + PullProgress::StitchWritingFsmeta => { + self.assemble(progress, "Writing filesystem metadata\n".into()).await; + } + PullProgress::StitchWritingVmdk => { + self.assemble(progress, "Writing root disk image\n".into()).await; + } + PullProgress::Resolving { .. } + | PullProgress::LayerDownloadVerifying { .. } + | PullProgress::LayerMaterializeWriting { .. } + | PullProgress::LayerMaterializeComplete { .. } + | PullProgress::Complete { .. } + | PullProgress::StitchComplete => {} + } + } + + /// Completes the steps still running once the pull or import succeeded. + async fn finish(self) { + for step in [self.download, self.materialize].into_iter().flatten() { + step.complete().await; + } + if let Some(step) = self.assemble { + step.complete().await; + } + } + + async fn report_download(&mut self, progress: &SandboxProgress) { + let (completed, total) = self.downloads.totals(self.total_download_bytes); + if self.download.is_none() { + self.download = Some( + progress + .start_measured_step(DOWNLOAD_LAYERS, ProgressUnit::Bytes, total) + .await, + ); + } + if let Some(step) = &self.download { + step.report(completed, total).await; + } + } + + async fn materialize_step(&mut self, progress: &SandboxProgress) -> Option<&MeasuredStep> { + if self.materialize.is_none() { + self.materialize = Some( + progress + .start_measured_step(MATERIALIZE_LAYERS, ProgressUnit::Bytes, None) + .await, + ); + } + self.materialize.as_ref() + } + + /// Reports root-disk assembly, which starts once every layer is in place. + async fn assemble(&mut self, progress: &SandboxProgress, line: String) { + if self.assemble.is_none() { + for step in [self.download.take(), self.materialize.take()].into_iter().flatten() { + step.complete().await; + } + self.assemble = Some(progress.start_step(ASSEMBLE_ROOT_DISK).await); + } + if let Some(step) = &self.assemble { + step.output(OutputStream::Stdout, line).await; + } + } + + fn layer_line(&self, activity: &str, layer_index: usize) -> String { + let ordinal = layer_index.saturating_add(1); + self.layers.map_or_else( + || format!("{activity} {ordinal}\n"), + |total| format!("{activity} {ordinal}/{total}\n"), + ) + } +} + +impl image::ImageBackend for MicrosandboxImageBackend { + fn capabilities<'a>( + &'a self, + platform: &'a sandbox::Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + platform::require_supported(platform)?; + // TODO: Add prepared-image transport for Microsandbox's layered + // EROFS/VMDK representation. The current artifact format packages + // only the flat ext4 representation used by direct roots. + let prepared = image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Direct].into(), + ); + Ok(image::ImageBackendCapabilities::new( + image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build, image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Layered, RootFilesystemMode::Direct].into(), + ), + prepared.clone(), + prepared, + )) + }) + } + + fn resolve<'a>(&'a self, request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + PendingOperation::run(move |progress| { + Box::pin(async move { + let fetch = async { + match &request.source { + image::ImageSource::Build { + context, + dockerfile, + target, + } => { + self.build_image(request, context, dockerfile, target.as_deref(), &progress) + .await + } + image::ImageSource::Reference { reference } => { + self.pull_reference(request, reference, &progress).await + } + } + }; + let resolved = Box::pin(self.record_resolved(request, fetch)).await?; + self.images.remove_unused().await; + Ok(resolved) + }) + }) + } + + fn export_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + destination: &'a Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |progress| { + Box::pin(async move { Box::pin(self.export_prepared_root(request, destination, &progress)).await }) + }) + } + + fn import_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + source: &'a Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |progress| { + Box::pin(async move { + let prepared = self.import_prepared_root(request, source, &progress).await?; + self.images.remove_unused().await; + Ok(prepared) + }) + }) + } +} + +async fn create_context_archive( + scratch: PathBuf, + context: PathBuf, + dockerfile: PathBuf, +) -> Result { + tokio::task::spawn_blocking(move || { + let archive = tempfile::NamedTempFile::new_in(scratch)?; + let path = archive.into_temp_path(); + let file = File::create(&path)?; + let ignore = dockerignore(&context)?; + let mut builder = tar::Builder::new(file); + append_directory(&mut builder, &context, &context, &dockerfile, &ignore)?; + builder.finish()?; + Ok::<_, std::io::Error>(path) + }) + .await + .map_err(error::backend)? + .map_err(|source| error::io("create Docker build context archive", source)) +} + +fn dockerignore(context: &Path) -> Result { + let mut builder = GitignoreBuilder::new(context); + let path = context.join(".dockerignore"); + if path.is_file() + && let Some(source) = builder.add(path) + { + return Err(std::io::Error::other(source)); + } + builder.build().map_err(std::io::Error::other) +} + +fn append_directory( + archive: &mut tar::Builder, + context: &Path, + directory: &Path, + dockerfile: &Path, + ignore: &Gitignore, +) -> Result<(), std::io::Error> { + let mut entries = std::fs::read_dir(directory)?.collect::, _>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + + for entry in entries { + let path = entry.path(); + let relative = path.strip_prefix(context).map_err(std::io::Error::other)?; + let metadata = std::fs::symlink_metadata(&path)?; + let is_directory = metadata.is_dir(); + let forced = relative == dockerfile || relative == Path::new(".dockerignore"); + let excluded = ignore.matched_path_or_any_parents(relative, is_directory).is_ignore(); + + if is_directory { + if !excluded { + archive.append_dir(relative, &path)?; + } + append_directory(archive, context, &path, dockerfile, ignore)?; + } else if forced || !excluded { + archive.append_path_with_name(&path, relative)?; + } + } + Ok(()) +} + +fn archive_path(path: &Path) -> Result { + path.to_str() + .map(|value| value.replace('\\', "/")) + .ok_or(Error::invalid("image.dockerfile", "must be valid Unicode")) +} + +#[cfg(test)] +// Test Clients live for the whole test; tightening their drop adds nothing. +#[allow(clippy::expect_used, clippy::significant_drop_tightening)] +mod tests { + use std::{fs, path::Path}; + + async fn image_backend(client: crate::client::Client, state: &Path) -> super::MicrosandboxImageBackend { + let state = crate::state::StateStore::open(state.to_path_buf()) + .await + .expect("state store should open"); + let images = crate::image_cache::ImageCache::new(client.clone(), state, None); + super::MicrosandboxImageBackend::new(client, images, None) + } + + #[test] + fn mutable_references_refresh_registry_metadata_while_digest_pins_reuse_the_cache() { + let tagged = "ghcr.io/altinn/agent:latest" + .parse() + .expect("tagged reference should parse"); + let pinned = format!("ghcr.io/altinn/agent@sha256:{}", "0".repeat(64)) + .parse() + .expect("digest-pinned reference should parse"); + + assert_eq!( + super::reference_pull_policy(&tagged), + microsandbox_image::PullPolicy::Always + ); + assert_eq!( + super::reference_pull_policy(&pinned), + microsandbox_image::PullPolicy::IfMissing + ); + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires Internet access"] + async fn imported_prepared_roots_are_recorded_in_the_image_catalog() { + use sandbox::image::ImageBackend as _; + + // Prepared roots pin the native platform manifest of Alpine 3.22. + let (architecture, manifest_digest) = match std::env::consts::ARCH { + "x86_64" => ( + "amd64", + "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", + ), + "aarch64" => ( + "arm64", + "sha256:2c9d26f410d032d5b1525aa8a873e238b05b90c4ae8618743d4311f0cc827e37", + ), + _ => return, + }; + let temporary = tempfile::tempdir().expect("temporary directory should be created"); + let reference = format!("docker.io/library/alpine@{manifest_digest}"); + let request = sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: reference.clone(), + }, + platform: sandbox::Platform::new("linux", architecture), + root_filesystem_mode: sandbox::RootFilesystemMode::Direct, + }; + let exporter = image_backend( + crate::client::Client::open(temporary.path().join("exporter"), None, None) + .await + .expect("exporting Client should open"), + &temporary.path().join("exporter-state"), + ) + .await; + let bundle = temporary.path().join("prepared"); + exporter + .export_prepared_image(&request, &bundle) + .await + .expect("prepared root should export"); + + let client = crate::client::Client::open(temporary.path().join("importer"), None, None) + .await + .expect("importing Client should open"); + let importer = image_backend(client.clone(), &temporary.path().join("importer-state")).await; + importer + .import_prepared_image(&request, &bundle) + .await + .expect("prepared root should import"); + importer + .import_prepared_image(&request, &bundle) + .await + .expect("importing the same root again should succeed"); + + let images = microsandbox::Image::list_local(client.local()) + .await + .expect("local images should list"); + let entry = crate::image_cache::cache_entry(manifest_digest); + assert!( + images.iter().any(|image| image.reference() == entry), + "the imported root should be recorded in the cache; found {:?}", + images + .iter() + .map(microsandbox::ImageHandle::reference) + .collect::>() + ); + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires Internet access"] + async fn a_tagged_image_is_recorded_by_its_manifest_digest() { + use sandbox::image::ImageBackend as _; + + let temporary = tempfile::tempdir().expect("temporary directory should be created"); + let client = crate::client::Client::open(temporary.path().join("runtime"), None, None) + .await + .expect("Client should open"); + let backend = image_backend(client.clone(), &temporary.path().join("state")).await; + let request = sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: sandbox::Platform::native("linux"), + root_filesystem_mode: sandbox::RootFilesystemMode::Layered, + }; + + let resolved = backend.resolve(&request).await.expect("tag should resolve"); + + let references: Vec = microsandbox::Image::list_local(client.local()) + .await + .expect("local images should list") + .iter() + .map(|image| image.reference().to_string()) + .collect(); + assert_eq!( + references, + [crate::image_cache::cache_entry(&resolved.manifest_digest)], + "a tag moves to newer versions, so only the resolved version's digest names it" + ); + } + + #[tokio::test(flavor = "local")] + async fn context_archive_applies_dockerignore_and_keeps_build_inputs() { + let context = tempfile::tempdir().expect("temporary context should be created"); + fs::create_dir(context.path().join("nested")).expect("nested directory should be created"); + fs::write(context.path().join("Dockerfile"), "FROM scratch\n").expect("Dockerfile should be written"); + fs::write( + context.path().join(".dockerignore"), + "Dockerfile\nignored.txt\nnested/*\n!nested/included.txt\n", + ) + .expect("Dockerignore should be written"); + fs::write(context.path().join("included.txt"), "included").expect("included file should be written"); + fs::write(context.path().join("ignored.txt"), "ignored").expect("ignored file should be written"); + fs::write(context.path().join("nested/included.txt"), "included").expect("re-included file should be written"); + fs::write(context.path().join("nested/ignored.txt"), "ignored").expect("nested ignored file should be written"); + + let archive = super::create_context_archive( + std::env::temp_dir(), + context.path().to_path_buf(), + Path::new("Dockerfile").to_path_buf(), + ) + .await + .expect("context archive should be created"); + let file = fs::File::open(archive).expect("context archive should open"); + let entries = tar::Archive::new(file) + .entries() + .expect("archive entries should be readable") + .map(|entry| { + entry + .expect("archive entry should be readable") + .path() + .expect("archive path should be readable") + .into_owned() + }) + .collect::>(); + + assert!(entries.contains(&Path::new("Dockerfile").to_path_buf())); + assert!(entries.contains(&Path::new(".dockerignore").to_path_buf())); + assert!(entries.contains(&Path::new("included.txt").to_path_buf())); + assert!(entries.contains(&Path::new("nested/included.txt").to_path_buf())); + assert!(!entries.contains(&Path::new("ignored.txt").to_path_buf())); + assert!(!entries.contains(&Path::new("nested/ignored.txt").to_path_buf())); + } + + #[test] + fn dockerfile_paths_use_archive_separators() { + assert_eq!( + super::archive_path(Path::new("nested\\Dockerfile")).expect("path should be valid"), + "nested/Dockerfile" + ); + } + + #[test] + fn docker_cache_tags_are_stable_per_source_and_platform() { + let context = Path::new("/workspace/project"); + let platform = sandbox::Platform::new("linux", "amd64"); + let tag = super::cache_tag(context, "Dockerfile", None, &platform); + + assert_eq!(tag, super::cache_tag(context, "Dockerfile", None, &platform)); + assert_ne!( + tag, + super::cache_tag(Path::new("/workspace/other"), "Dockerfile", None, &platform) + ); + assert_ne!(tag, super::cache_tag(context, "nested/Dockerfile", None, &platform)); + assert_ne!(tag, super::cache_tag(context, "Dockerfile", Some("minimal"), &platform)); + assert_ne!( + tag, + super::cache_tag(context, "Dockerfile", None, &sandbox::Platform::new("linux", "arm64")) + ); + } +} diff --git a/sandbox/sandbox-microsandbox/src/image_cache.rs b/sandbox/sandbox-microsandbox/src/image_cache.rs new file mode 100644 index 0000000..cb39625 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/image_cache.rs @@ -0,0 +1,974 @@ +//! The Microsandbox image cache of one Provider home. +//! +//! Microsandbox keeps an image version while any catalog entry names it, and +//! `Image::remove_local` removes the version with its last entry: its manifest, the layers no +//! other version shares and its root filesystem artifacts. It refuses while a runtime uses the +//! image. Everything that holds an image is therefore a catalog entry: +//! +//! - A Sandbox entry per Sandbox record, added when the record is created and removed when the +//! Sandbox is deleted, so an image stays while a Sandbox uses it, with or without a runtime. +//! - A cache entry per image version, named by its digest, which resolving or importing the +//! image refreshes, and which deleting a Sandbox that used it refreshes too. A removal pass +//! removes it once it has not been refreshed for the retention period. +//! +//! Microsandbox applies each removal atomically, so removing an entry never needs to know what +//! else holds its image. Only removal passes remove the last entry of a version, and so delete +//! files; they run exclusively of image fetches, which may reuse those files. + +use std::{ + collections::HashSet, + future::Future, + path::PathBuf, + rc::Rc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use microsandbox_image::{CachedImageMetadata, GlobalCache, Reference}; +use sandbox::{Error, SandboxId}; +use tokio::sync::RwLock; + +use crate::{ + client::Client, + error, + state::{SandboxRecord, StateStore}, +}; + +/// The shortest retention period. A cache entry must outlive the time between resolving an +/// image and creating the Sandbox that holds it. +pub(crate) const MINIMUM_RETENTION: Duration = Duration::from_hours(1); + +/// Directory below the Microsandbox cache for this crate's image and build-context archives. +/// Microsandbox stages its own downloads in the parent directory. +const SCRATCH_DIRECTORY: &str = "tmp/sandbox-microsandbox"; + +/// Repository of Sandbox entries, tagged with the Sandbox ID. Nothing pulls it: runtimes are +/// created from the cache only. +const SANDBOX_REPOSITORY: &str = "sandbox-microsandbox-sandbox"; + +/// Repository of cache entries, pinned to the image version's manifest digest. +const CACHE_REPOSITORY: &str = "sandbox-microsandbox-cache"; + +/// Repository of the temporary entry that tells whether a runtime records using an image. +const PROBE_REPOSITORY: &str = "sandbox-microsandbox-probe"; + +/// Marks a home whose catalog, from before Sandbox entries, has been migrated. +const MIGRATED_MARKER: &str = "image-catalog-v2"; + +/// The Microsandbox image cache of one Provider home. +#[derive(Clone)] +pub(crate) struct ImageCache { + client: Client, + state: StateStore, + /// Removes unused images when set. Unset leaves the cache to its owner. + retention: Option, + /// Shared while an image is fetched and recorded, held exclusively by a removal pass. A + /// fetch reuses cached layers that the catalog does not yet attribute to the new version. + catalog: Rc>, +} + +impl ImageCache { + pub(crate) fn new(client: Client, state: StateStore, retention: Option) -> Self { + Self { + client, + state, + retention, + catalog: Rc::new(RwLock::new(())), + } + } + + /// Fetches an image and records it in its cache entry, which marks it used, and returns the + /// entry's name. Removal passes wait for the fetch, which may reuse cached layers that no + /// entry attributes to the new version yet. + pub(crate) async fn record( + &self, + fetch: impl Future>, + ) -> Result<(String, T), Error> { + let _recording = self.catalog.read().await; + let (metadata, fetched) = fetch.await?; + let name = cache_entry(&metadata.manifest_digest); + self.write_entry(&name, metadata).await?; + Ok((name, fetched)) + } + + /// Adds the entry that keeps a Sandbox's image while the Sandbox exists, and returns the + /// name to create its runtime from, or `None` when the image is not in the cache. Adding it + /// again changes nothing. Without a retention period nothing removes images, so no entry is + /// added and any entry naming the image is returned. + pub(crate) async fn hold(&self, record: &SandboxRecord) -> Result, Error> { + let name = sandbox_entry(&record.id); + let manifest_digest = record.image.manifest_digest.as_str(); + let _recording = self.catalog.read().await; + let mut cached = None; + for entry in microsandbox::Image::list_local(self.client.local()) + .await + .map_err(error::microsandbox)? + { + if entry.manifest_digest() != Some(manifest_digest) { + continue; + } + if entry.reference() == name { + return Ok(Some(name)); + } + // A tag recorded before images were recorded by digest may have moved on. + if cached.is_none() + && let Some(metadata) = self.metadata(entry.reference())? + && metadata.manifest_digest == manifest_digest + { + cached = Some((entry.reference().to_string(), metadata)); + } + } + let Some((cached_name, metadata)) = cached else { + return Ok(None); + }; + if self.retention.is_none() { + return Ok(Some(cached_name)); + } + self.write_entry(&name, metadata).await?; + Ok(Some(name)) + } + + /// Releases a deleted Sandbox's image. Its cache entry is refreshed before the Sandbox's + /// entry goes, so the image stays for the retention period after the deletion, and never + /// loses its last entry here. An entry this leaves behind goes with the next pass. + pub(crate) async fn release(&self, record: &SandboxRecord) { + if self.retention.is_none() { + return; + } + let name = sandbox_entry(&record.id); + let taken_over = { + let _recording = self.catalog.read().await; + match self.metadata(&name) { + Ok(Some(metadata)) => { + self.write_entry(&cache_entry(&record.image.manifest_digest), metadata) + .await + } + other => other.map(drop), + } + }; + if let Err(error) = taken_over { + tracing::warn!(sandbox = %record.id, %error, "failed to keep a deleted Sandbox's image"); + return; + } + match microsandbox::Image::remove_local(self.client.local(), &name, false).await { + // Another Sandbox's runtime still uses the image. + Ok(()) + | Err(microsandbox::MicrosandboxError::ImageNotFound(_) | microsandbox::MicrosandboxError::ImageInUse(_)) => + {} + Err(failure) => tracing::warn!(entry = name, error = %failure, "failed to release a Sandbox's image"), + } + } + + /// Removes cache entries not used for the retention period, and entries of Sandboxes + /// whose record is gone. + /// + /// Removal is best-effort and never fails the caller. A pass is skipped while an image is + /// fetched or another pass runs; later operations run passes of their own. + pub(crate) async fn remove_unused(&self) { + let Some(retention) = self.retention else { + return; + }; + let Ok(_exclusive) = self.catalog.try_write() else { + return; + }; + if let Err(error) = self.remove_unused_at(SystemTime::now(), retention).await { + tracing::warn!(%error, "failed to remove unused Microsandbox images"); + } + self.remove_stale_scratch(retention).await; + } + + async fn remove_unused_at(&self, now: SystemTime, retention: Duration) -> Result<(), Error> { + // A Sandbox's image is kept while its record exists, whether or not the Sandbox holds it + // yet, as in a home from before Sandboxes held their images. + let records = self.state.sandbox_records().await?; + let sandboxes: HashSet = records.iter().map(|record| entry_tag(&record.id)).collect(); + let used: HashSet<&str> = records + .iter() + .map(|record| record.image.manifest_digest.as_str()) + .collect(); + let cutoff = unix_millis(now).saturating_sub(i64::try_from(retention.as_millis()).unwrap_or(i64::MAX)); + for image in microsandbox::Image::list_local(self.client.local()) + .await + .map_err(error::microsandbox)? + { + let last_used = image + .last_used_at() + .or_else(|| image.created_at()) + .map(|time| time.timestamp_millis()); + let unused = sandbox_entry_tag(image.reference()).map_or_else( + || is_expired(last_used, cutoff) && image.manifest_digest().is_none_or(|digest| !used.contains(digest)), + |tag| !sandboxes.contains(tag), + ); + if !unused { + continue; + } + match microsandbox::Image::remove_local(self.client.local(), image.reference(), false).await { + Ok(()) => tracing::info!(reference = image.reference(), "removed unused Microsandbox image entry"), + // A runtime still uses the image, or the entry is already gone. + Err( + microsandbox::MicrosandboxError::ImageInUse(_) | microsandbox::MicrosandboxError::ImageNotFound(_), + ) => {} + Err(failure) => tracing::warn!( + reference = image.reference(), + error = %failure, + "failed to remove unused Microsandbox image entry" + ), + } + } + Ok(()) + } + + /// Reports whether this home's catalog is from before Sandboxes held their images. + pub(crate) async fn migration_pending(&self) -> bool { + !tokio::fs::try_exists(self.state.marker(MIGRATED_MARKER)) + .await + .unwrap_or(false) + } + + /// Reports whether a runtime records that it uses a held Sandbox image, which is what + /// protects an image from Microsandbox's prune. Microsandbox refuses to remove an entry of an + /// image a runtime records using, so a temporary entry that can be removed shows that none + /// does. The Sandbox's own entry keeps the image meanwhile. + pub(crate) async fn is_pinned(&self, record: &SandboxRecord) -> Result { + let Some(metadata) = self.metadata(&sandbox_entry(&record.id))? else { + return Ok(false); + }; + let probe = format!("{PROBE_REPOSITORY}:{}", entry_tag(&record.id)); + self.write_entry(&probe, metadata).await?; + match microsandbox::Image::remove_local(self.client.local(), &probe, false).await { + Ok(()) => Ok(false), + Err(microsandbox::MicrosandboxError::ImageInUse(_)) => { + microsandbox::Image::remove_local(self.client.local(), &probe, true) + .await + .map_err(error::microsandbox)?; + Ok(true) + } + Err(failure) => Err(error::microsandbox(failure)), + } + } + + /// Completes the migration of a catalog from before Sandboxes held their images. Image + /// versions no entry names, which a moved tag left behind, are reachable only through + /// Microsandbox's prune, which also removes every entry of an image no runtime records + /// using, so it runs only once every Sandbox's image [is pinned](Self::is_pinned), while the + /// Provider opens and before anything else runs. + pub(crate) async fn finish_migration(&self) -> Result<(), Error> { + let report = microsandbox::Image::prune_local(self.client.local()) + .await + .map_err(error::microsandbox)?; + tracing::info!( + manifests = report.manifests_removed, + layers = report.layers_removed, + "migrated the Microsandbox image catalog" + ); + // Earlier releases staged archives in the parent of the scratch directory. + if let Some(legacy_scratch) = self.scratch_directory().parent() + && let Ok(mut entries) = tokio::fs::read_dir(legacy_scratch).await + { + while let Ok(Some(entry)) = entries.next_entry().await { + let archive = entry.file_name().to_string_lossy().starts_with(".tmp"); + if archive && entry.file_type().await.is_ok_and(|kind| kind.is_file()) { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + tokio::fs::write(self.state.marker(MIGRATED_MARKER), b"") + .await + .map_err(|source| error::io("record the Microsandbox image catalog migration", source)) + } + + /// Returns the metadata cached for a catalog entry. + fn metadata(&self, name: &str) -> Result, Error> { + let reference = name.parse::().map_err(error::backend)?; + self.global_cache()? + .read_image_metadata(&reference) + .map_err(error::backend) + } + + async fn write_entry(&self, name: &str, metadata: CachedImageMetadata) -> Result<(), Error> { + let reference = name.parse::().map_err(error::backend)?; + self.global_cache()? + .write_image_metadata_async(&reference, &metadata) + .await + .map_err(error::backend)?; + microsandbox::Image::persist(self.client.local(), name, metadata) + .await + .map_err(error::microsandbox)?; + Ok(()) + } + + fn global_cache(&self) -> Result { + GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend) + } + + /// Removes archives an interrupted image build left in this crate's scratch directory. + async fn remove_stale_scratch(&self, retention: Duration) { + let scratch = self.scratch_directory(); + let Ok(mut entries) = tokio::fs::read_dir(&scratch).await else { + return; + }; + let now = SystemTime::now(); + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(metadata) = entry.metadata().await else { + continue; + }; + let stale = metadata + .modified() + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= retention); + if metadata.is_file() + && stale + && let Err(error) = tokio::fs::remove_file(entry.path()).await + { + tracing::warn!(path = %entry.path().display(), %error, "failed to remove stale image archive"); + } + } + } + + pub(crate) fn scratch_directory(&self) -> PathBuf { + self.client.local().cache_dir().join(SCRATCH_DIRECTORY) + } +} + +fn entry_tag(id: &SandboxId) -> String { + id.as_uuid().simple().to_string() +} + +/// Returns the catalog name of a Sandbox's entry. +fn sandbox_entry(id: &SandboxId) -> String { + format!("{SANDBOX_REPOSITORY}:{}", entry_tag(id)) +} + +/// Returns the catalog name of an image version's cache entry. +pub(crate) fn cache_entry(manifest_digest: &str) -> String { + format!("{CACHE_REPOSITORY}@{manifest_digest}") +} + +/// Returns the Sandbox ID tag of a Sandbox entry, or `None` for any other catalog entry. +fn sandbox_entry_tag(reference: &str) -> Option<&str> { + let (repository, tag) = reference.rsplit_once(':')?; + (repository == SANDBOX_REPOSITORY).then_some(tag) +} + +/// Reports whether a cache entry was last used at or before the cutoff. An entry without a +/// recorded use has expired. +fn is_expired(last_used_millis: Option, cutoff_millis: i64) -> bool { + last_used_millis.is_none_or(|used| used <= cutoff_millis) +} + +fn unix_millis(time: SystemTime) -> i64 { + time.duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()) + .unwrap_or_default() +} + +#[cfg(test)] +// Test Clients live for the whole test; tightening their drop adds nothing. +#[allow(clippy::expect_used, clippy::significant_drop_tightening)] +mod tests { + use std::{ + collections::BTreeMap, + path::PathBuf, + time::{Duration, SystemTime}, + }; + + use sandbox::{ByteQuantity, CpuQuantity, Hostname, Platform, RootFilesystem, SandboxName, SandboxResources}; + + use super::{ImageCache, MIGRATED_MARKER, cache_entry, is_expired, sandbox_entry}; + use crate::{ + client::Client, + state::{SandboxRecord, StateStore}, + }; + + const DAY: Duration = Duration::from_hours(24); + + struct Home { + directory: tempfile::TempDir, + client: Client, + state: StateStore, + } + + impl Home { + async fn open() -> Self { + let directory = tempfile::tempdir().expect("temporary home should be created"); + let client = Client::open(directory.path().join("runtime"), None, None) + .await + .expect("Client should open"); + let state = StateStore::open(directory.path().join("state")) + .await + .expect("state store should open"); + Self { + directory, + client, + state, + } + } + + fn images(&self) -> ImageCache { + ImageCache::new(self.client.clone(), self.state.clone(), Some(DAY)) + } + + fn path(&self) -> &std::path::Path { + self.directory.path() + } + + /// Records an image the way resolving it does. + async fn resolve(&self, images: &ImageCache, manifest_digest: &str) { + images + .record(async { Ok((metadata(manifest_digest), ())) }) + .await + .expect("image should be recorded"); + } + + /// Records a catalog entry the way releases before Sandbox entries did. + async fn record_legacy(&self, name: &str, manifest_digest: &str) { + microsandbox::Image::persist(self.client.local(), name, metadata(manifest_digest)) + .await + .expect("image should be recorded"); + microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()) + .expect("image cache should open") + .write_image_metadata_async(&name.parse().expect("reference"), &metadata(manifest_digest)) + .await + .expect("image metadata should be written"); + } + + /// Stands in for the files Microsandbox materializes for an image version. + fn materialize(&self, manifest_digest: &str) -> PathBuf { + let path = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()) + .expect("image cache should open") + .fsmeta_erofs_path(&manifest_digest.parse().expect("digest should parse")); + std::fs::create_dir_all(path.parent().expect("fsmeta directory")).expect("fsmeta directory"); + std::fs::write(&path, b"fsmeta").expect("fsmeta file"); + path + } + + async fn sandbox_needing(&self, manifest_digest: &str) -> SandboxRecord { + let record = sandbox_record("00000000-0000-4000-8000-000000000001", manifest_digest); + self.state + .save_sandbox(&record) + .await + .expect("Sandbox record should be saved"); + record + } + + async fn references(&self) -> Vec { + let mut references: Vec = microsandbox::Image::list_local(self.client.local()) + .await + .expect("images should list") + .iter() + .map(|image| image.reference().to_string()) + .collect(); + references.sort(); + references + } + } + + fn metadata(manifest_digest: &str) -> microsandbox_image::CachedImageMetadata { + microsandbox_image::CachedImageMetadata { + manifest_digest: manifest_digest.to_string(), + config_digest: digest('c'), + raw_manifest_json: "{}".to_string(), + raw_config_json: "{}".to_string(), + config: microsandbox_image::ImageConfig::default(), + layers: Vec::new(), + } + } + + fn sandbox_record(id: &str, manifest_digest: &str) -> SandboxRecord { + SandboxRecord::new(sandbox::backend::CreateSandboxRequest { + id: id.parse().expect("Sandbox ID"), + name: SandboxName::new("worker").expect("Sandbox name"), + hostname: Hostname::new("worker").expect("hostname"), + image: sandbox::image::ResolvedImage { + source: sandbox::image::ImageSource::Reference { + reference: "example.com/app:latest".to_string(), + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: manifest_digest.to_string(), + }, + resources: SandboxResources::new( + "1".parse::().expect("CPU"), + "512Mi".parse::().expect("memory"), + RootFilesystem::layered("1Gi".parse::().expect("root filesystem")), + ), + init_system: sandbox::init::InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + }) + } + + fn digest(fill: char) -> String { + format!("sha256:{}", fill.to_string().repeat(64)) + } + + #[test] + fn cache_entries_expire_once_last_used_before_the_cutoff() { + assert!(!is_expired(Some(1001), 1000)); + assert!(is_expired(Some(1000), 1000)); + assert!(is_expired(None, 1000)); + } + + #[tokio::test(flavor = "local")] + async fn a_deleted_sandboxs_image_stays_for_the_retention_period_after_the_deletion() { + let home = Home::open().await; + let images = home.images(); + let image = digest('a'); + home.resolve(&images, &image).await; + let files = home.materialize(&image); + let record = home.sandbox_needing(&image).await; + let entry = images + .hold(&record) + .await + .expect("hold") + .expect("the image should be cached"); + assert_eq!(images.hold(&record).await.expect("hold again"), Some(entry.clone())); + + // Nothing is removed while the Sandbox uses the image, however long ago it was pulled. + images + .remove_unused_at(SystemTime::now() + 2 * DAY, DAY) + .await + .expect("pass"); + assert_eq!(home.references().await, [cache_entry(&image), entry]); + + home.state.remove_sandbox(&record).await.expect("record removed"); + images.release(&record).await; + assert_eq!(home.references().await, [cache_entry(&image)]); + images + .remove_unused_at(SystemTime::now() + Duration::from_hours(23), DAY) + .await + .expect("pass"); + assert!( + files.exists(), + "the image stays for the retention period after the deletion" + ); + images + .remove_unused_at(SystemTime::now() + Duration::from_hours(25), DAY) + .await + .expect("pass"); + assert!(home.references().await.is_empty()); + assert!(!files.exists(), "the image goes with its last entry"); + } + + #[tokio::test(flavor = "local")] + async fn an_entry_whose_sandbox_record_is_gone_is_removed() { + let home = Home::open().await; + let images = home.images(); + let image = digest('a'); + home.resolve(&images, &image).await; + let record = home.sandbox_needing(&image).await; + let entry = images + .hold(&record) + .await + .expect("hold") + .expect("the image should be cached"); + home.state.remove_sandbox(&record).await.expect("record removed"); + + images.remove_unused_at(SystemTime::now(), DAY).await.expect("pass"); + assert!(!home.references().await.contains(&entry)); + } + + #[tokio::test(flavor = "local")] + async fn an_image_that_is_not_cached_cannot_be_held() { + let home = Home::open().await; + let images = home.images(); + let record = home.sandbox_needing(&digest('a')).await; + assert_eq!(images.hold(&record).await.expect("hold"), None); + } + + /// Records a tag the way releases before Sandbox entries did, moved from a previous version + /// to a current one, which leaves the previous version without an entry. + async fn record_moved_tag(home: &Home) -> PathBuf { + let (previous, current) = (digest('a'), digest('b')); + home.record_legacy("example.com/app:latest", &previous).await; + home.record_legacy("example.com/app:latest", ¤t).await; + home.materialize(&previous) + } + + #[tokio::test(flavor = "local")] + async fn opening_a_provider_migrates_a_home_from_before_sandboxes_held_their_images() { + let home = Home::open().await; + let previous_files = record_moved_tag(&home).await; + let legacy_archive = home.client.local().cache_dir().join("tmp/.tmpArchive"); + std::fs::create_dir_all(legacy_archive.parent().expect("legacy scratch")).expect("legacy scratch"); + std::fs::write(&legacy_archive, b"archive").expect("legacy archive"); + + provider(home.path()).await; + assert!(!previous_files.exists(), "what nothing holds is removed"); + assert!(!legacy_archive.exists()); + assert!(home.state.marker(MIGRATED_MARKER).exists()); + } + + /// Records a Sandbox built from a Dockerfile before this release, whose image only its + /// import entry names, and which never started. + async fn record_unstarted_built_sandbox(home: &Home) -> (SandboxRecord, PathBuf) { + let image = digest('e'); + home.record_legacy("sandbox-microsandbox-import:docker-1234", &image) + .await; + let mut record = sandbox_record("00000000-0000-4000-8000-000000000009", &image); + record.image.source = sandbox::image::ImageSource::Build { + context: PathBuf::from("context"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }; + home.state.save_sandbox(&record).await.expect("record saved"); + (record, home.materialize(&image)) + } + + #[tokio::test(flavor = "local")] + async fn migration_waits_for_a_sandbox_that_never_started_and_completes_once_it_is_deleted() { + use sandbox::backend::SandboxBackend as _; + + let home = Home::open().await; + let previous_files = record_moved_tag(&home).await; + let (record, image_files) = record_unstarted_built_sandbox(&home).await; + + let provider = provider(home.path()).await; + provider + .images() + .remove_unused_at(SystemTime::now() + 10 * DAY, DAY) + .await + .expect("pass"); + assert!( + image_files.exists(), + "the Sandbox keeps its image through the migration and later passes" + ); + assert!( + previous_files.exists(), + "nothing is pruned while a Sandbox's image is unprotected" + ); + assert!(!home.state.marker(MIGRATED_MARKER).exists()); + + provider.delete(&record.id).await.expect("Sandbox deleted"); + drop(provider); + crate::MicrosandboxProvider::builder(home.path()) + .remove_unused_images_after(DAY) + .open() + .await + .expect("Provider should open"); + assert!(!previous_files.exists()); + assert!(home.state.marker(MIGRATED_MARKER).exists()); + } + + #[tokio::test(flavor = "local")] + #[ignore = "seeds the Microsandbox database with python3"] + async fn migration_keeps_the_image_of_a_sandbox_whose_first_start_was_interrupted() { + let home = Home::open().await; + let previous_files = record_moved_tag(&home).await; + let (record, image_files) = record_unstarted_built_sandbox(&home).await; + // A runtime whose creation stopped before Microsandbox recorded that it uses its image. + let builder = Client::sandbox_builder( + &record.runtime_name, + "sandbox-microsandbox-import:docker-1234", + record.resources, + ) + .expect("runtime builder"); + let config = Box::pin(home.client.scope(builder.build())) + .await + .expect("runtime config"); + let seeded = std::process::Command::new("python3") + .arg("-c") + .arg( + "import pathlib, sqlite3, sys; db = next(pathlib.Path(sys.argv[1]).rglob('msb.db')); \ + connection = sqlite3.connect(db); connection.execute('INSERT INTO sandbox (name, config, status, ephemeral) \ + VALUES (?, ?, ?, ?)', (sys.argv[2], sys.argv[3], 'Stopped', 0)); connection.commit()", + ) + .arg(home.path()) + .arg(&record.runtime_name) + .arg(serde_json::to_string(&config).expect("runtime config serializes")) + .output() + .expect("python3 should run"); + assert!(seeded.status.success(), "{}", String::from_utf8_lossy(&seeded.stderr)); + assert!( + home.client + .scope(microsandbox::Sandbox::get(&record.runtime_name)) + .await + .is_ok(), + "the runtime should exist" + ); + + let provider = provider(home.path()).await; + provider + .images() + .remove_unused_at(SystemTime::now() + 10 * DAY, DAY) + .await + .expect("pass"); + assert!(image_files.exists()); + assert!(previous_files.exists()); + assert!(!home.state.marker(MIGRATED_MARKER).exists()); + } + + #[tokio::test(flavor = "local")] + async fn an_image_recorded_before_cache_entries_is_held() { + let home = Home::open().await; + let image = digest('a'); + home.record_legacy("sandbox-microsandbox-import:docker-1234", &image) + .await; + let record = home.sandbox_needing(&image).await; + + assert_eq!( + home.images().hold(&record).await.expect("hold"), + Some(sandbox_entry(&record.id)) + ); + let unmanaged = ImageCache::new(home.client.clone(), home.state.clone(), None); + let other = sandbox_record("00000000-0000-4000-8000-000000000004", &image); + let name = unmanaged + .hold(&other) + .await + .expect("hold") + .expect("the image is cached"); + assert_ne!(name, sandbox_entry(&other.id)); + assert!( + !home.references().await.contains(&sandbox_entry(&other.id)), + "without a retention period nothing removes images, so no entry is added" + ); + } + + #[tokio::test(flavor = "local")] + async fn an_unreadable_sandbox_record_removes_nothing() { + let home = Home::open().await; + let images = home.images(); + let (held, unused) = (digest('a'), digest('b')); + home.resolve(&images, &held).await; + let record = home.sandbox_needing(&held).await; + let entry = images + .hold(&record) + .await + .expect("hold") + .expect("the image should be cached"); + home.state.remove_sandbox(&record).await.expect("record removed"); + home.resolve(&images, &unused).await; + std::fs::write(home.path().join("state/sandboxes/unreadable.json"), b"{").expect("unreadable record"); + + assert!(images.remove_unused_at(SystemTime::now() + 2 * DAY, DAY).await.is_err()); + let references = home.references().await; + assert!(references.contains(&entry) && references.contains(&cache_entry(&unused))); + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires Internet access"] + async fn a_sandbox_fetches_an_image_the_cache_lost_by_digest() { + let manifest_digest = match std::env::consts::ARCH { + "x86_64" => "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", + "aarch64" => "sha256:2c9d26f410d032d5b1525aa8a873e238b05b90c4ae8618743d4311f0cc827e37", + _ => return, + }; + let home = Home::open().await; + let provider = provider(home.path()).await; + let mut record = sandbox_record("00000000-0000-4000-8000-000000000003", manifest_digest); + record.image.source = sandbox::image::ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }; + record.image.platform = native_platform(); + home.state.save_sandbox(&record).await.expect("record saved"); + + let entry = provider.hold_image(&record).await.expect("the image should be fetched"); + assert_eq!(entry, sandbox_entry(&record.id)); + assert!(home.references().await.contains(&cache_entry(manifest_digest))); + } + + #[tokio::test(flavor = "local")] + async fn without_a_retention_period_the_cache_is_left_alone() { + let home = Home::open().await; + let image = digest('a'); + home.record_legacy(&format!("example.com/app@{image}"), &image).await; + + ImageCache::new(home.client.clone(), home.state.clone(), None) + .remove_unused() + .await; + assert_eq!(home.references().await.len(), 1); + } + + fn native_platform() -> Platform { + Platform::native("linux") + } + + fn vm_resources(mode: sandbox::RootFilesystemMode) -> SandboxResources { + let capacity = "1Gi".parse::().expect("root filesystem"); + SandboxResources::new( + "1".parse::().expect("CPU"), + "512Mi".parse::().expect("memory"), + match mode { + sandbox::RootFilesystemMode::Direct => RootFilesystem::direct(capacity), + _ => RootFilesystem::layered(capacity), + }, + ) + } + + fn ensure_request(name: &str, reference: &str, mode: sandbox::RootFilesystemMode) -> sandbox::EnsureSandboxRequest { + sandbox::EnsureSandboxRequest::new( + SandboxName::new(name).expect("Sandbox name"), + sandbox::SandboxSpec { + image: sandbox::image::ImageSource::Reference { + reference: reference.to_string(), + }, + platform: native_platform(), + resources: vm_resources(mode), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: sandbox::RetentionPolicy::Retain, + }, + ) + } + + /// Creates a Sandbox without starting it, as when its first start fails. + async fn create_unstarted(provider: &crate::MicrosandboxProvider, id: &str, reference: &str) -> sandbox::Sandbox { + use sandbox::{backend::SandboxBackend as _, provider::SandboxProvider as _}; + + let mode = sandbox::RootFilesystemMode::Direct; + let image = provider + .image_backend() + .resolve(&sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: reference.to_string(), + }, + platform: native_platform(), + root_filesystem_mode: mode, + }) + .await + .expect("image should resolve"); + provider + .create(sandbox::backend::CreateSandboxRequest { + id: id.parse().expect("Sandbox ID"), + image, + name: SandboxName::new("unstarted").expect("Sandbox name"), + hostname: Hostname::new("unstarted").expect("hostname"), + resources: vm_resources(mode), + init_system: sandbox::init::InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + }) + .await + .expect("Sandbox should be created") + } + + /// Lists the cached artifacts in a directory, ignoring lock files. + fn cached_files(directory: &std::path::Path) -> Vec { + std::fs::read_dir(directory).map_or_else( + |_| Vec::new(), + |entries| { + entries + .map(|entry| entry.expect("cache entry should be readable").path()) + .filter(|path| path.extension().is_none_or(|extension| extension != "lock")) + .collect() + }, + ) + } + + async fn provider(home: &std::path::Path) -> std::rc::Rc { + std::rc::Rc::new( + crate::MicrosandboxProvider::builder(home) + .remove_unused_images_after(DAY) + .open() + .await + .expect("Provider should open"), + ) + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires a Microsandbox host runtime, hardware virtualization and registry access"] + async fn sandboxes_keep_their_images_until_they_are_deleted() { + use sandbox::{ + RootFilesystemMode::{Direct, Layered}, + backend::SandboxBackend as _, + }; + + let home = tempfile::tempdir().expect("temporary home should be created"); + let provider = provider(home.path()).await; + let service = sandbox::SandboxService::new(provider.clone()); + let later = || SystemTime::now() + 2 * DAY; + + let stopped = service + .ensure(&ensure_request("stopped", "docker.io/library/alpine:3.21", Layered)) + .await + .expect("layered Sandbox should start") + .snapshot() + .clone(); + provider.stop(&stopped.id).await.expect("Sandbox should stop"); + let unstarted = create_unstarted( + &provider, + "00000000-0000-4000-8000-0000000020d4", + "docker.io/library/alpine:3.20", + ) + .await; + let deleted = service + .ensure(&ensure_request("deleted", "docker.io/library/alpine:3.22", Direct)) + .await + .expect("direct Sandbox should start") + .snapshot() + .clone(); + service.delete(&deleted.name).await.expect("Sandbox should be deleted"); + provider.images().remove_unused_at(later(), DAY).await.expect("pass"); + + let cache = home.path().join("runtime/cache"); + let flat_ref = |manifest_digest: &str| { + cache + .join("flat/refs") + .join(format!("{}.json", manifest_digest.replace(':', "_"))) + }; + assert!( + !flat_ref(&deleted.image.manifest_digest).exists(), + "the deleted Sandbox's image should be removed once unused for the retention period" + ); + assert!(flat_ref(&unstarted.image.manifest_digest).exists()); + + provider + .start(&stopped.id) + .await + .expect("the stopped Sandbox should keep its image and restart"); + provider + .start(&unstarted.id) + .await + .expect("the Sandbox without a runtime should keep its image and start"); + for sandbox in [&stopped, &unstarted] { + service.delete(&sandbox.name).await.expect("Sandbox should be deleted"); + } + provider.images().remove_unused_at(later(), DAY).await.expect("pass"); + for directory in ["flat/blobs", "flat/refs", "layers", "fsmeta", "vmdk"] { + assert_eq!( + cached_files(&cache.join(directory)), + Vec::::new(), + "no image should remain in {directory} once no Sandbox needs one" + ); + } + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires a Microsandbox host runtime, hardware virtualization and registry access"] + async fn migration_prunes_once_every_sandbox_image_is_pinned() { + use sandbox::backend::SandboxBackend as _; + + let home = Home::open().await; + let first = provider(home.path()).await; + let service = sandbox::SandboxService::new(first.clone()); + let sandbox = service + .ensure(&ensure_request( + "running", + "docker.io/library/alpine:3.21", + sandbox::RootFilesystemMode::Layered, + )) + .await + .expect("Sandbox should start") + .snapshot() + .clone(); + drop(service); + drop(first); + // Turn the home back into one from before this release, with a version a moved tag left. + let previous_files = record_moved_tag(&home).await; + std::fs::remove_file(home.state.marker(MIGRATED_MARKER)).expect("marker removed"); + + let reopened = provider(home.path()).await; + assert!( + !previous_files.exists(), + "every Sandbox's image is pinned, so the migration prunes" + ); + assert!(home.state.marker(MIGRATED_MARKER).exists()); + reopened.stop(&sandbox.id).await.expect("Sandbox should stop"); + reopened + .start(&sandbox.id) + .await + .expect("the Sandbox should keep its image and restart"); + reopened.delete(&sandbox.id).await.expect("Sandbox should be deleted"); + } +} diff --git a/sandbox/sandbox-microsandbox/src/lib.rs b/sandbox/sandbox-microsandbox/src/lib.rs new file mode 100644 index 0000000..224b072 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/lib.rs @@ -0,0 +1,30 @@ +//! Microsandbox implementation of the backend-neutral Sandbox SDK. +//! +//! The integration deliberately contains no Agent automation. It uses an +//! explicit Microsandbox home, materializes stopped Sandboxes in adapter-owned +//! state, and boots the underlying microVM only when the generic Backend is +//! started. + +mod backend; +mod client; +mod encoding; +mod error; +mod execution; +mod files; +mod guest_tcp; +mod heartbeat; +mod image; +mod image_cache; +mod network_backend; +mod network_endpoint; +mod platform; +mod state; +mod volumes; + +pub use backend::{MicrosandboxProvider, MicrosandboxProviderBuilder}; + +/// `RUST_LOG` directives that keep the Microsandbox runtime's helper processes quiet at the +/// default level. The runtime's agent client logs every relay connection at INFO. +pub const LOG_DIRECTIVES: &str = "microsandbox_agent_client=warn"; +pub use guest_tcp::{GuestTcpDialer, GuestTcpStream}; +pub use network_backend::{MicrosandboxNetworkBackend, SecretBinding}; diff --git a/sandbox/sandbox-microsandbox/src/network_backend.rs b/sandbox/sandbox-microsandbox/src/network_backend.rs new file mode 100644 index 0000000..4167867 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/network_backend.rs @@ -0,0 +1,1258 @@ +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + future::poll_fn, + num::NonZeroUsize, + rc::Rc, +}; + +use microsandbox_network::control::{ + AuthorizationDecision as RuntimeDecision, ControllerMessage, NETWORK_CONTROL_PROTOCOL, NetworkOperation, + RuntimeMessage, SecretMaterial as RuntimeSecretMaterial, +}; +use microsandbox_network::secrets::config::{ + HostPattern, SecretEntry, SecretSource, SecretSubstitution, SecretViolationAction, SecretsConfig, +}; +use sandbox::{ + Error, LocalFuture, ResourceKind, SandboxId, SandboxName, + network::{ + NetworkBackend, NetworkBackendId, NetworkBatch, NetworkControlEndpointParts, NetworkControlMessage, + NetworkEndpoint, NetworkEndpointSelection, NetworkTransferProgress, StartNetworkRequest, + }, + secret_store::{SecretReference, SecretStore}, +}; +use sandbox_authorization::{ + Action, AuthorizationContext, AuthorizationDecision, AuthorizationRequest, PolicyEngine, Principal, Resource, + vocabulary::{action, context, principal_kind, resource_kind}, +}; +use tokio::{sync::mpsc, task::JoinHandle}; +use zeroize::Zeroizing; + +const BACKEND_ID: &str = "microsandbox"; +const RECEIVE_BATCH_SIZE: usize = 16; + +/// Microsandbox Network Backend using the trusted runtime's protocol engine. +pub struct MicrosandboxNetworkBackend { + policy: Rc, + secret_store: Option>, + secret_bindings: RefCell>>, + drivers: RefCell>, +} + +/// Host-owned mapping from a non-secret placeholder to current secret material. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecretBinding { + environment: String, + placeholder: String, + reference: SecretReference, +} + +struct Driver { + commands: mpsc::Sender, + task: JoinHandle<()>, +} + +#[derive(Clone, Copy)] +struct SandboxSubject<'a> { + id: &'a SandboxId, + name: &'a SandboxName, +} + +enum DriverCommand { + RevokeAll, +} + +impl MicrosandboxNetworkBackend { + /// Creates a Network Backend that evaluates every runtime request with `policy`. + #[must_use] + pub fn new(policy: Rc) -> Self { + Self { + policy, + secret_store: None, + secret_bindings: RefCell::new(HashMap::new()), + drivers: RefCell::new(HashMap::new()), + } + } + + /// Configures the host Secret Store used by mediated requests. + #[must_use] + pub fn with_secret_store(mut self, store: Rc) -> Self { + self.secret_store = Some(store); + self + } + + /// Replaces the secret bindings applied when this named Sandbox's Network starts. + /// + /// Returns whether the binding definition changed. Existing connections + /// keep their handshake configuration until the caller restarts the Network. + /// Secret material itself is resolved from the store for every authorized + /// use, so rotating a value behind an unchanged binding needs no restart. + /// + /// # Errors + /// + /// Returns an error when a binding is empty, duplicated, ambiguous, or + /// unsafe for the Microsandbox control protocol. + pub fn set_secret_bindings(&self, sandbox_name: SandboxName, bindings: Vec) -> Result { + validate_secret_bindings(&bindings)?; + let changed = self.secret_bindings.borrow().get(&sandbox_name) != Some(&bindings); + self.secret_bindings.borrow_mut().insert(sandbox_name, bindings); + Ok(changed) + } + + /// Removes the configured bindings for a named Sandbox. + pub fn remove_secret_bindings(&self, sandbox_name: &SandboxName) { + self.secret_bindings.borrow_mut().remove(sandbox_name); + } + + /// Revokes all currently allowed flows for one running Sandbox. + /// + /// New operations continue to use the current Policy Engine state. + /// + /// # Errors + /// + /// Returns [`Error::NotFound`] when this process does not drive the + /// Sandbox's Network endpoint. + pub async fn revoke_all(&self, sandbox_id: &SandboxId) -> Result<(), Error> { + let commands = self + .drivers + .borrow() + .get(sandbox_id) + .filter(|driver| !driver.task.is_finished()) + .map(|driver| driver.commands.clone()) + .ok_or_else(|| Error::not_found(ResourceKind::Network, sandbox_id))?; + commands + .send(DriverCommand::RevokeAll) + .await + .map_err(|_| Error::Backend("Microsandbox Network controller stopped".into())) + } + + async fn stop_driver(&self, sandbox_id: &SandboxId) { + let driver = self.drivers.borrow_mut().remove(sandbox_id); + if let Some(driver) = driver { + driver.task.abort(); + let _ = driver.task.await; + } + } +} + +impl NetworkBackend for MicrosandboxNetworkBackend { + fn id(&self) -> NetworkBackendId { + NetworkBackendId::new(BACKEND_ID) + } + + fn is_running(&self, sandbox_id: &SandboxId) -> bool { + self.drivers + .borrow() + .get(sandbox_id) + .is_some_and(|driver| !driver.task.is_finished()) + } + + fn select_endpoint( + &self, + available: &sandbox::network::NetworkEndpointCapabilities, + ) -> Option { + let selection = NetworkEndpointSelection::Control(sandbox::network::NetworkControlProtocolId::new( + NETWORK_CONTROL_PROTOCOL, + )); + available.supports(&selection).then_some(selection) + } + + fn start(&self, request: StartNetworkRequest) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + if self.is_running(&request.sandbox_id) { + return Ok(()); + } + self.stop_driver(&request.sandbox_id).await; + let NetworkEndpoint::Control(endpoint) = request.endpoint else { + return Err(Error::UnsupportedNetworkEndpoint(request.endpoint.selection())); + }; + if endpoint.properties().protocol().as_str() != NETWORK_CONTROL_PROTOCOL { + return Err(Error::UnsupportedNetworkEndpoint(NetworkEndpointSelection::Control( + endpoint.properties().protocol().clone(), + ))); + } + let (commands, command_rx) = mpsc::channel(16); + let policy = self.policy.clone(); + let secret_store = self.secret_store.clone(); + let secret_bindings = self + .secret_bindings + .borrow() + .get(&request.sandbox_name) + .cloned() + .unwrap_or_default(); + let sandbox_id = request.sandbox_id.clone(); + let log_sandbox_id = sandbox_id.clone(); + let log_sandbox_name = request.sandbox_name.clone(); + let task = tokio::task::spawn_local(async move { + if let Err(error) = drive( + sandbox_id, + request.sandbox_name, + endpoint.into_parts(), + policy, + secret_store, + secret_bindings, + command_rx, + ) + .await + { + tracing::warn!( + %error, + sandbox = %log_sandbox_id, + sandbox_name = %log_sandbox_name, + "Microsandbox Network data plane stopped" + ); + } + }); + self.drivers + .borrow_mut() + .insert(request.sandbox_id, Driver { commands, task }); + Ok(()) + }) + } + + fn stop<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.stop_driver(sandbox_id).await; + Ok(()) + }) + } + + fn delete<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + self.stop(sandbox_id) + } +} + +impl SecretBinding { + /// Creates a host-owned secret binding. + /// + /// # Errors + /// + /// Returns an error when the environment-variable name is invalid. + pub fn new(environment: impl Into, reference: SecretReference) -> Result { + let environment = environment.into(); + let placeholder = format!("$MSB_{environment}"); + Self::with_placeholder(environment, placeholder, reference) + } + + /// Creates a host-owned secret binding with a caller-selected placeholder. + /// + /// # Errors + /// + /// Returns an error when the environment-variable name or placeholder is invalid. + pub fn with_placeholder( + environment: impl Into, + placeholder: impl Into, + reference: SecretReference, + ) -> Result { + let binding = Self { + environment: environment.into(), + placeholder: placeholder.into(), + reference, + }; + if !valid_secret_binding(&binding) { + return Err(Error::invalid( + "secretBinding", + "environment-variable name or placeholder is invalid", + )); + } + Ok(binding) + } + + /// Returns the non-secret environment assignment exposed inside the Sandbox. + #[must_use] + pub fn guest_environment(&self) -> (&str, &str) { + (&self.environment, &self.placeholder) + } + + fn runtime_entry(&self) -> SecretEntry { + SecretEntry { + env_var: self.environment.clone(), + value: Zeroizing::new(String::new()), + source: Some(SecretSource::Store { + reference: self.environment.clone(), + }), + placeholder: self.placeholder.clone(), + // The host controller decides per request where the secret is + // substituted (`SecretUse`), so every host is eligible here. + allowed_hosts: vec![HostPattern::Any], + substitution: SecretSubstitution::default(), + // The placeholder is inert text that also reaches requests the + // secret is not substituted into, such as a conversation history + // sent in a model request body. Forwarding it unchanged exposes no + // secret; blocking it would fail every later request in the session. + passthrough_hosts: vec![HostPattern::Any], + violation_action: Some(SecretViolationAction::Block), + require_tls_identity: true, + } + } +} + +async fn drive( + sandbox_id: SandboxId, + sandbox_name: SandboxName, + mut endpoint: NetworkControlEndpointParts, + policy: Rc, + secret_store: Option>, + secret_bindings: Vec, + mut commands: mpsc::Receiver, +) -> Result<(), Error> { + let batch_size = NonZeroUsize::new(RECEIVE_BATCH_SIZE) + .ok_or_else(|| Error::Backend("invalid Microsandbox Network receive batch size".into()))?; + let mut received = NetworkBatch::new(batch_size); + let mut state = DriverState::default(); + + loop { + tokio::select! { + progress = poll_fn(|context| endpoint.from_sandbox.as_mut().poll_receive(context, &mut received)) => { + match progress.map_err(|error| Error::Backend(error.to_string()))? { + NetworkTransferProgress::Items(_) => { + while let Some(message) = received.pop_front() { + let runtime_message = serde_json::from_slice(message.as_bytes()).map_err(protocol_error)?; + let response = handle_runtime_message( + SandboxSubject { + id: &sandbox_id, + name: &sandbox_name, + }, + runtime_message, + policy.as_ref(), + secret_store.as_deref(), + &secret_bindings, + &mut state, + ).await?; + if let Some(response) = response { + send(&mut endpoint, response).await?; + } + } + } + NetworkTransferProgress::Closed => return Ok(()), + } + } + command = commands.recv() => { + let Some(DriverCommand::RevokeAll) = command else { + return Ok(()); + }; + for flow_id in state.flows.drain() { + send(&mut endpoint, ControllerMessage::Revoke { flow_id }).await?; + } + } + } + } +} + +async fn handle_runtime_message( + subject: SandboxSubject<'_>, + message: RuntimeMessage, + policy: &dyn PolicyEngine, + secret_store: Option<&dyn SecretStore>, + secret_bindings: &[SecretBinding], + state: &mut DriverState, +) -> Result, Error> { + match message { + RuntimeMessage::Hello { protocol } if protocol == NETWORK_CONTROL_PROTOCOL => { + state.handshake_complete = true; + state.flows.clear(); + Ok(Some(ControllerMessage::HelloAccepted { + protocol, + secrets: SecretsConfig { + secrets: secret_bindings.iter().map(SecretBinding::runtime_entry).collect(), + violation_action: SecretViolationAction::Block, + passthrough_hosts: None, + }, + })) + } + RuntimeMessage::Hello { .. } => Err(Error::Backend("invalid Microsandbox Network control handshake".into())), + RuntimeMessage::AuthorizationRequest { + request_id, + flow_id, + operation, + } if state.handshake_complete => { + let authorization = authorize_operation(subject, &operation, policy, secret_store, secret_bindings).await; + if authorization.is_none() { + log_authorization_denial(subject, &operation, &mut state.denials); + } + let (decision, secret_material) = authorization.map_or_else( + || (RuntimeDecision::Deny, None), + |secret_material| { + state.flows.insert(flow_id); + (RuntimeDecision::Allow, secret_material) + }, + ); + Ok(Some(ControllerMessage::AuthorizationDecision { + request_id, + decision, + secret_material, + })) + } + RuntimeMessage::FlowClosed { flow_id } if state.handshake_complete => { + state.flows.remove(&flow_id); + Ok(None) + } + RuntimeMessage::AuthorizationRequest { .. } | RuntimeMessage::FlowClosed { .. } => Err(Error::Backend( + "Microsandbox Network request arrived before the protocol handshake".into(), + )), + } +} + +/// Mutable state of one Network driver: the handshake, live flows, and denial logging. +#[derive(Default)] +struct DriverState { + handshake_complete: bool, + flows: HashSet, + denials: DenialLog, +} + +/// Per-driver bound on denial logging. +/// +/// A Sandbox can be denied once per request it makes, so unbounded logging would +/// let it grow the host log at will. Each distinct `(action, hostname)` is logged +/// on its first denial and then once per [`DenialLog::REPEAT_EVERY`] repeats. A +/// Sandbox rotating hostnames would still log one line per new key, so the total +/// number of lines per driver is capped at [`DenialLog::MAX_LINES`]; beyond it only +/// every [`DenialLog::SUMMARY_EVERY`]th denial is logged, with the running total. +#[derive(Default)] +struct DenialLog { + counts: HashMap<(&'static str, Option), u64>, + total: u64, + lines: u64, +} + +impl DenialLog { + const REPEAT_EVERY: u64 = 100; + const MAX_KEYS: usize = 1_024; + const MAX_LINES: u64 = 1_000; + const SUMMARY_EVERY: u64 = 10_000; + + /// Records one denial and returns the count to log when a line is warranted. + fn record(&mut self, action: &'static str, hostname: Option<&str>) -> Option { + self.total += 1; + if self.lines >= Self::MAX_LINES { + return self.total.is_multiple_of(Self::SUMMARY_EVERY).then_some(self.total); + } + let key = (action, hostname.map(str::to_owned)); + if !self.counts.contains_key(&key) && self.counts.len() >= Self::MAX_KEYS { + self.counts.clear(); + } + let count = self.counts.entry(key).or_insert(0); + *count += 1; + let log = *count == 1 || count.is_multiple_of(Self::REPEAT_EVERY); + if log { + self.lines += 1; + } + log.then_some(*count) + } +} + +fn log_authorization_denial(subject: SandboxSubject<'_>, operation: &NetworkOperation, denials: &mut DenialLog) { + let (action, hostname, destination) = operation_log_fields(operation); + if let Some(denied) = denials.record(action, hostname) { + tracing::warn!( + action, + hostname, + destination = %destination, + sandbox = %subject.id, + sandbox_name = %subject.name, + denied, + "Microsandbox Network authorization denied" + ); + } +} + +fn operation_log_fields(operation: &NetworkOperation) -> (&'static str, Option<&str>, String) { + match operation { + NetworkOperation::Connect { + destination, hostname, .. + } => (action::NETWORK_CONNECT, hostname.as_deref(), destination.to_string()), + NetworkOperation::DnsQuery { name, resolver, .. } => ( + action::DNS_QUERY, + Some(name), + resolver.as_ref().map_or_else(|| "none".into(), ToString::to_string), + ), + NetworkOperation::HttpRequest { + destination, authority, .. + } => (action::HTTP_REQUEST, Some(authority), destination.to_string()), + NetworkOperation::SecretUse { + destination, authority, .. + } => (action::SECRET_USE, Some(authority), destination.to_string()), + } +} + +async fn authorize_operation( + subject: SandboxSubject<'_>, + operation: &NetworkOperation, + policy: &dyn PolicyEngine, + secret_store: Option<&dyn SecretStore>, + bindings: &[SecretBinding], +) -> Option> { + if let NetworkOperation::SecretUse { secret, locations, .. } = operation + && (locations.is_empty() + || locations.iter().collect::>().len() != locations.len() + || !bindings.iter().any(|binding| binding.environment == *secret)) + { + return None; + } + if !matches!( + policy.evaluate(authorization_request(subject, operation)).await, + Ok(AuthorizationDecision::Allow) + ) { + return None; + } + let NetworkOperation::SecretUse { secret, .. } = operation else { + return Some(None); + }; + let binding = bindings.iter().find(|binding| binding.environment == *secret)?; + let store = secret_store?; + let resolved = store.resolve(&binding.reference).await.ok()?; + let value = std::str::from_utf8(resolved.expose()).ok()?; + if value.is_empty() || value.bytes().any(|byte| matches!(byte, 0 | b'\r' | b'\n')) { + return None; + } + Some(Some(RuntimeSecretMaterial::new(value.to_owned()))) +} + +fn authorization_request(subject: SandboxSubject<'_>, operation: &NetworkOperation) -> AuthorizationRequest { + let principal = Principal::new(principal_kind::SANDBOX, subject.id.to_string()); + match operation { + NetworkOperation::Connect { + source, + destination, + transport, + hostname, + destination_is_host, + } => { + let mut authorization_context = sandbox_context(subject.name) + .with_attribute(context::NETWORK_DESTINATION_ADDRESS, destination.to_string()) + .with_attribute(context::NETWORK_DESTINATION_IS_HOST, *destination_is_host) + .with_attribute(context::NETWORK_TRANSPORT, transport_name(*transport)); + if let Some(source) = source { + authorization_context.insert(context::NETWORK_SOURCE_ADDRESS, source.to_string()); + } + let resource_id = hostname.as_ref().map_or_else( + || destination.to_string(), + |hostname| format!("{hostname}:{}", destination.port()), + ); + if let Some(hostname) = hostname { + authorization_context.insert(context::NETWORK_HOSTNAME, hostname.clone()); + } + AuthorizationRequest { + principal, + action: Action::new(action::NETWORK_CONNECT), + resource: Resource::new(resource_kind::EXTERNAL_SERVICE, resource_id), + context: authorization_context, + } + } + NetworkOperation::DnsQuery { + name, + record_type, + resolver, + transport, + } => { + let mut authorization_context = sandbox_context(subject.name) + .with_attribute(context::DNS_RECORD_TYPE, record_type.clone()) + .with_attribute(context::NETWORK_TRANSPORT, transport_name(*transport)); + if let Some(resolver) = resolver { + authorization_context.insert(context::DNS_RESOLVER, resolver.to_string()); + } + AuthorizationRequest { + principal, + action: Action::new(action::DNS_QUERY), + resource: Resource::new(resource_kind::DOMAIN, name.clone()), + context: authorization_context, + } + } + NetworkOperation::HttpRequest { + destination, + scheme, + authority, + method, + path, + version, + stream_id, + } => AuthorizationRequest { + principal, + action: Action::new(action::HTTP_REQUEST), + resource: Resource::new(resource_kind::EXTERNAL_SERVICE, authority.clone()), + context: http_context(*destination, *scheme, authority, method, path, *version, *stream_id) + .with_attribute(context::SANDBOX_NAME, subject.name.as_str()), + }, + NetworkOperation::SecretUse { + destination, + scheme, + authority, + method, + path, + version, + stream_id, + secret, + locations, + } => { + let mut authorization_context = + http_context(*destination, *scheme, authority, method, path, *version, *stream_id) + .with_attribute(context::SANDBOX_NAME, subject.name.as_str()); + authorization_context.insert( + context::SECRET_LOCATIONS, + locations + .iter() + .map(|location| secret_location_name(*location)) + .map(str::to_string) + .collect::>(), + ); + AuthorizationRequest { + principal, + action: Action::new(action::SECRET_USE), + resource: Resource::new(resource_kind::SECRET, secret.clone()), + context: authorization_context, + } + } + } +} + +fn http_context( + destination: std::net::SocketAddr, + scheme: microsandbox_network::control::HttpScheme, + authority: &str, + method: &str, + path: &str, + version: microsandbox_network::control::HttpVersion, + stream_id: Option, +) -> AuthorizationContext { + let mut authorization_context = AuthorizationContext::new() + .with_attribute(context::NETWORK_DESTINATION_ADDRESS, destination.to_string()) + .with_attribute(context::HTTP_SCHEME, http_scheme_name(scheme)) + .with_attribute(context::HTTP_AUTHORITY, authority) + .with_attribute(context::HTTP_METHOD, method) + .with_attribute(context::HTTP_PATH, path) + .with_attribute(context::HTTP_VERSION, http_version_name(version)); + if let Some(stream_id) = stream_id { + authorization_context.insert(context::HTTP_STREAM_ID, stream_id); + } + authorization_context +} + +fn sandbox_context(sandbox_name: &SandboxName) -> AuthorizationContext { + AuthorizationContext::new().with_attribute(context::SANDBOX_NAME, sandbox_name.as_str()) +} + +fn validate_secret_bindings(bindings: &[SecretBinding]) -> Result<(), Error> { + if bindings.iter().any(|binding| !valid_secret_binding(binding)) { + return Err(Error::invalid("secretBindings", "contains an invalid binding")); + } + let environments = bindings + .iter() + .map(|binding| &binding.environment) + .collect::>(); + let placeholders = bindings + .iter() + .map(|binding| &binding.placeholder) + .collect::>(); + let unambiguous = bindings.iter().enumerate().all(|(index, binding)| { + bindings + .iter() + .enumerate() + .all(|(other_index, other)| index == other_index || !binding.placeholder.contains(&other.placeholder)) + }); + if environments.len() != bindings.len() || placeholders.len() != bindings.len() || !unambiguous { + return Err(Error::invalid( + "secretBindings", + "environment variables and placeholders must be unique and placeholders must be unambiguous", + )); + } + Ok(()) +} + +fn valid_secret_binding(binding: &SecretBinding) -> bool { + valid_environment_variable(&binding.environment) + && !binding.placeholder.is_empty() + && binding.placeholder.len() <= microsandbox_network::secrets::config::MAX_SECRET_PLACEHOLDER_BYTES + && !binding + .placeholder + .bytes() + .any(|byte| matches!(byte, 0 | b'\r' | b'\n')) +} + +fn valid_environment_variable(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +const fn http_scheme_name(scheme: microsandbox_network::control::HttpScheme) -> &'static str { + match scheme { + microsandbox_network::control::HttpScheme::Http => "http", + microsandbox_network::control::HttpScheme::Https => "https", + } +} + +const fn http_version_name(version: microsandbox_network::control::HttpVersion) -> &'static str { + match version { + microsandbox_network::control::HttpVersion::Http1 => "http1", + microsandbox_network::control::HttpVersion::Http2 => "http2", + } +} + +const fn secret_location_name(location: microsandbox_network::control::SecretLocation) -> &'static str { + match location { + microsandbox_network::control::SecretLocation::Header => "header", + microsandbox_network::control::SecretLocation::BasicAuth => "basicAuth", + microsandbox_network::control::SecretLocation::Query => "query", + } +} + +const fn transport_name(transport: microsandbox_network::control::TransportProtocol) -> &'static str { + match transport { + microsandbox_network::control::TransportProtocol::Tcp => "tcp", + microsandbox_network::control::TransportProtocol::Udp => "udp", + microsandbox_network::control::TransportProtocol::Icmpv4 => "icmpv4", + microsandbox_network::control::TransportProtocol::Icmpv6 => "icmpv6", + } +} + +async fn send(endpoint: &mut NetworkControlEndpointParts, response: ControllerMessage) -> Result<(), Error> { + let bytes = Zeroizing::new(serde_json::to_vec(&response).map_err(protocol_error)?); + let one = NonZeroUsize::new(1).ok_or_else(|| Error::Backend("invalid Network control send batch size".into()))?; + let mut pending = NetworkBatch::new(one); + pending + .push_back(NetworkControlMessage::from(bytes)) + .map_err(|_| Error::Backend("failed to queue Microsandbox Network response".into()))?; + match poll_fn(|context| endpoint.to_sandbox.as_mut().poll_send(context, &mut pending)) + .await + .map_err(|error| Error::Backend(error.to_string()))? + { + NetworkTransferProgress::Items(_) if pending.is_empty() => Ok(()), + NetworkTransferProgress::Items(_) => Err(Error::Backend( + "Microsandbox Network response was only partially accepted".into(), + )), + NetworkTransferProgress::Closed => { + Err(Error::Backend("Microsandbox Network control endpoint is closed".into())) + } + } +} + +fn protocol_error(error: impl std::fmt::Display) -> Error { + Error::Backend(format!("invalid Microsandbox Network control message: {error}")) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::time::Duration; + + #[test] + fn denial_logging_is_bounded_per_key() { + let mut denials = super::DenialLog::default(); + assert_eq!(denials.record("network.connect", Some("example.com")), Some(1)); + for _ in 1..super::DenialLog::REPEAT_EVERY - 1 { + assert_eq!(denials.record("network.connect", Some("example.com")), None); + } + assert_eq!( + denials.record("network.connect", Some("example.com")), + Some(super::DenialLog::REPEAT_EVERY) + ); + assert_eq!( + denials.record("network.connect", None), + Some(1), + "a new key logs immediately" + ); + + let mut rotating = super::DenialLog::default(); + let hostnames: Vec = (0..super::DenialLog::MAX_LINES + 50) + .map(|i| format!("h{i}.example")) + .collect(); + let logged = hostnames + .iter() + .filter(|hostname| rotating.record("dns.query", Some(hostname)).is_some()) + .count(); + assert_eq!( + u64::try_from(logged).expect("count"), + super::DenialLog::MAX_LINES, + "rotating hostnames hit the line cap" + ); + } + use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + rc::Rc, + }; + + use microsandbox_network::control::{ + AuthorizationError, HttpScheme, HttpVersion, NetworkControlClient, NetworkOperation, TransportProtocol, + }; + use sandbox::{ + LocalFuture, SandboxName, + memory::MemorySecretStore, + network::{NetworkBackend as _, NetworkEndpoint, StartNetworkRequest}, + secret_store::{SecretMaterial, SecretReference, SecretStore}, + }; + use sandbox_authorization::{AuthorizationDecision, AuthorizationRequest, PolicyEngine, StaticPolicy}; + + use super::{MicrosandboxNetworkBackend, SecretBinding}; + + struct TestControlEndpoint { + #[cfg(unix)] + _directory: tempfile::TempDir, + path: std::path::PathBuf, + } + + impl TestControlEndpoint { + fn new() -> Self { + #[cfg(unix)] + { + let directory = tempfile::tempdir().expect("temporary endpoint directory"); + let path = directory.path().join("network.sock"); + Self { + _directory: directory, + path, + } + } + + #[cfg(windows)] + { + Self { + path: format!(r"\\.\pipe\agent-network-test-{}", uuid::Uuid::new_v4()).into(), + } + } + } + } + + struct RecordingPolicy { + decisions: RefCell>, + requests: RefCell>, + } + + struct RecordingSecretStore { + inner: MemorySecretStore, + resolutions: Cell, + } + + impl RecordingPolicy { + fn allow_all() -> Self { + Self { + decisions: RefCell::new(BTreeMap::new()), + requests: RefCell::new(Vec::new()), + } + } + + fn decide(&self, action: &str, decision: AuthorizationDecision) { + self.decisions.borrow_mut().insert(action.to_string(), decision); + } + } + + impl PolicyEngine for RecordingPolicy { + fn evaluate( + &self, + request: AuthorizationRequest, + ) -> sandbox_authorization::LocalFuture<'_, Result> + { + self.requests.borrow_mut().push(request.clone()); + let decision = self + .decisions + .borrow() + .get(request.action.as_str()) + .copied() + .unwrap_or(AuthorizationDecision::Allow); + Box::pin(async move { Ok(decision) }) + } + } + + impl RecordingSecretStore { + fn new() -> Self { + Self { + inner: MemorySecretStore::default(), + resolutions: Cell::new(0), + } + } + } + + impl SecretStore for RecordingSecretStore { + fn set<'a>( + &'a self, + name: &'a str, + value: &'a [u8], + ) -> LocalFuture<'a, Result> { + self.inner.set(name, value) + } + + fn resolve<'a>( + &'a self, + reference: &'a SecretReference, + ) -> LocalFuture<'a, Result> { + self.resolutions.set(self.resolutions.get() + 1); + self.inner.resolve(reference) + } + } + + fn sandbox_id() -> sandbox::SandboxId { + "d727d8a4-1345-4b31-b99d-59e04c9e476c" + .parse() + .expect("valid test Sandbox ID") + } + + fn sandbox_name() -> SandboxName { + SandboxName::new("worker").expect("valid test Sandbox name") + } + + fn operation() -> NetworkOperation { + NetworkOperation::Connect { + source: Some("192.0.2.2:40000".parse().expect("valid test source")), + destination: "198.51.100.10:443".parse().expect("valid test destination"), + transport: TransportProtocol::Tcp, + hostname: Some("example.com".to_string()), + destination_is_host: false, + } + } + + fn dns_operation() -> NetworkOperation { + NetworkOperation::DnsQuery { + name: "example.com".to_string(), + record_type: "A".to_string(), + resolver: Some("192.0.2.53:53".parse().expect("valid test resolver")), + transport: TransportProtocol::Udp, + } + } + + fn http_operation() -> NetworkOperation { + NetworkOperation::HttpRequest { + destination: "198.51.100.10:443".parse().expect("valid test destination"), + scheme: HttpScheme::Https, + authority: "example.com".to_string(), + method: "POST".to_string(), + path: "/items".to_string(), + version: HttpVersion::Http2, + stream_id: Some(3), + } + } + + fn secret_use_operation() -> NetworkOperation { + NetworkOperation::SecretUse { + destination: "198.51.100.10:443".parse().expect("valid test destination"), + scheme: HttpScheme::Https, + authority: "example.com".to_string(), + method: "POST".to_string(), + path: "/items".to_string(), + version: HttpVersion::Http1, + stream_id: None, + secret: "PROVIDER_TOKEN".to_string(), + locations: vec![microsandbox_network::control::SecretLocation::Header], + } + } + + #[test] + fn maps_http_request_to_authorization_contract() { + let sandbox_id = sandbox_id(); + let sandbox_name = sandbox_name(); + let request = super::authorization_request( + super::SandboxSubject { + id: &sandbox_id, + name: &sandbox_name, + }, + &http_operation(), + ); + + assert_eq!(request.action.as_str(), "http.request"); + assert_eq!(request.resource.kind, "externalService"); + assert_eq!(request.resource.id, "example.com"); + assert_eq!( + request.context.attributes["network.destinationAddress"].as_str(), + Some("198.51.100.10:443") + ); + assert_eq!(request.context.attributes["http.scheme"].as_str(), Some("https")); + assert_eq!(request.context.attributes["http.method"].as_str(), Some("POST")); + assert_eq!(request.context.attributes["http.path"].as_str(), Some("/items")); + assert_eq!(request.context.attributes["http.version"].as_str(), Some("http2")); + assert_eq!(request.context.attributes["http.streamId"].as_integer(), Some(3)); + } + + #[test] + fn secret_bindings_are_scoped_by_sandbox_name() { + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::allow_all())); + let first = SandboxName::new("first").expect("valid Sandbox name"); + let second = SandboxName::new("second").expect("valid Sandbox name"); + backend + .set_secret_bindings( + first.clone(), + vec![ + SecretBinding::with_placeholder( + "FIRST_TOKEN", + "$FIRST_TOKEN", + SecretReference::from_opaque("first"), + ) + .expect("valid first binding"), + ], + ) + .expect("configure first Sandbox"); + backend + .set_secret_bindings( + second.clone(), + vec![ + SecretBinding::with_placeholder( + "SECOND_TOKEN", + "$SECOND_TOKEN", + SecretReference::from_opaque("second"), + ) + .expect("valid second binding"), + ], + ) + .expect("configure second Sandbox"); + + let bindings = backend.secret_bindings.borrow(); + assert_eq!(bindings[&first][0].environment, "FIRST_TOKEN"); + assert_eq!(bindings[&second][0].environment, "SECOND_TOKEN"); + } + + #[test] + fn default_secret_binding_exposes_an_inert_environment_placeholder() { + let binding = + SecretBinding::new("API_TOKEN", SecretReference::from_opaque("stored-token")).expect("valid binding"); + let runtime = binding.runtime_entry(); + + assert_eq!(runtime.env_var, "API_TOKEN"); + assert_eq!(runtime.placeholder, "$MSB_API_TOKEN"); + } + + #[test] + fn secret_binding_rejects_an_invalid_environment_variable() { + let error = SecretBinding::new("NOT-AN-ENV", SecretReference::from_opaque("stored-token")) + .expect_err("invalid environment-variable names must fail closed"); + + assert!(error.to_string().contains("environment-variable name")); + } + + #[test] + fn replacing_secret_bindings_reports_definition_changes() { + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::allow_all())); + let sandbox = sandbox_name(); + let binding = || SecretBinding::new("TOKEN", SecretReference::from_opaque("token")).expect("valid binding"); + + assert!( + backend + .set_secret_bindings(sandbox.clone(), vec![binding()]) + .expect("initial binding") + ); + assert!( + !backend + .set_secret_bindings(sandbox.clone(), vec![binding()]) + .expect("unchanged binding") + ); + assert!( + backend + .set_secret_bindings(sandbox, Vec::new()) + .expect("removed binding") + ); + } + + #[tokio::test(flavor = "local")] + async fn controller_allows_and_revokes_a_live_flow() { + let control = TestControlEndpoint::new(); + let path = control.path.clone(); + let controller = microsandbox_network::control::NetworkControlHost::bind(path.clone()) + .await + .expect("bind Network control endpoint"); + let endpoint = crate::network_endpoint::open(controller).expect("open Network control endpoint"); + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::allow_all())); + let sandbox_id = sandbox_id(); + backend + .start(StartNetworkRequest { + sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name(), + endpoint: NetworkEndpoint::Control(endpoint), + }) + .await + .expect("start Network Backend"); + let client = NetworkControlClient::new(path.clone(), &tokio::runtime::Handle::current()); + + let mut grant = client + .authorize(operation()) + .await + .expect("operation should be allowed"); + let dns_grant = client + .authorize(dns_operation()) + .await + .expect("DNS query should be allowed"); + let http_grant = client + .authorize(http_operation()) + .await + .expect("HTTP request should be allowed"); + backend.revoke_all(&sandbox_id).await.expect("revoke live flows"); + tokio::time::timeout(Duration::from_secs(1), grant.revoked()) + .await + .expect("revocation should reach runtime client"); + + drop(grant); + drop(dns_grant); + drop(http_grant); + drop(client); + let reconnected = NetworkControlClient::new(path, &tokio::runtime::Handle::current()); + let _grant = reconnected + .authorize(operation()) + .await + .expect("a restarted runtime should establish a new control session"); + + backend.stop(&sandbox_id).await.expect("stop Network Backend"); + } + + #[tokio::test(flavor = "local")] + async fn controller_fails_closed_on_policy_denial_and_disconnect() { + let control = TestControlEndpoint::new(); + let path = control.path.clone(); + let controller = microsandbox_network::control::NetworkControlHost::bind(path.clone()) + .await + .expect("bind Network control endpoint"); + let endpoint = crate::network_endpoint::open(controller).expect("open Network control endpoint"); + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::deny_all())); + let sandbox_id = sandbox_id(); + backend + .start(StartNetworkRequest { + sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name(), + endpoint: NetworkEndpoint::Control(endpoint), + }) + .await + .expect("start Network Backend"); + let client = NetworkControlClient::new(path, &tokio::runtime::Handle::current()); + + let error = client + .authorize(operation()) + .await + .err() + .expect("deny-all policy should refuse the operation"); + assert!(matches!(error, AuthorizationError::Denied)); + backend.stop(&sandbox_id).await.expect("stop Network Backend"); + let error = client + .authorize(operation()) + .await + .err() + .expect("stopped controller should fail closed"); + assert!(matches!( + error, + AuthorizationError::Denied | AuthorizationError::Unavailable + )); + } + + #[tokio::test(flavor = "local")] + async fn controller_resolves_secrets_after_both_authorizations_and_observes_rotation() { + let control = TestControlEndpoint::new(); + let path = control.path.clone(); + let controller = microsandbox_network::control::NetworkControlHost::bind(path.clone()) + .await + .expect("bind Network control endpoint"); + let endpoint = crate::network_endpoint::open(controller).expect("open Network control endpoint"); + let policy = Rc::new(RecordingPolicy::allow_all()); + let store = Rc::new(RecordingSecretStore::new()); + let reference = store + .set("provider-token", b"first") + .await + .expect("store initial secret"); + let backend = MicrosandboxNetworkBackend::new(policy.clone()).with_secret_store(store.clone()); + backend + .set_secret_bindings( + sandbox_name(), + vec![SecretBinding::with_placeholder("PROVIDER_TOKEN", "$TOKEN", reference).expect("valid binding")], + ) + .expect("configure secret mediation"); + let sandbox_id = sandbox_id(); + backend + .start(StartNetworkRequest { + sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name(), + endpoint: NetworkEndpoint::Control(endpoint), + }) + .await + .expect("start Network Backend"); + let client = NetworkControlClient::new(path, &tokio::runtime::Handle::current()); + + let http_grant = client + .authorize(NetworkOperation::HttpRequest { + destination: "198.51.100.10:443".parse().expect("valid test destination"), + scheme: HttpScheme::Https, + authority: "example.com".to_string(), + method: "POST".to_string(), + path: "/items".to_string(), + version: HttpVersion::Http1, + stream_id: None, + }) + .await + .expect("HTTP request authorization"); + drop(http_grant); + let material = client + .authorize_secret_use(secret_use_operation()) + .await + .expect("authorized secret use"); + assert_eq!(material.expose(), "first"); + assert_eq!(store.resolutions.get(), 1); + { + let requests = policy.requests.borrow(); + assert_eq!(requests[0].action.as_str(), "http.request"); + assert_eq!(requests[1].action.as_str(), "secret.use"); + assert_eq!(requests[1].resource.kind, "secret"); + assert_eq!(requests[1].resource.id, "PROVIDER_TOKEN"); + assert_eq!( + requests[1].context.attributes["http.authority"].as_str(), + Some("example.com") + ); + assert_eq!( + requests[1].context.attributes["secret.locations"] + .as_strings() + .expect("locations should be a list"), + ["header"] + ); + } + + store.set("provider-token", b"second").await.expect("rotate secret"); + let material = client + .authorize_secret_use(secret_use_operation()) + .await + .expect("authorized request after rotation"); + assert_eq!(material.expose(), "second"); + assert_eq!(store.resolutions.get(), 2); + + policy.decide("secret.use", AuthorizationDecision::Deny); + assert!(matches!( + client.authorize_secret_use(secret_use_operation()).await, + Err(AuthorizationError::Denied) + )); + assert_eq!(store.resolutions.get(), 2); + + policy.decide("secret.use", AuthorizationDecision::Allow); + policy.decide("http.request", AuthorizationDecision::Deny); + assert!(matches!( + client.authorize(http_operation()).await, + Err(AuthorizationError::Denied) + )); + assert_eq!(store.resolutions.get(), 2); + + backend.stop(&sandbox_id).await.expect("stop Network Backend"); + } + + #[tokio::test(flavor = "local")] + async fn unknown_bindings_and_secret_store_failures_are_denied() { + let policy = StaticPolicy::allow_all(); + let store = MemorySecretStore::default(); + let sandbox_id = sandbox_id(); + let sandbox_name = sandbox_name(); + let subject = super::SandboxSubject { + id: &sandbox_id, + name: &sandbox_name, + }; + let missing = + SecretBinding::with_placeholder("PROVIDER_TOKEN", "$TOKEN", SecretReference::from_opaque("missing")) + .expect("valid binding"); + + assert!( + super::authorize_operation( + subject, + &secret_use_operation(), + &policy, + Some(&store), + std::slice::from_ref(&missing), + ) + .await + .is_none() + ); + assert!( + super::authorize_operation(subject, &secret_use_operation(), &policy, Some(&store), &[],) + .await + .is_none() + ); + } +} diff --git a/sandbox/sandbox-microsandbox/src/network_endpoint.rs b/sandbox/sandbox-microsandbox/src/network_endpoint.rs new file mode 100644 index 0000000..46f71e6 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/network_endpoint.rs @@ -0,0 +1,151 @@ +use std::{ + num::NonZeroUsize, + pin::Pin, + task::{Context, Poll}, +}; + +use microsandbox_network::control::{NetworkControlHost, NetworkControlIncoming}; +use sandbox::network::{ + BatchReceiver, BatchSender, NetworkBatch, NetworkControlEndpoint, NetworkControlEndpointProperties, + NetworkControlMessage, NetworkControlProtocolId, NetworkEndpointError, NetworkTransferProgress, +}; +use tokio_util::sync::PollSender; + +pub(crate) fn open(controller: NetworkControlHost) -> Result { + let maximum_message_length = NonZeroUsize::new(microsandbox_network::control::MAX_CONTROL_MESSAGE_LENGTH) + .ok_or_else(|| sandbox::Error::Backend("invalid Network control message length".into()))?; + let parts = controller.into_parts(); + + Ok(NetworkControlEndpoint::new( + NetworkControlEndpointProperties::new( + NetworkControlProtocolId::new(microsandbox_network::control::NETWORK_CONTROL_PROTOCOL), + maximum_message_length, + ), + ControlReceiver { + receiver: parts.incoming, + }, + ControlSender { + sender: PollSender::new(parts.outgoing), + maximum_message_length: maximum_message_length.get(), + }, + )) +} + +struct ControlReceiver { + receiver: NetworkControlIncoming, +} + +impl BatchReceiver for ControlReceiver { + fn poll_receive( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + output: &mut NetworkBatch, + ) -> Poll> { + if output.is_full() { + return Poll::Ready(Err(NetworkEndpointError::FullReceiveBatch)); + } + + let mut received = 0; + while !output.is_full() { + match self.receiver.poll_recv(context) { + Poll::Ready(Some(message)) => { + if output.push_back(NetworkControlMessage::from(message)).is_err() { + return Poll::Ready(Err(NetworkEndpointError::FullReceiveBatch)); + } + received += 1; + } + Poll::Ready(None) => return transfer_or_closed(received), + Poll::Pending => return transfer_or_pending(received), + } + } + transfer_or_full(received) + } +} + +struct ControlSender { + sender: PollSender>>, + maximum_message_length: usize, +} + +impl BatchSender for ControlSender { + fn poll_send( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + pending: &mut NetworkBatch, + ) -> Poll> { + if pending.is_empty() { + return Poll::Ready(Err(NetworkEndpointError::EmptySendBatch)); + } + + let mut sent = 0; + while let Some(message) = pending.front() { + if message.len() > self.maximum_message_length { + return transfer_or_error( + sent, + NetworkEndpointError::ControlMessageTooLarge { + actual: message.len(), + maximum: self.maximum_message_length, + }, + ); + } + match self.sender.poll_reserve(context) { + Poll::Ready(Ok(())) => { + let Some(message) = pending.pop_front() else { + return Poll::Ready(Err(NetworkEndpointError::EmptySendBatch)); + }; + if let Err(rejected) = self.sender.send_item(message.into_bytes()) { + if let Some(message) = rejected.into_inner() + && pending.push_front(NetworkControlMessage::from(message)).is_err() + { + return Poll::Ready(Err(NetworkEndpointError::Backend( + "failed to restore an unaccepted Network control message".into(), + ))); + } + return transfer_or_closed(sent); + } + sent += 1; + } + Poll::Ready(Err(_)) => return transfer_or_closed(sent), + Poll::Pending => return transfer_or_pending(sent), + } + } + transfer_or_error(sent, NetworkEndpointError::EmptySendBatch) + } + + fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + self.sender.abort_send(); + self.sender.close(); + Poll::Ready(Ok(())) + } +} + +fn transfer_or_closed(count: usize) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Ready(Ok(NetworkTransferProgress::Closed)), |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} + +fn transfer_or_pending(count: usize) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Pending, |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} + +fn transfer_or_full(count: usize) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Ready(Err(NetworkEndpointError::FullReceiveBatch)), |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} + +fn transfer_or_error( + count: usize, + error: NetworkEndpointError, +) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Ready(Err(error)), |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} diff --git a/sandbox/sandbox-microsandbox/src/platform.rs b/sandbox/sandbox-microsandbox/src/platform.rs new file mode 100644 index 0000000..b1abeee --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/platform.rs @@ -0,0 +1,55 @@ +use sandbox::{Error, Platform}; + +pub(crate) fn require_supported(requested: &Platform) -> Result { + let actual = Platform::native("linux"); + let host_supported = host_supported(std::env::consts::OS, std::env::consts::ARCH); + let unconstrained = + requested.variant.is_none() && requested.os_version.is_none() && requested.os_features.is_empty(); + + if host_supported && actual.satisfies(requested) && unconstrained { + Ok(actual) + } else { + Err(Error::UnsupportedPlatform(requested.clone())) + } +} + +pub(crate) fn host_supported(os: &str, architecture: &str) -> bool { + crate::client::runtime_sha256(os, architecture).is_some() +} + +#[cfg(test)] +mod tests { + use sandbox::Platform; + + #[test] + fn supports_only_the_native_unconstrained_linux_platform() { + let native = Platform::native("linux"); + let expected_support = super::host_supported(std::env::consts::OS, std::env::consts::ARCH); + assert_eq!(super::require_supported(&native).is_ok(), expected_support); + + assert!(super::require_supported(&Platform::native("windows")).is_err()); + assert!(super::require_supported(&Platform::new("linux", "different-architecture")).is_err()); + } + + #[test] + fn supports_only_hosts_with_a_pinned_runtime_release() { + for (os, architecture) in [ + ("linux", "x86_64"), + ("linux", "aarch64"), + ("macos", "aarch64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] { + assert!(super::host_supported(os, architecture)); + } + + for (os, architecture) in [ + ("linux", "riscv64"), + ("macos", "x86_64"), + ("windows", "x86"), + ("freebsd", "x86_64"), + ] { + assert!(!super::host_supported(os, architecture)); + } + } +} diff --git a/sandbox/sandbox-microsandbox/src/state.rs b/sandbox/sandbox-microsandbox/src/state.rs new file mode 100644 index 0000000..c5d7749 --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/state.rs @@ -0,0 +1,474 @@ +use std::{collections::BTreeMap, io::Write as _, path::PathBuf}; + +use sandbox::{ + Hostname, SandboxId, SandboxName, SandboxResources, backend::CreateSandboxRequest, image::ResolvedImage, + init::InitSystem, mount::Mount, network::NetworkAttachment, volume::VolumeId, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest as _, Sha256}; + +use crate::{encoding::lower_hex, error}; + +const SANDBOX_SCHEMA_VERSION: u32 = 4; +const VOLUME_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SandboxRecord { + schema_version: u32, + pub(crate) id: SandboxId, + pub(crate) runtime_name: String, + pub(crate) name: SandboxName, + /// Absent in records written before hostnames were persisted; those + /// Sandboxes report their name once their runtime is recreated. + #[serde(default, skip_serializing_if = "Option::is_none")] + hostname: Option, + pub(crate) image: ResolvedImage, + pub(crate) resources: SandboxResources, + #[serde(default)] + pub(crate) init_system: InitSystem, + pub(crate) mounts: Vec, + pub(crate) environment: BTreeMap, + pub(crate) network: Option, +} + +impl SandboxRecord { + pub(crate) fn new(request: CreateSandboxRequest) -> Self { + let runtime_name = format!("sandbox-{}", request.id.as_uuid().simple()); + Self { + schema_version: SANDBOX_SCHEMA_VERSION, + runtime_name, + id: request.id, + name: request.name, + hostname: Some(request.hostname), + image: request.image, + resources: request.resources, + init_system: request.init_system, + mounts: request.mounts, + environment: request.environment, + network: request.network, + } + } + + /// Returns the hostname the guest reports, defaulting to the Sandbox name. + pub(crate) fn hostname(&self) -> Hostname { + self.hostname.clone().unwrap_or_else(|| self.name.clone().into()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct VolumeRecord { + schema_version: u32, + pub(crate) id: VolumeId, + pub(crate) runtime_name: String, + pub(crate) name: sandbox::volume::VolumeName, +} + +impl VolumeRecord { + pub(crate) fn new(id: VolumeId, name: sandbox::volume::VolumeName) -> Self { + let runtime_name = format!("volume-{}", id.as_uuid().simple()); + Self { + schema_version: VOLUME_SCHEMA_VERSION, + runtime_name, + id, + name, + } + } +} + +#[derive(Clone)] +pub(crate) struct StateStore { + home: PathBuf, + sandboxes: PathBuf, + volumes: PathBuf, +} + +impl StateStore { + pub(crate) async fn open(home: PathBuf) -> Result { + let store = Self { + sandboxes: home.join("sandboxes"), + volumes: home.join("volumes"), + home, + }; + for directory in [&store.sandboxes, &store.volumes] { + tokio::fs::create_dir_all(directory) + .await + .map_err(|source| error::io("create Microsandbox state directory", source))?; + } + Ok(store) + } + + pub(crate) async fn save_sandbox(&self, record: &SandboxRecord) -> Result<(), sandbox::Error> { + write_new(self.sandbox_path(&record.name), record).await + } + + pub(crate) async fn update_sandbox(&self, record: &SandboxRecord) -> Result<(), sandbox::Error> { + write_replace(self.sandbox_path(&record.name), record).await + } + + pub(crate) async fn sandbox_by_name(&self, name: &SandboxName) -> Result { + let record: SandboxRecord = read_record( + self.sandbox_path(name), + "read Microsandbox Sandbox state", + sandbox::ResourceKind::Sandbox, + name.to_string(), + ) + .await?; + validate_schema(record.schema_version, SANDBOX_SCHEMA_VERSION)?; + Ok(record) + } + + pub(crate) async fn sandbox_by_id(&self, id: &SandboxId) -> Result { + let record = scan_records( + &self.sandboxes, + sandbox::ResourceKind::Sandbox, + id.to_string(), + |record: &SandboxRecord| &record.id == id, + ) + .await?; + validate_schema(record.schema_version, SANDBOX_SCHEMA_VERSION)?; + Ok(record) + } + + /// Returns every Sandbox record, whether or not its runtime exists. + pub(crate) async fn sandbox_records(&self) -> Result, sandbox::Error> { + let records: Vec = read_records(&self.sandboxes, sandbox::ResourceKind::Sandbox).await?; + for record in &records { + validate_schema(record.schema_version, SANDBOX_SCHEMA_VERSION)?; + } + Ok(records) + } + + pub(crate) async fn remove_sandbox(&self, record: &SandboxRecord) -> Result<(), sandbox::Error> { + remove_file(self.sandbox_path(&record.name), "remove Microsandbox Sandbox state").await + } + + pub(crate) async fn save_volume(&self, record: &VolumeRecord) -> Result<(), sandbox::Error> { + write_new(self.volume_path(&record.name), record).await + } + + pub(crate) async fn volume_by_name( + &self, + name: &sandbox::volume::VolumeName, + ) -> Result { + let record: VolumeRecord = read_record( + self.volume_path(name), + "read Microsandbox Volume state", + sandbox::ResourceKind::Volume, + name.to_string(), + ) + .await?; + validate_schema(record.schema_version, VOLUME_SCHEMA_VERSION)?; + Ok(record) + } + + pub(crate) async fn volume_by_id(&self, id: &VolumeId) -> Result { + let record = scan_records( + &self.volumes, + sandbox::ResourceKind::Volume, + id.to_string(), + |record: &VolumeRecord| record.id == *id, + ) + .await?; + validate_schema(record.schema_version, VOLUME_SCHEMA_VERSION)?; + Ok(record) + } + + pub(crate) async fn remove_volume(&self, record: &VolumeRecord) -> Result<(), sandbox::Error> { + remove_file(self.volume_path(&record.name), "remove Microsandbox Volume state").await + } + + /// Path of a Provider-wide marker file beside the records. + pub(crate) fn marker(&self, name: &str) -> PathBuf { + self.home.join(name) + } + + fn sandbox_path(&self, name: &SandboxName) -> PathBuf { + self.sandboxes.join(record_filename(name)) + } + + fn volume_path(&self, name: &sandbox::volume::VolumeName) -> PathBuf { + self.volumes.join(record_filename(name)) + } +} + +fn record_filename(name: impl AsRef) -> String { + format!("{}.json", lower_hex(&Sha256::digest(name.as_ref().as_bytes()))) +} + +fn validate_schema(version: u32, expected: u32) -> Result<(), sandbox::Error> { + if version == expected { + Ok(()) + } else { + Err(sandbox::Error::Backend(format!( + "unsupported Microsandbox state schema version {version}" + ))) + } +} + +async fn write_new(path: PathBuf, value: &T) -> Result<(), sandbox::Error> +where + T: Serialize + Send + Sync + 'static, +{ + write_record(path, value, false).await +} + +async fn write_replace(path: PathBuf, value: &T) -> Result<(), sandbox::Error> +where + T: Serialize + Send + Sync + 'static, +{ + write_record(path, value, true).await +} + +async fn write_record(path: PathBuf, value: &T, replace: bool) -> Result<(), sandbox::Error> +where + T: Serialize + Send + Sync + 'static, +{ + let serialized = serde_json::to_vec_pretty(value).map_err(error::backend)?; + tokio::task::spawn_blocking(move || { + let parent = path + .parent() + .ok_or_else(|| std::io::Error::other("state record has no parent directory"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(&serialized)?; + temporary.as_file().sync_all()?; + if replace { + temporary.persist(path).map_err(|failure| failure.error)?; + } else { + temporary.persist_noclobber(path).map_err(|failure| failure.error)?; + } + Ok::<(), std::io::Error>(()) + }) + .await + .map_err(error::backend)? + .map_err(|source| error::io("persist Microsandbox state", source)) +} + +async fn read_record( + path: PathBuf, + operation: &'static str, + resource: sandbox::ResourceKind, + id: String, +) -> Result +where + T: DeserializeOwned, +{ + let contents = tokio::fs::read(path).await.map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + sandbox::Error::not_found(resource, &id) + } else { + error::io(operation, source) + } + })?; + serde_json::from_slice(&contents).map_err(error::backend) +} + +async fn scan_records( + directory: &PathBuf, + resource: sandbox::ResourceKind, + id: String, + predicate: impl Fn(&T) -> bool, +) -> Result +where + T: DeserializeOwned, +{ + for record in read_records(directory, resource).await? { + if predicate(&record) { + return Ok(record); + } + } + Err(sandbox::Error::not_found(resource, &id)) +} + +async fn read_records(directory: &PathBuf, resource: sandbox::ResourceKind) -> Result, sandbox::Error> +where + T: DeserializeOwned, +{ + let mut entries = tokio::fs::read_dir(directory) + .await + .map_err(|source| error::io("list Microsandbox state", source))?; + let mut records = Vec::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|source| error::io("read Microsandbox state entry", source))? + { + let path = entry.path(); + // Writes stage records in temporary files beside the committed ones. + if path.extension().is_none_or(|extension| extension != "json") { + continue; + } + match read_record( + path.clone(), + "read Microsandbox state entry", + resource, + path.display().to_string(), + ) + .await + { + Ok(record) => records.push(record), + // Removed since the directory was read. + Err(error) if error.is_not_found() => {} + Err(error) => return Err(error), + } + } + Ok(records) +} + +async fn remove_file(path: PathBuf, operation: &'static str) -> Result<(), sandbox::Error> { + tokio::fs::remove_file(path) + .await + .map_err(|source| error::io(operation, source)) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::{collections::BTreeMap, path::PathBuf}; + + use sandbox::{ + ByteQuantity, CpuQuantity, Hostname, Platform, RootFilesystem, SandboxName, SandboxResources, + backend::CreateSandboxRequest, image, init::InitSystem, + }; + + use super::{SandboxRecord, StateStore, VolumeRecord}; + + fn sandbox_id(value: &str) -> sandbox::SandboxId { + value.parse().expect("test Sandbox ID should be a UUID") + } + + fn image() -> image::ResolvedImage { + image::ResolvedImage { + source: image::ImageSource::Build { + context: PathBuf::from("context"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: "sha256:1234".to_string(), + } + } + + fn sandbox_name() -> SandboxName { + SandboxName::new("worker").expect("test Sandbox name should be valid") + } + + fn resources() -> SandboxResources { + SandboxResources::new( + "1".parse::().expect("test CPU should be valid"), + "512Mi".parse::().expect("test memory should be valid"), + RootFilesystem::layered( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ) + } + + fn sandbox_record(id: &str) -> SandboxRecord { + SandboxRecord::new(CreateSandboxRequest { + id: sandbox_id(id), + name: sandbox_name(), + hostname: Hostname::new("worker-host").expect("test hostname should be valid"), + image: image(), + resources: resources(), + init_system: InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + }) + } + + #[tokio::test(flavor = "local")] + async fn sandbox_records_survive_reopening_the_store() { + let home = tempfile::tempdir().expect("temporary state home should be created"); + let store = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should open"); + let record = sandbox_record("00000000-0000-4000-8000-000000000001"); + store.save_sandbox(&record).await.expect("record should be saved"); + + let reopened = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should reopen"); + assert_eq!( + reopened + .sandbox_by_name(&sandbox_name()) + .await + .expect("record should be found by name"), + record + ); + assert_eq!( + reopened + .sandbox_by_id(&record.id) + .await + .expect("record should be found by identifier"), + record + ); + } + + #[tokio::test(flavor = "local")] + async fn records_without_a_persisted_hostname_report_the_sandbox_name() { + let mut record = sandbox_record("00000000-0000-4000-8000-000000000005"); + assert_eq!(record.hostname().as_str(), "worker-host"); + + let mut serialized = serde_json::to_value(&record).expect("record should serialize"); + let fields = serialized.as_object_mut().expect("record should be an object"); + assert!(fields.remove("hostname").is_some(), "hostname should be persisted"); + let legacy: SandboxRecord = serde_json::from_value(serialized).expect("legacy record should deserialize"); + assert_eq!(legacy.hostname().as_str(), "worker"); + + record.hostname = None; + assert_eq!(legacy, record); + } + + #[tokio::test(flavor = "local")] + async fn saving_a_duplicate_name_does_not_replace_immutable_state() { + let home = tempfile::tempdir().expect("temporary state home should be created"); + let store = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should open"); + let original = sandbox_record("00000000-0000-4000-8000-000000000002"); + let replacement = sandbox_record("00000000-0000-4000-8000-000000000003"); + store.save_sandbox(&original).await.expect("record should be saved"); + + assert!(store.save_sandbox(&replacement).await.is_err()); + assert_eq!( + store + .sandbox_by_name(&sandbox_name()) + .await + .expect("original record should remain"), + original + ); + } + + #[tokio::test(flavor = "local")] + async fn volume_records_are_addressable_by_name_and_identifier() { + let home = tempfile::tempdir().expect("temporary state home should be created"); + let store = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should open"); + let id = "00000000-0000-4000-8000-000000000004" + .parse() + .expect("test Volume identifier should be valid"); + let name = sandbox::volume::VolumeName::new("home").expect("test Volume name should be valid"); + let record = VolumeRecord::new(id, name.clone()); + store.save_volume(&record).await.expect("record should be saved"); + + assert_eq!( + store + .volume_by_name(&name) + .await + .expect("record should be found by name"), + record + ); + assert_eq!( + store + .volume_by_id(&record.id) + .await + .expect("record should be found by identifier"), + record + ); + } +} diff --git a/sandbox/sandbox-microsandbox/src/volumes.rs b/sandbox/sandbox-microsandbox/src/volumes.rs new file mode 100644 index 0000000..b3a53cd --- /dev/null +++ b/sandbox/sandbox-microsandbox/src/volumes.rs @@ -0,0 +1,55 @@ +use sandbox::{Error, volume}; + +use crate::{backend::MicrosandboxProvider, error, state::VolumeRecord}; + +impl MicrosandboxProvider { + pub(crate) async fn ensure_volume_record( + &self, + request: volume::EnsureVolumeRequest, + ) -> Result { + let (id, name) = request.into_parts(); + let record = match self.state.volume_by_name(&name).await { + Ok(record) => record, + Err(error) if error.is_not_found() => { + let record = VolumeRecord::new(id, name); + self.state.save_volume(&record).await?; + record + } + Err(error) => return Err(error), + }; + self.ensure_volume_runtime(&record).await?; + Ok(record.to_volume()) + } + + pub(crate) async fn delete_volume_record(&self, id: &volume::VolumeId) -> Result<(), Error> { + let record = self.state.volume_by_id(id).await?; + match self.client.scope(microsandbox::Volume::get(&record.runtime_name)).await { + Ok(handle) => handle.remove().await.map_err(error::microsandbox)?, + Err(microsandbox::MicrosandboxError::VolumeNotFound(_)) => {} + Err(failure) => return Err(error::microsandbox(failure)), + } + self.state.remove_volume(&record).await + } + + pub(crate) async fn ensure_volume_runtime(&self, record: &VolumeRecord) -> Result<(), Error> { + match self.client.scope(microsandbox::Volume::get(&record.runtime_name)).await { + Ok(_) => return Ok(()), + Err(microsandbox::MicrosandboxError::VolumeNotFound(_)) => {} + Err(failure) => return Err(error::microsandbox(failure)), + } + self.client + .scope(microsandbox::Volume::builder(&record.runtime_name).directory().create()) + .await + .map(|_| ()) + .map_err(error::microsandbox) + } +} + +impl VolumeRecord { + pub(crate) fn to_volume(&self) -> volume::Volume { + volume::Volume { + id: self.id.clone(), + name: self.name.clone(), + } + } +} diff --git a/sandbox/sandbox-microsandbox/tests/architecture.rs b/sandbox/sandbox-microsandbox/tests/architecture.rs new file mode 100644 index 0000000..f553f9e --- /dev/null +++ b/sandbox/sandbox-microsandbox/tests/architecture.rs @@ -0,0 +1,13 @@ +#![allow(clippy::expect_used)] + +use std::{fs, path::Path}; + +#[test] +fn microsandbox_integration_does_not_depend_on_agent_automation() { + let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("Microsandbox integration Cargo.toml should be readable"); + + assert!(manifest.contains("sandbox =")); + assert!(!manifest.contains("agent =")); + assert!(!manifest.contains("agent-runtime =")); +} diff --git a/sandbox/sandbox-microsandbox/tests/backend.rs b/sandbox/sandbox-microsandbox/tests/backend.rs new file mode 100644 index 0000000..cc7469a --- /dev/null +++ b/sandbox/sandbox-microsandbox/tests/backend.rs @@ -0,0 +1,235 @@ +// A Provider handle lives for the whole test; tightening its drop adds nothing. +#![allow(clippy::expect_used, clippy::significant_drop_tightening)] + +use microsandbox_network::control::NETWORK_CONTROL_PROTOCOL; +use sandbox::{ + Platform, RootFilesystemMode, SandboxFeature, + backend::SandboxBackend as _, + image::{ImageSource, ImageSourceKind, ResolveRequest}, + network::{NetworkControlProtocolId, NetworkEndpointSelection}, + provider::SandboxProvider as _, +}; +use sandbox_microsandbox::MicrosandboxProvider; + +const ALPINE_3_22_INDEX_DIGEST: &str = "sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"; + +#[tokio::test(flavor = "local")] +async fn backend_state_and_runtime_are_rooted_in_the_explicit_home() { + let home = tempfile::tempdir().expect("temporary home should be created"); + let backend_home = home.path().join("microsandbox"); + let backend = MicrosandboxProvider::open(&backend_home) + .await + .expect("Backend should open without starting a VM"); + + assert!(backend_home.join("state/sandboxes").is_dir()); + assert!(backend_home.join("state/volumes").is_dir()); + assert!(backend_home.join("runtime").is_dir()); + + let architecture = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + let platform = Platform::new("linux", architecture); + let host_supported = matches!(std::env::consts::OS, "linux" | "windows") + || (std::env::consts::OS == "macos" && std::env::consts::ARCH == "aarch64"); + let result = backend.capabilities(&platform).await; + if !host_supported { + assert!(result.is_err()); + return; + } + let capabilities = result.expect("native Linux capabilities should be reported"); + assert!(capabilities.features.contains(SandboxFeature::Execution)); + assert!(capabilities.features.contains(SandboxFeature::TerminalExecution)); + assert!(capabilities.features.contains(SandboxFeature::TerminalAttach)); + assert!(capabilities.features.contains(SandboxFeature::FileTransfer)); + assert!(capabilities.features.contains(SandboxFeature::PersistentVolumes)); + assert!(capabilities.features.contains(SandboxFeature::NestedContainers)); + assert!( + capabilities + .network + .supports(&NetworkEndpointSelection::Control(NetworkControlProtocolId::new( + NETWORK_CONTROL_PROTOCOL + ))) + ); + + let image_capabilities = backend + .image_backend() + .capabilities(&platform) + .await + .expect("native Image Backend capabilities should be reported"); + assert!(image_capabilities.resolve.sources.contains(ImageSourceKind::Build)); + assert!(image_capabilities.resolve.sources.contains(ImageSourceKind::Reference)); + assert!( + image_capabilities + .resolve + .root_filesystem_modes + .contains(RootFilesystemMode::Layered) + ); + assert!( + image_capabilities + .resolve + .root_filesystem_modes + .contains(RootFilesystemMode::Direct) + ); + for prepared in [ + &image_capabilities.prepared_image_export, + &image_capabilities.prepared_image_import, + ] { + assert!(prepared.sources.contains(ImageSourceKind::Reference)); + assert!(!prepared.sources.contains(ImageSourceKind::Build)); + assert!(prepared.root_filesystem_modes.contains(RootFilesystemMode::Direct)); + assert!(!prepared.root_filesystem_modes.contains(RootFilesystemMode::Layered)); + } +} + +#[tokio::test(flavor = "local")] +async fn cache_directory_can_be_shared_without_sharing_provider_state() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let shared_cache = temporary.path().join("shared-cache"); + let first_home = temporary.path().join("first-provider"); + let second_home = temporary.path().join("second-provider"); + + MicrosandboxProvider::builder(&first_home) + .cache_directory(&shared_cache) + .open() + .await + .expect("first Provider should open with the shared cache"); + MicrosandboxProvider::builder(&second_home) + .cache_directory(&shared_cache) + .open() + .await + .expect("second Provider should open with the shared cache"); + + assert!(shared_cache.is_dir()); + assert!(first_home.join("state/sandboxes").is_dir()); + assert!(second_home.join("state/sandboxes").is_dir()); +} + +#[tokio::test(flavor = "local")] +#[ignore = "requires access to the public Docker registry"] +async fn multi_platform_index_resolves_to_the_native_image_manifest() { + let (architecture, expected_manifest_digest) = match std::env::consts::ARCH { + "x86_64" => ( + "amd64", + "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", + ), + "aarch64" => ( + "arm64", + "sha256:2c9d26f410d032d5b1525aa8a873e238b05b90c4ae8618743d4311f0cc827e37", + ), + _ => return, + }; + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let provider = MicrosandboxProvider::open(temporary.path().join("provider")) + .await + .expect("Provider should open"); + let request = ResolveRequest { + source: ImageSource::Reference { + reference: format!("docker.io/library/alpine@{ALPINE_3_22_INDEX_DIGEST}"), + }, + platform: Platform::new("linux", architecture), + root_filesystem_mode: RootFilesystemMode::Layered, + }; + + let resolved = provider + .image_backend() + .resolve(&request) + .await + .expect("multi-platform index should resolve"); + + assert_eq!(resolved.manifest_digest, expected_manifest_digest); + assert_ne!(resolved.manifest_digest, ALPINE_3_22_INDEX_DIGEST); +} + +#[tokio::test(flavor = "local")] +async fn cache_directory_must_not_be_empty() { + let result = MicrosandboxProvider::builder("private-provider") + .cache_directory("") + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.cacheDirectory", + .. + }) + )); +} + +#[tokio::test(flavor = "local")] +async fn unused_images_are_never_removed_from_a_cache_other_providers_may_share() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .cache_directory(temporary.path().join("shared-cache")) + .remove_unused_images_after(std::time::Duration::from_mins(1)) + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.unusedImageRetention", + .. + }) + )); +} + +#[tokio::test(flavor = "local")] +async fn unused_images_are_kept_for_at_least_an_hour() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .remove_unused_images_after(std::time::Duration::from_mins(59)) + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.unusedImageRetention", + .. + }) + )); + MicrosandboxProvider::builder(temporary.path().join("provider")) + .remove_unused_images_after(std::time::Duration::from_hours(1)) + .open() + .await + .expect("an hour should be accepted"); +} + +#[tokio::test(flavor = "local")] +async fn runtime_bundle_must_be_a_regular_file() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .runtime_bundle(temporary.path().join("missing.tar.gz"), "0".repeat(64)) + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.runtimeBundle.path", + .. + }) + )); +} + +#[tokio::test(flavor = "local")] +async fn runtime_bundle_digest_must_be_sha256() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let bundle = temporary.path().join("runtime.tar.gz"); + std::fs::write(&bundle, []).expect("placeholder runtime bundle should be written"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .runtime_bundle(bundle, "not-a-sha256") + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.runtimeBundle.sha256", + .. + }) + )); +} diff --git a/sandbox/sandbox-microsandbox/tests/fixtures/runtime-image/Dockerfile b/sandbox/sandbox-microsandbox/tests/fixtures/runtime-image/Dockerfile new file mode 100644 index 0000000..b797eae --- /dev/null +++ b/sandbox/sandbox-microsandbox/tests/fixtures/runtime-image/Dockerfile @@ -0,0 +1,8 @@ +# syntax=docker/dockerfile:1 +FROM alpine:3.22 + +RUN apk add --no-cache iptables nftables + +RUN --mount=type=cache,target=/var/cache true + +CMD ["/bin/echo", "default-entrypoint"] diff --git a/sandbox/sandbox-microsandbox/tests/network_runtime.rs b/sandbox/sandbox-microsandbox/tests/network_runtime.rs new file mode 100644 index 0000000..b1cb637 --- /dev/null +++ b/sandbox/sandbox-microsandbox/tests/network_runtime.rs @@ -0,0 +1,314 @@ +// A Provider handle lives for the whole test; tightening its drop adds nothing. +#![allow(clippy::expect_used, clippy::significant_drop_tightening)] + +use std::{cell::RefCell, panic::AssertUnwindSafe, rc::Rc}; + +use futures_util::FutureExt as _; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Platform, RetentionPolicy, RootFilesystem, SandboxHandle, + SandboxName, SandboxResources, SandboxService, SandboxSpec, backend::SandboxBackend as _, execution::ExecutionSpec, + image::ImageSource, memory::MemorySecretStore, secret_store::SecretStore as _, +}; +use sandbox_authorization::{AuthorizationDecision, AuthorizationRequest, LocalFuture, PolicyEngine}; +use sandbox_microsandbox::{MicrosandboxNetworkBackend, MicrosandboxProvider, SecretBinding}; + +struct RecordingPolicy { + denied_action: RefCell>, + requests: RefCell>, +} + +impl RecordingPolicy { + const fn allow_all() -> Self { + Self { + denied_action: RefCell::new(None), + requests: RefCell::new(Vec::new()), + } + } + + fn deny(&self, action: &str) { + self.denied_action.replace(Some(action.to_string())); + } +} + +impl PolicyEngine for RecordingPolicy { + fn evaluate( + &self, + request: AuthorizationRequest, + ) -> LocalFuture<'_, Result> { + let decision = if self.denied_action.borrow().as_deref() == Some(request.action.as_str()) { + AuthorizationDecision::Deny + } else { + AuthorizationDecision::Allow + }; + self.requests.borrow_mut().push(request); + Box::pin(async move { Ok(decision) }) + } +} + +#[tokio::test(flavor = "local")] +#[ignore = "requires Internet access, a Docker Engine API, Microsandbox host runtime and hardware virtualization"] +async fn controlled_network_authorizes_dns_tcp_and_http_and_fails_closed() { + let temporary = tempfile::tempdir().expect("temporary integration home should be created"); + let backend = Rc::new( + MicrosandboxProvider::open(temporary.path().join("control-plane")) + .await + .expect("Backend should open"), + ); + let policy = Rc::new(RecordingPolicy::allow_all()); + let secrets = Rc::new(MemorySecretStore::default()); + let token = secrets + .set("provider-token", b"integration-secret") + .await + .expect("integration secret should be stored"); + let network = Rc::new(MicrosandboxNetworkBackend::new(policy.clone()).with_secret_store(secrets.clone())); + let sandbox_name = SandboxName::new("controlled-network").expect("test Sandbox name should be valid"); + network + .set_secret_bindings( + sandbox_name.clone(), + vec![ + SecretBinding::with_placeholder("PROVIDER_TOKEN", "$MEDIATED_TOKEN", token) + .expect("integration secret binding should be valid"), + ], + ) + .expect("secret mediation should be configured"); + let service = SandboxService::new(backend.clone()).with_network_backend(network); + let request = EnsureSandboxRequest::new( + sandbox_name, + SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: resources(), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Delete, + }, + ); + let sandbox = service.ensure(&request).await.expect("controlled Sandbox should start"); + + let test_result = AssertUnwindSafe(async { + let allowed = sandbox + .run_execution(shell("wget -T 10 -qO- http://example.com")) + .await + .expect("allowed request should execute"); + assert!(allowed.status.success(), "allowed request failed: {allowed:?}"); + { + let requests = policy.requests.borrow(); + assert_action(&requests, "dns.query"); + assert_action(&requests, "network.connect"); + assert_http_request(&requests, "example.com", "http"); + } + + backend.stop(sandbox.id()).await.expect("Sandbox should stop"); + backend.start(sandbox.id()).await.expect("Sandbox should restart"); + let requests_before_restart = policy.requests.borrow().len(); + let after_restart = sandbox + .run_execution(shell("wget -T 10 -qO- https://example.net")) + .await + .expect("request after runtime restart should execute"); + assert!( + after_restart.status.success(), + "controller should accept a fresh runtime session" + ); + { + let requests = policy.requests.borrow(); + assert_http_request(&requests[requests_before_restart..], "example.net", "https"); + } + + assert_mediated_secret_enforcement(&sandbox, policy.as_ref()).await; + + let requests_before_denial = policy.requests.borrow().len(); + policy.deny("http.request"); + let denied = sandbox + .run_execution(shell("wget -T 5 -qO- https://example.org")) + .await + .expect("denied request should still produce an exit status"); + assert!( + !denied.status.success(), + "http.request denial unexpectedly allowed egress" + ); + { + let requests = policy.requests.borrow(); + let denied_requests = &requests[requests_before_denial..]; + assert_action(denied_requests, "network.connect"); + assert_action(denied_requests, "http.request"); + } + }) + .catch_unwind() + .await; + service + .delete(request.name()) + .await + .expect("controlled Sandbox should delete"); + if let Err(payload) = test_result { + std::panic::resume_unwind(payload); + } +} + +#[tokio::test(flavor = "local")] +#[ignore = "requires Internet access, a Docker Engine API, Microsandbox host runtime and hardware virtualization"] +async fn resource_restart_after_reopening_the_provider_keeps_network_control() { + let temporary = tempfile::tempdir().expect("temporary integration home should be created"); + let home = temporary.path().join("control-plane"); + let spec = |cpu: &str| SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: resources_with_cpu(cpu), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Delete, + }; + let sandbox_name = SandboxName::new("controlled-restart").expect("test Sandbox name should be valid"); + { + let backend = Rc::new(MicrosandboxProvider::open(&home).await.expect("Backend should open")); + let network = Rc::new(MicrosandboxNetworkBackend::new(Rc::new(RecordingPolicy::allow_all()))); + // Dropped without release: the runtime keeps running, as when agentd exits. + let _abandoned = SandboxService::new(backend) + .with_network_backend(network) + .ensure(&EnsureSandboxRequest::new(sandbox_name.clone(), spec("1"))) + .await + .expect("controlled Sandbox should start"); + } + + // A reopened Provider holds no process-local Network control state, as + // after an agentd restart while the Sandbox kept running. Growing the CPU + // count restarts the runtime before its Network endpoint is reopened. + let policy = Rc::new(RecordingPolicy::allow_all()); + policy.deny("network.connect"); + let backend = Rc::new(MicrosandboxProvider::open(&home).await.expect("Backend should reopen")); + let network = Rc::new(MicrosandboxNetworkBackend::new(policy.clone())); + let service = SandboxService::new(backend).with_network_backend(network); + let request = EnsureSandboxRequest::new(sandbox_name, spec("2")); + let sandbox = service + .ensure(&request) + .await + .expect("resource change should restart the Sandbox"); + + let test_result = AssertUnwindSafe(async { + let denied = sandbox + .run_execution(shell("wget -T 5 -qO- http://example.com")) + .await + .expect("denied request should still produce an exit status"); + assert!( + !denied.status.success(), + "restarted Sandbox reached the network without host control" + ); + assert_action(&policy.requests.borrow(), "network.connect"); + }) + .catch_unwind() + .await; + service + .delete(request.name()) + .await + .expect("controlled Sandbox should delete"); + if let Err(payload) = test_result { + std::panic::resume_unwind(payload); + } +} + +async fn assert_mediated_secret_enforcement(sandbox: &SandboxHandle, policy: &RecordingPolicy) { + let requests_before_secret = policy.requests.borrow().len(); + let mediated = sandbox + .run_execution(shell( + "wget -T 10 -qO /dev/null --header='Authorization: Bearer $MEDIATED_TOKEN' https://example.net", + )) + .await + .expect("mediated request should execute"); + assert!(mediated.status.success(), "mediated HTTPS request should succeed"); + { + let requests = policy.requests.borrow(); + let mediated_requests = &requests[requests_before_secret..]; + assert_action(mediated_requests, "http.request"); + let secret_use = mediated_requests + .iter() + .find(|request| request.action.as_str() == "secret.use") + .expect("secret use should be authorized independently"); + assert_eq!(secret_use.resource.kind, "secret"); + assert_eq!(secret_use.resource.id, "PROVIDER_TOKEN"); + assert_eq!( + secret_use.context.attributes["http.authority"].as_str(), + Some("example.net") + ); + assert_eq!( + secret_use.context.attributes["secret.locations"] + .as_strings() + .expect("locations should be a list"), + ["header"] + ); + } + + // A placeholder the secret is not substituted into, such as conversation history in a model + // request body, does not block the request. + let history = sandbox + .run_execution(shell( + "wget -T 10 -S -O /dev/null --header='Authorization: Bearer $MEDIATED_TOKEN' \ + --post-data='history: $MEDIATED_TOKEN' https://example.net 2>&1; true", + )) + .await + .expect("request with a body placeholder should execute"); + let history = String::from_utf8_lossy(&history.stdout); + assert!( + history.contains("HTTP/1.1 "), + "a placeholder in the request body blocked the request: {history}" + ); + + policy.deny("secret.use"); + let denied = sandbox + .run_execution(shell( + "wget -T 5 -qO /dev/null --header='Authorization: Bearer $MEDIATED_TOKEN' https://example.net", + )) + .await + .expect("denied secret request should still produce an exit status"); + assert!(!denied.status.success(), "secret.use denial allowed egress"); +} + +fn assert_action(requests: &[AuthorizationRequest], action: &str) { + assert!( + requests.iter().any(|request| request.action.as_str() == action), + "expected {action} authorization request" + ); +} + +fn assert_http_request(requests: &[AuthorizationRequest], authority: &str, scheme: &str) { + let request = requests + .iter() + .find(|request| request.action.as_str() == "http.request") + .expect("HTTP request should be authorized independently"); + assert_eq!(request.resource.kind, "externalService"); + assert_eq!(request.resource.id, authority); + assert_eq!(request.context.attributes["http.scheme"].as_str(), Some(scheme)); + assert_eq!(request.context.attributes["http.method"].as_str(), Some("GET")); + assert_eq!(request.context.attributes["http.path"].as_str(), Some("/")); + assert_eq!(request.context.attributes["http.version"].as_str(), Some("http1")); +} + +fn shell(script: &str) -> ExecutionSpec { + ExecutionSpec::command( + sandbox::SandboxPath::new("/bin/sh"), + ["-c".to_string(), script.to_string()], + ) +} + +fn resources() -> SandboxResources { + resources_with_cpu("1") +} + +fn resources_with_cpu(cpu: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + "512Mi".parse::().expect("test memory should be valid"), + RootFilesystem::layered("2Gi".parse::().expect("root filesystem should be valid")), + ) +} + +fn native_linux_platform() -> Platform { + Platform::new( + "linux", + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }, + ) +} diff --git a/sandbox/sandbox-microsandbox/tests/runtime.rs b/sandbox/sandbox-microsandbox/tests/runtime.rs new file mode 100644 index 0000000..940f2e9 --- /dev/null +++ b/sandbox/sandbox-microsandbox/tests/runtime.rs @@ -0,0 +1,735 @@ +// A Provider handle lives for the whole test; tightening its drop adds nothing. +#![allow(clippy::expect_used, clippy::significant_drop_tightening)] + +use std::{io::Cursor, path::PathBuf, rc::Rc}; + +use bytes::Bytes; +use futures_util::StreamExt as _; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Hostname, OperationEvent, Platform, ProgressEvent, + RetentionPolicy, RootFilesystem, Sandbox, SandboxName, SandboxResources, SandboxService, SandboxSpec, SandboxState, + backend::SandboxBackend as _, + execution::{self, ExecutionSpec, StartExecutionRequest}, + image::ImageSource, + mount::Mount, + terminal::{StartTerminalExecutionRequest, TerminalEvent, TerminalSize}, + volume::{EnsureVolumeRequest, VolumeName}, +}; +use sandbox_microsandbox::MicrosandboxProvider; +use sha2::{Digest as _, Sha256}; +use tokio::io::AsyncReadExt as _; + +#[tokio::test(flavor = "local")] +#[ignore = "requires a Docker Engine API, Microsandbox host runtime and hardware virtualization"] +async fn retained_lifecycle_execution_files_and_volumes() { + let temporary = RetainedOnFailureTempDir::new(); + let backend_home = temporary.path().join("control-plane"); + let reference_backend_home = temporary.path().join("reference-control-plane"); + let backend = Rc::new( + MicrosandboxProvider::open(&backend_home) + .await + .expect("Backend should open"), + ); + let home = backend + .ensure_volume(EnsureVolumeRequest::new( + VolumeName::new("home").expect("valid Volume name"), + )) + .await + .expect("retained volume should be created"); + let service = SandboxService::new(backend.clone()); + let mut request = EnsureSandboxRequest::new( + SandboxName::new("integration-worker").expect("test Sandbox name should be valid"), + SandboxSpec { + image: ImageSource::Build { + context: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/runtime-image"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: native_linux_platform(), + resources: direct_resources("1", "512Mi", "4Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Retain, + }, + ) + .with_hostname(Hostname::new("integration-host").expect("test hostname should be valid")) + .with_mounts([Mount::Volume { + id: home.id.clone(), + target: sandbox::SandboxPath::new("/workspace"), + read_only: false, + }]); + let (mut sandbox, events) = collect_progress(service.ensure(&request)) + .await + .expect("Sandbox should be built and started"); + assert_provisioning_progress(&events); + assert_eq!(sandbox.state, SandboxState::Running); + assert_hostname(backend.as_ref(), &sandbox, "integration-host").await; + assert_direct_root_filesystem(backend.as_ref(), &sandbox).await; + assert_nested_container_networking(backend.as_ref(), &sandbox).await; + + sandbox = assert_resource_update_and_root_growth(backend.as_ref(), &service, &mut request, sandbox).await; + + let output = run(backend.as_ref(), &sandbox.id, ExecutionSpec::image_entrypoint()).await; + assert_eq!(output.stdout.as_ref(), b"default-entrypoint\n"); + + assert_terminal_execution(backend.as_ref(), &sandbox).await; + + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new("/workspace/retained.txt"), + Box::pin(Cursor::new(b"retained".to_vec())), + ) + .await + .expect("file should stream into the Sandbox"); + assert_atomic_replacement(backend.as_ref(), &sandbox).await; + backend.stop(&sandbox.id).await.expect("Sandbox should stop"); + + drop(service); + drop(backend); + let backend = MicrosandboxProvider::open(&backend_home) + .await + .expect("Backend should reopen from the same home"); + assert_eq!( + backend + .find(request.name()) + .await + .expect("Sandbox should be re-adopted") + .state, + SandboxState::Stopped + ); + backend.start(&sandbox.id).await.expect("Sandbox should restart"); + + assert_eq!( + read(&backend, &sandbox.id, "/workspace/retained.txt").await, + b"retained" + ); + assert_immediate_restart_and_delete(&backend, &request, &sandbox).await; + assert_build_cache_reused(backend, &request, &home.id).await; + assert_reference_image_resolves(reference_backend_home).await; +} + +/// A direct root filesystem pulled from a registry is prepared without Microsandbox's layered +/// image artifacts, so a restart must boot from the Sandbox's own root disk without them. +#[tokio::test(flavor = "local")] +#[ignore = "requires a Microsandbox host runtime, hardware virtualization and registry access"] +async fn direct_reference_sandbox_restarts_on_its_root_filesystem() { + let temporary = RetainedOnFailureTempDir::new(); + let home = temporary.path().join("control-plane"); + let backend = Rc::new(MicrosandboxProvider::open(&home).await.expect("Backend should open")); + let service = SandboxService::new(backend.clone()); + let request = EnsureSandboxRequest::new( + SandboxName::new("direct-reference-worker").expect("test Sandbox name should be valid"), + SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: direct_resources("1", "512Mi", "1Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Retain, + }, + ); + let (sandbox, _) = collect_progress(service.ensure(&request)) + .await + .expect("OCI reference should resolve and start"); + assert_direct_root_filesystem(backend.as_ref(), &sandbox).await; + assert_guest_heartbeat_advances(backend.as_ref(), &sandbox.id).await; + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new("/root/retained.txt"), + Box::pin(Cursor::new(b"retained".to_vec())), + ) + .await + .expect("file should stream into the Sandbox"); + + backend.stop(&sandbox.id).await.expect("Sandbox should stop"); + let stopped = backend + .inspect(&sandbox.id) + .await + .expect("stopped Sandbox should be inspected"); + assert_eq!(stopped.guest_heartbeat, None, "a stopped guest reports no heartbeat"); + backend + .start(&sandbox.id) + .await + .expect("stopped direct Sandbox should restart"); + assert_direct_root_filesystem(backend.as_ref(), &sandbox).await; + assert_guest_heartbeat_advances(backend.as_ref(), &sandbox.id).await; + assert_eq!(read(&backend, &sandbox.id, "/root/retained.txt").await, b"retained"); + + // A paused VM refuses a graceful stop and a frozen one never answers it; + // stopping either must still end it, and it starts again on its own disk. + // Finding and signalling the VM process reads the host's `/proc`. + if cfg!(target_os = "linux") { + assert_stop_ends_the_vm(&backend, &service, &request, &sandbox, |runtime| { + msb(&home, &["pause", runtime]); + }) + .await; + assert_stop_ends_the_vm(&backend, &service, &request, &sandbox, |runtime| { + signal(&runtime_processes(runtime), "STOP"); + }) + .await; + } + + backend.delete(&sandbox.id).await.expect("Sandbox should be deleted"); +} + +/// Disrupts the running Sandbox's runtime with `disrupt`, then checks that a +/// stop ends its VM process within a bound and that it starts again on its root disk. +async fn assert_stop_ends_the_vm( + backend: &MicrosandboxProvider, + service: &SandboxService, + request: &EnsureSandboxRequest, + sandbox: &Sandbox, + disrupt: impl FnOnce(&str), +) { + let runtime = format!("sandbox-{}", sandbox.id.as_uuid().simple()); + assert!( + !runtime_processes(&runtime).is_empty(), + "the running Sandbox should have a runtime process" + ); + disrupt(&runtime); + let started = tokio::time::Instant::now(); + let stopped = service.stop(request.name()).await; + let elapsed = started.elapsed(); + // An exited process has an empty command line, so only a live runtime is left here. + let survivors = runtime_processes(&runtime); + signal(&survivors, "KILL"); + stopped.expect("a disrupted Sandbox should stop"); + assert!( + survivors.is_empty(), + "runtime processes {survivors:?} outlived the stop" + ); + assert!( + elapsed < std::time::Duration::from_secs(30), + "stopping a disrupted Sandbox should be bounded, took {elapsed:?}" + ); + let (restarted, _) = collect_progress(service.ensure(request)) + .await + .expect("a stopped direct Sandbox should start again"); + assert_eq!(restarted.id, sandbox.id); + assert_eq!(restarted.state, SandboxState::Running); + assert_eq!(read(backend, &sandbox.id, "/root/retained.txt").await, b"retained"); +} + +/// Host processes whose command line names the Sandbox's runtime. +fn runtime_processes(runtime: &str) -> Vec { + std::fs::read_dir("/proc") + .expect("/proc should be readable") + .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse::().ok()) + .filter(|pid| *pid != std::process::id()) + .filter(|pid| { + std::fs::read(format!("/proc/{pid}/cmdline")).is_ok_and(|cmdline| { + cmdline + .split(|byte| *byte == 0) + .any(|argument| argument == runtime.as_bytes()) + }) + }) + .collect() +} + +/// Runs the Provider's own `msb` against its runtime home. +fn msb(home: &std::path::Path, arguments: &[&str]) { + let runtime = home.join("runtime"); + let status = std::process::Command::new(runtime.join("bin").join("msb")) + .args(arguments) + .env("MSB_HOME", &runtime) + .status() + .expect("msb should run"); + assert!(status.success(), "msb {arguments:?} should succeed"); +} + +fn signal(pids: &[u32], signal: &str) { + for pid in pids { + let status = std::process::Command::new("kill") + .arg(format!("-{signal}")) + .arg(pid.to_string()) + .status() + .expect("kill should run"); + assert!(status.success(), "kill -{signal} {pid} should succeed"); + } +} + +/// A running guest's heartbeat advances on its own, without traffic to the guest. +async fn assert_guest_heartbeat_advances(backend: &MicrosandboxProvider, id: &sandbox::SandboxId) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let mut first = None; + loop { + let heartbeat = backend + .inspect(id) + .await + .expect("running Sandbox should be inspected") + .guest_heartbeat; + match (first, heartbeat) { + (None, Some(heartbeat)) => first = Some(heartbeat), + (Some(first), Some(heartbeat)) if heartbeat != first => return, + _ => {} + } + assert!( + tokio::time::Instant::now() < deadline, + "guest heartbeat should advance within 10s, first observed {first:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } +} + +/// A replacement must never expose a partial file: a reader polling the path throughout the +/// write sees the old or the new contents only, the replaced file keeps its mode, and no +/// staging file is left behind. +async fn assert_atomic_replacement(backend: &MicrosandboxProvider, sandbox: &Sandbox) { + let path = "/workspace/replaced.txt"; + let old = vec![b'a'; 4 * 1024 * 1024]; + let new = vec![b'b'; 4 * 1024 * 1024]; + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new(path), + Box::pin(Cursor::new(old.clone())), + ) + .await + .expect("initial file should stream into the Sandbox"); + let mode = run( + backend, + &sandbox.id, + shell(&format!("stat -c %a {path} && chmod 4750 {path} && stat -c %a {path}")), + ) + .await; + assert_eq!(mode.stdout.as_ref(), b"644\n4750\n", "a new file gets the default mode"); + // One digest per observation, each from a single open of the path, so an observation can + // only be the complete old file, the complete new file, or a torn one. The reader marks + // its first observation so the replacement provably overlaps with it. + let old_digest = hex(&Sha256::digest(&old)); + let new_digest = hex(&Sha256::digest(&new)); + let ready = "/workspace/.reader-ready"; + let reader = backend + .start_execution( + &sandbox.id, + StartExecutionRequest::new(shell(&format!( + "end=$(($(date +%s) + 20)); while [ $(date +%s) -lt $end ]; do \ + digest=$(sha256sum < {path} | cut -d ' ' -f 1); echo \"$digest\"; touch {ready}; \ + [ \"$digest\" = {new_digest} ] && break; done" + ))), + ) + .await + .expect("reader should start"); + for attempt in 0.. { + let probe = run(backend, &sandbox.id, shell(&format!("test -f {ready}"))).await; + if probe.status.success() { + break; + } + assert!(attempt < 100, "reader never started observing the file"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new(path), + Box::pin(Cursor::new(new.clone())), + ) + .await + .expect("replacement should stream into the Sandbox"); + let observed = reader.collect().await.expect("reader should exit"); + let lines = String::from_utf8(observed.stdout.to_vec()).expect("reader output should be UTF-8"); + assert!(!lines.is_empty(), "reader observed nothing"); + for line in lines.lines() { + assert!( + line == old_digest || line == new_digest, + "reader observed a partial or mixed file: {line:?}" + ); + } + assert!(lines.contains(&old_digest), "reader never observed the original"); + assert!(lines.contains(&new_digest), "reader never observed the replacement"); + assert_eq!(read(backend, &sandbox.id, path).await, new); + let after = run( + backend, + &sandbox.id, + shell(&format!( + "rm {ready}; stat -c %a {path}; ls -A /workspace | grep -c agent- || true" + )), + ) + .await; + assert_eq!(after.stdout.as_ref(), b"4750\n0\n"); +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + bytes.iter().fold(String::new(), |mut hex, byte| { + let _ = write!(hex, "{byte:02x}"); + hex + }) +} + +async fn assert_hostname(backend: &MicrosandboxProvider, sandbox: &Sandbox, expected: &str) { + assert_eq!(sandbox.hostname.as_str(), expected); + let output = run(backend, &sandbox.id, shell("hostname")).await; + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), expected); +} + +async fn assert_direct_root_filesystem(backend: &MicrosandboxProvider, sandbox: &Sandbox) { + let output = run( + backend, + &sandbox.id, + shell("awk '$2 == \"/\" { print $3 }' /proc/mounts"), + ) + .await; + assert_eq!(output.stdout.as_ref(), b"ext4\n"); +} + +async fn assert_nested_container_networking(backend: &MicrosandboxProvider, sandbox: &Sandbox) { + let output = run( + backend, + &sandbox.id, + shell( + r"set -eu +cleanup() { + iptables -t nat -F SBX_KUBE_PROXY_TEST 2>/dev/null || true + iptables -t nat -X SBX_KUBE_PROXY_TEST 2>/dev/null || true + nft delete table ip sandbox_test 2>/dev/null || true +} +trap cleanup EXIT +iptables -t nat -N SBX_KUBE_PROXY_TEST +iptables -t nat -A SBX_KUBE_PROXY_TEST -m statistic --mode random --probability 0.5 -j RETURN +nft add table ip sandbox_test +nft add chain ip sandbox_test service +nft add rule ip sandbox_test service meta mark set numgen random mod 2 +", + ), + ) + .await; + assert!( + output.status.success(), + "nested container networking kernel probes failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn assert_resource_update_and_root_growth( + backend: &MicrosandboxProvider, + service: &SandboxService, + request: &mut EnsureSandboxRequest, + sandbox: Sandbox, +) -> Sandbox { + request.spec_mut().resources = direct_resources("2", "768Mi", "5Gi"); + let (resized, events) = collect_progress(service.ensure(request)) + .await + .expect("Sandbox resources should be updated"); + assert_eq!(resized.id, sandbox.id); + assert_eq!(resized.resources, request.spec().resources); + assert!(events.iter().any( + |event| matches!(event, ProgressEvent::StepStarted { name, .. } if name == "Update Microsandbox VM resources") + )); + + let root_size = run(backend, &resized.id, shell("df -kP / | awk 'END { print $2 }'")).await; + let root_kib = String::from_utf8(root_size.stdout.to_vec()) + .expect("root filesystem size should be UTF-8") + .trim() + .parse::() + .expect("root filesystem size should be numeric"); + assert!( + root_kib > 4 * 1024 * 1024, + "root filesystem should have grown past 4 GiB" + ); + let cpus = run(backend, &resized.id, shell("nproc")).await; + assert_eq!(cpus.stdout.as_ref(), b"2\n", "the restarted VM should have 2 CPUs"); + + request.spec_mut().resources = direct_resources("2", "768Mi", "4Gi"); + let error = service + .ensure(request) + .await + .expect_err("Microsandbox root filesystem shrink should be rejected"); + assert!(matches!( + error, + sandbox::Error::Component { source, .. } + if matches!(*source, sandbox::Error::UnsupportedResourceChange { resource: "rootFilesystem", .. }) + )); + request.spec_mut().resources = direct_resources("2", "768Mi", "5Gi"); + resized +} + +async fn assert_reference_image_resolves(backend_home: PathBuf) { + let backend = Rc::new( + MicrosandboxProvider::open(backend_home) + .await + .expect("reference Backend should open"), + ); + let service = SandboxService::new(backend.clone()); + let request = EnsureSandboxRequest::new( + SandboxName::new("reference-worker").expect("test Sandbox name should be valid"), + SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: resources("1", "512Mi", "4Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Delete, + }, + ); + + let (sandbox, events) = collect_progress(service.ensure(&request)) + .await + .expect("OCI reference should resolve and start"); + assert!( + events + .iter() + .any(|event| matches!(event, ProgressEvent::StepStarted { name, .. } if name == "Pull OCI image")) + ); + let output = run(backend.as_ref(), &sandbox.id, shell("cat /etc/alpine-release")).await; + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).starts_with("3.22.")); + assert_hostname(backend.as_ref(), &sandbox, "reference-worker").await; + + service + .release(request.name(), request.spec().retention_policy) + .await + .expect("reference Sandbox should be deleted"); +} + +async fn assert_immediate_restart_and_delete( + backend: &MicrosandboxProvider, + request: &EnsureSandboxRequest, + sandbox: &Sandbox, +) { + backend.stop(&sandbox.id).await.expect("Sandbox should stop again"); + assert_eq!( + backend + .find(request.name()) + .await + .expect("stopped Sandbox should remain discoverable") + .state, + SandboxState::Stopped + ); + backend + .start(&sandbox.id) + .await + .expect("Sandbox should immediately restart again"); + + backend.delete(&sandbox.id).await.expect("Sandbox should be deleted"); +} + +async fn assert_build_cache_reused( + backend: MicrosandboxProvider, + request: &EnsureSandboxRequest, + home_id: &sandbox::volume::VolumeId, +) { + let backend = Rc::new(backend); + let service = SandboxService::new(backend.clone()); + let (sandbox, events) = collect_progress(service.ensure(request)) + .await + .expect("Sandbox should rebuild from the retained Docker cache"); + assert!( + step_output(&events, "Build Docker image").iter().any(|bytes| { + [b"CACHED".as_slice(), b"Using cache".as_slice()] + .iter() + .any(|marker| bytes.windows(marker.len()).any(|window| window == *marker)) + }), + "second Docker build should report a reused layer; events: {events:#?}" + ); + for skipped in ["Export Docker image", "Import Microsandbox image"] { + assert!( + !events + .iter() + .any(|event| matches!(event, ProgressEvent::StepStarted { name, .. } if name == skipped)), + "reused Microsandbox image should skip {skipped}" + ); + } + + backend + .delete(&sandbox.id) + .await + .expect("rebuilt Sandbox should be deleted"); + backend + .delete_volume(home_id) + .await + .expect("retained volume should be deleted"); +} + +async fn assert_terminal_execution(backend: &dyn sandbox::backend::SandboxBackend, sandbox: &Sandbox) { + let mut terminal = backend + .start_terminal_execution( + &sandbox.id, + StartTerminalExecutionRequest::new( + shell("read -r value; set -- $(stty size); printf 'terminal:%s:%sx%s\\n' \"$value\" \"$1\" \"$2\""), + TerminalSize::new(31, 97).expect("initial terminal size should be valid"), + ), + ) + .await + .expect("terminal Execution should start"); + terminal + .control + .resize(TerminalSize::new(42, 111).expect("resized terminal dimensions should be valid")) + .await + .expect("terminal should resize"); + terminal + .control + .write_input(Bytes::from_static(b"hello\n")) + .await + .expect("terminal input should be written"); + + let mut output = Vec::new(); + let status = loop { + match terminal + .events + .next() + .await + .expect("terminal event stream should report exit") + .expect("terminal event should succeed") + { + TerminalEvent::Output(bytes) => output.extend_from_slice(&bytes), + TerminalEvent::Exited(status) => break Ok(status), + TerminalEvent::Failed { message } => break Err(message), + _ => {} + } + } + .expect("terminal process should start and exit"); + let output = String::from_utf8_lossy(&output); + assert!(status.success()); + assert!( + output.contains("terminal:hello:42x111"), + "unexpected terminal output: {output}" + ); +} + +async fn collect_progress( + mut pending: sandbox::PendingSandbox<'_>, +) -> Result<(Sandbox, Vec), sandbox::Error> { + let mut events = Vec::new(); + while let Some(event) = pending.next().await { + match event? { + OperationEvent::Progress(event) => events.push(event), + OperationEvent::Ready(sandbox) => return Ok((sandbox.snapshot().clone(), events)), + _ => {} + } + } + Err(sandbox::Error::OperationStreamEnded) +} + +/// Output of every occurrence of the named step. +fn step_output<'a>(events: &'a [ProgressEvent], step: &str) -> Vec<&'a [u8]> { + let ids = events + .iter() + .filter_map(|event| match event { + ProgressEvent::StepStarted { id, name, .. } if name == step => Some(id), + _ => None, + }) + .collect::>(); + events + .iter() + .filter_map(|event| match event { + ProgressEvent::StepOutput { id, bytes, .. } if ids.contains(&id) => Some(bytes.as_ref()), + _ => None, + }) + .collect() +} + +fn assert_provisioning_progress(events: &[ProgressEvent]) { + for expected in [ + "Check Docker Engine", + "Build Docker image", + "Retain Docker build cache", + "Look up imported Microsandbox image", + "Create Microsandbox VM", + ] { + assert!( + events + .iter() + .any(|event| { matches!(event, ProgressEvent::StepStarted { name, .. } if name == expected) }) + ); + } +} + +struct RetainedOnFailureTempDir(Option); + +impl RetainedOnFailureTempDir { + fn new() -> Self { + Self(Some( + tempfile::tempdir().expect("temporary integration home should be created"), + )) + } + + fn path(&self) -> &std::path::Path { + self.0.as_ref().expect("temporary integration home should exist").path() + } +} + +impl Drop for RetainedOnFailureTempDir { + fn drop(&mut self) { + if std::thread::panicking() + && let Some(temporary) = self.0.take() + { + eprintln!( + "retaining failed Microsandbox integration home at {}", + temporary.keep().display() + ); + } + } +} + +async fn read(backend: &MicrosandboxProvider, id: &sandbox::SandboxId, path: &str) -> Vec { + let mut reader = backend + .read_file(id, &sandbox::SandboxPath::new(path)) + .await + .expect("Sandbox file should open"); + let mut contents = Vec::new(); + reader + .read_to_end(&mut contents) + .await + .expect("Sandbox file should stream out"); + contents +} + +fn resources(cpu: &str, memory: &str, root_filesystem: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + memory.parse::().expect("test memory should be valid"), + RootFilesystem::layered( + root_filesystem + .parse::() + .expect("test root filesystem should be valid"), + ), + ) +} + +fn direct_resources(cpu: &str, memory: &str, root_filesystem: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + memory.parse::().expect("test memory should be valid"), + RootFilesystem::direct( + root_filesystem + .parse::() + .expect("test root filesystem should be valid"), + ), + ) +} + +fn shell(script: &str) -> ExecutionSpec { + ExecutionSpec::command( + sandbox::SandboxPath::new("/bin/sh"), + ["-c".to_string(), script.to_string()], + ) +} + +async fn run( + backend: &dyn sandbox::backend::SandboxBackend, + sandbox_id: &sandbox::SandboxId, + spec: ExecutionSpec, +) -> execution::ExecutionOutput { + let execution = backend + .start_execution(sandbox_id, StartExecutionRequest::new(spec)) + .await + .expect("Execution should start"); + execution.collect().await.expect("Execution should exit") +} + +fn native_linux_platform() -> Platform { + Platform::new( + "linux", + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }, + ) +} diff --git a/sandbox/sandbox/Cargo.toml b/sandbox/sandbox/Cargo.toml new file mode 100644 index 0000000..73d8c06 --- /dev/null +++ b/sandbox/sandbox/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sandbox" +description = "Backend-neutral sandbox lifecycle building blocks" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +bytes.workspace = true +futures-core.workspace = true +serde.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tokio-util = { workspace = true, features = ["rt"] } +uuid.workspace = true +zeroize.workspace = true + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/sandbox/sandbox/examples/worktree/Cargo.toml b/sandbox/sandbox/examples/worktree/Cargo.toml new file mode 100644 index 0000000..211775e --- /dev/null +++ b/sandbox/sandbox/examples/worktree/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sandbox-worktree" +description = "Runs a coding harness in a retained Microsandbox VM with the current worktree bind-mounted" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true +version.workspace = true + +[dependencies] +clap.workspace = true +futures-util.workspace = true +sandbox = { path = "../.." } +sandbox-microsandbox = { path = "../../../sandbox-microsandbox" } +sha2.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/sandbox/sandbox/examples/worktree/Dockerfile b/sandbox/sandbox/examples/worktree/Dockerfile new file mode 100644 index 0000000..c98f7f9 --- /dev/null +++ b/sandbox/sandbox/examples/worktree/Dockerfile @@ -0,0 +1,176 @@ +FROM archlinux:base@sha256:b0deabeb3d283da2c7f7dbf0eea051b7b2cd0554e0b737cc457fd21683bdcdd1 + +# The repository's rust-toolchain.toml selects the toolchain inside a checkout, and Renovate moves +# this ARG together with it. The agent user (uid 1000, created below) owns RUSTUP_HOME, so a +# checkout pinned ahead of this image installs its toolchain on first use instead of failing. +ARG RUST_VERSION=1.97.1 + +ENV CARGO_HOME=/usr/local/cargo +ENV DOTNET_ROOT=/usr/local/share/dotnet +ENV RUSTUP_HOME=/usr/local/rustup +ENV PATH=/usr/local/cargo/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/bin + +RUN pacman -Syu --noconfirm --needed \ + base-devel \ + clang \ + curl \ + docker \ + docker-buildx \ + git \ + icu \ + iproute2 \ + jq \ + krb5 \ + lldb \ + openssl \ + perf \ + pkgconf \ + procps-ng \ + qemu-base \ + ripgrep \ + rustup \ + strace \ + sudo \ + tmux \ + && pacman -Scc --noconfirm \ + && rustup set profile minimal \ + && rustup default "${RUST_VERSION}" \ + && rustup component add clippy rustfmt \ + && chown -R 1000:1000 /usr/local/rustup + +ARG TARGETARCH +ARG DOTNET_VERSION=10.0.302 +ARG DOTNET_INSTALL_SCRIPT_COMMIT=6f559c420847ded38591392dafe785ad511f39f5 +ARG DOTNET_INSTALL_SCRIPT_SHA256=082f7685e156738a1b2e2ed8381a621870d4ce8e8c59278034556f05c186eb2e +ARG FLUX_VERSION=2.9.4 +ARG GO_VERSION=1.26.4 +ARG HELM_VERSION=3.21.1 +ARG KIND_VERSION=0.32.0 +ARG KUBECTL_VERSION=1.35.7 +ARG NODE_VERSION=22.23.2 + +RUN case "${TARGETARCH}" in \ + amd64) PORTABLE_ARCH=amd64; NODE_ARCH=x64; DOTNET_ARCH=x64 ;; \ + arm64) PORTABLE_ARCH=arm64; NODE_ARCH=arm64; DOTNET_ARCH=arm64 ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && GO_ARCHIVE="go${GO_VERSION}.linux-${PORTABLE_ARCH}.tar.gz" \ + && GO_SHA256="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' \ + | jq -r --arg version "go${GO_VERSION}" --arg filename "${GO_ARCHIVE}" \ + '.[] | select(.version == $version) | .files[] | select(.filename == $filename) | .sha256')" \ + && test -n "${GO_SHA256}" \ + && test "${GO_SHA256}" != "null" \ + && curl -fsSL "https://go.dev/dl/${GO_ARCHIVE}" -o /tmp/go.tar.gz \ + && echo "${GO_SHA256} /tmp/go.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/go.tar.gz -C /usr/local \ + && NODE_ARCHIVE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + && NODE_SHA256="$(curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/SHASUMS256.txt" \ + | awk -v filename="${NODE_ARCHIVE}" '$2 == filename { print $1 }')" \ + && test -n "${NODE_SHA256}" \ + && curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/${NODE_ARCHIVE}" -o /tmp/node.tar.xz \ + && echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \ + && tar -xJf /tmp/node.tar.xz --strip-components=1 -C /usr/local \ + && curl -fsSL \ + "https://raw.githubusercontent.com/dotnet/install-scripts/${DOTNET_INSTALL_SCRIPT_COMMIT}/src/dotnet-install.sh" \ + -o /tmp/dotnet-install.sh \ + && echo "${DOTNET_INSTALL_SCRIPT_SHA256} /tmp/dotnet-install.sh" | sha256sum -c - \ + && bash /tmp/dotnet-install.sh \ + --architecture "${DOTNET_ARCH}" \ + --install-dir "${DOTNET_ROOT}" \ + --no-path \ + --version "${DOTNET_VERSION}" \ + && ln -s "${DOTNET_ROOT}/dotnet" /usr/local/bin/dotnet \ + && KIND_BINARY="kind-linux-${PORTABLE_ARCH}" \ + && KIND_SHA256="$(curl -fsSL \ + "https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/${KIND_BINARY}.sha256sum" \ + | awk '{ print $1 }')" \ + && curl -fsSL \ + "https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/${KIND_BINARY}" \ + -o /tmp/kind \ + && echo "${KIND_SHA256} /tmp/kind" | sha256sum -c - \ + && install -m 0755 /tmp/kind /usr/local/bin/kind \ + && HELM_ARCHIVE="helm-v${HELM_VERSION}-linux-${PORTABLE_ARCH}.tar.gz" \ + && HELM_SHA256="$(curl -fsSL "https://get.helm.sh/${HELM_ARCHIVE}.sha256sum" | awk '{ print $1 }')" \ + && curl -fsSL "https://get.helm.sh/${HELM_ARCHIVE}" -o /tmp/helm.tar.gz \ + && echo "${HELM_SHA256} /tmp/helm.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/helm.tar.gz -C /tmp \ + && install -m 0755 "/tmp/linux-${PORTABLE_ARCH}/helm" /usr/local/bin/helm \ + && FLUX_ARCHIVE="flux_${FLUX_VERSION}_linux_${PORTABLE_ARCH}.tar.gz" \ + && FLUX_SHA256="$(curl -fsSL \ + "https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_checksums.txt" \ + | awk -v filename="${FLUX_ARCHIVE}" '$2 == filename { print $1 }')" \ + && test -n "${FLUX_SHA256}" \ + && curl -fsSL \ + "https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/${FLUX_ARCHIVE}" \ + -o /tmp/flux.tar.gz \ + && echo "${FLUX_SHA256} /tmp/flux.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \ + && KUBECTL_SHA256="$(curl -fsSL \ + "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${PORTABLE_ARCH}/kubectl.sha256")" \ + && curl -fsSL \ + "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${PORTABLE_ARCH}/kubectl" \ + -o /tmp/kubectl \ + && echo "${KUBECTL_SHA256} /tmp/kubectl" | sha256sum -c - \ + && install -m 0755 /tmp/kubectl /usr/local/bin/kubectl \ + && rm -rf \ + /tmp/dotnet-install.sh \ + /tmp/flux.tar.gz \ + /tmp/go.tar.gz \ + /tmp/helm.tar.gz \ + /tmp/kind \ + /tmp/kubectl \ + /tmp/linux-* \ + /tmp/node.tar.xz + +ARG YARN_VERSION=4.18.0 + +ENV COREPACK_HOME=/usr/local/share/corepack + +# Agent Sandboxes mount their CA bundle here; this step's Node downloads trust it. +RUN if [ -r /run/agent/tls/ca-bundle.pem ]; then \ + export NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem; \ + fi; \ + mkdir -p "${COREPACK_HOME}" \ + && corepack enable \ + && corepack install --global "yarn@${YARN_VERSION}" \ + && chmod -R a+rX "${COREPACK_HOME}" + +ARG CODEX_VERSION=0.159.3 +ARG CLAUDE_CODE_VERSION=2.1.286 + +RUN if [ -r /run/agent/tls/ca-bundle.pem ]; then \ + export NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem; \ + fi; \ + npm install --global "@openai/codex@${CODEX_VERSION}" \ + && npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" + +RUN { getent group docker >/dev/null || groupadd --system docker; } \ + && { getent group kvm >/dev/null || groupadd --system kvm; } \ + && useradd --create-home --shell /usr/bin/bash --uid 1000 --groups docker,kvm agent \ + && printf 'agent ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/agent \ + && chmod 0440 /etc/sudoers.d/agent \ + && install -d /etc/systemd/system/init.scope.d \ + && printf '[Scope]\nTasksMax=infinity\n' >/etc/systemd/system/init.scope.d/50-agent-tasks.conf \ + && install -d /usr/lib/sysctl.d \ + && printf 'fs.inotify.max_user_instances = 1024\nfs.inotify.max_user_watches = 1048576\nfs.inotify.max_queued_events = 32768\n' \ + >/usr/lib/sysctl.d/50-agent-inotify.conf \ + && systemctl mask serial-getty@hvc0.service \ + && systemctl enable docker.service \ + && rm -f /etc/machine-id /var/lib/dbus/machine-id \ + && touch /etc/machine-id + +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +ENV DOTNET_NOLOGO=1 +ENV DISABLE_AUTOUPDATER=1 +ENV PATH=/home/agent/.local/bin:${PATH} + +COPY tmpfiles.conf /usr/lib/tmpfiles.d/worktree.conf + +ENV HOME=/home/agent + +USER agent +WORKDIR /workspace/altinn-studio + +ENTRYPOINT ["/usr/lib/systemd/systemd"] +CMD ["codex", "--yolo"] diff --git a/sandbox/sandbox/examples/worktree/README.md b/sandbox/sandbox/examples/worktree/README.md new file mode 100644 index 0000000..55ef266 --- /dev/null +++ b/sandbox/sandbox/examples/worktree/README.md @@ -0,0 +1,25 @@ +# Worktree sandbox + +Runs Codex or Claude Code directly through the Sandbox SDK in a microVM (Microsandbox), with the current Git worktree +bind mounted along with Codex/Claude configuration from the current user's home directory. It exercises the Sandbox +layer alone, without `agentd`; the Agent-layer equivalent is `src/experimental/agent/examples/self-dev`. + +Installed tools: +- .NET 10 +- Rust +- Go +- nodejs +- container tooling: docker with Buildx, kind, kubectl, flux, helm + +This should allow the agent to build and use most/all altinn-studio projects. +Note that though this protects e.g. the host filesystem, it has permissive network access. +Defaults: 4 CPU, 8Gi memory, a 64Gi direct root filesystem and a 4Gi `/tmp`. + +Run from `src/experimental`: + +```sh +cargo run -p sandbox-worktree # Start Codex +cargo run -p sandbox-worktree -- --harness claude # Start Claude Code +cargo run -p sandbox-worktree -- delete # Delete the Sandbox +cargo run -p sandbox-worktree -- --name my-sandbox # Override the worktree-derived name +``` diff --git a/sandbox/sandbox/examples/worktree/src/main.rs b/sandbox/sandbox/examples/worktree/src/main.rs new file mode 100644 index 0000000..f55ea22 --- /dev/null +++ b/sandbox/sandbox/examples/worktree/src/main.rs @@ -0,0 +1,297 @@ +use std::{ + collections::BTreeMap, + env, + error::Error, + io, + path::{Path, PathBuf}, + process::Command as ProcessCommand, + rc::Rc, +}; + +use clap::{Parser, Subcommand, ValueEnum}; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Platform, RetentionPolicy, RootFilesystem, SandboxFeature, + SandboxName, SandboxPath, SandboxResources, SandboxService, SandboxSpec, + execution::{ExecutionSpec, ExitStatus, Program}, + image::ImageSource, + mount::Mount, + terminal::{AttachTerminalRequest, TerminalAttachOutcome}, +}; +use sandbox_microsandbox::MicrosandboxProvider; +use sha2::{Digest as _, Sha256}; + +mod progress; + +const SANDBOX_HOME: &str = "/home/agent"; +const SANDBOX_REPOSITORY: &str = "/workspace/altinn-studio"; +const SANDBOX_WORKSPACE: &str = "/workspace/altinn-studio"; +const WORKTREE_ID_HEX_LENGTH: usize = 12; + +#[derive(Debug, Parser)] +#[command(about = "Develop the Sandbox SDK from a retained Microsandbox VM")] +struct Arguments { + /// Sandbox name. Defaults to a stable name derived from the current worktree. + #[arg(long, global = true)] + name: Option, + + /// CPU assigned to the development Sandbox. + #[arg(long, default_value = "4")] + cpu: CpuQuantity, + + /// Memory assigned to the development Sandbox. + #[arg(long, default_value = "8Gi")] + memory: ByteQuantity, + + /// Writable root filesystem capacity. + #[arg(long, default_value = "64Gi")] + root_filesystem: ByteQuantity, + + /// Interactive coding harness to start. + #[arg(long, value_enum, default_value_t)] + harness: Harness, + + #[command(subcommand)] + command: Option, +} + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +enum Harness { + #[default] + Codex, + Claude, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Delete the Sandbox. + Delete, +} + +struct HostPaths { + claude_home: PathBuf, + codex_home: PathBuf, + repository: PathBuf, +} + +#[tokio::main(flavor = "local")] +async fn main() -> Result<(), Box> { + let arguments = Arguments::parse(); + let host_home = host_home()?; + let repository = worktree_repository()?; + let sandbox_name = arguments + .name + .clone() + .map_or_else(|| worktree_sandbox_name(&repository), Ok)?; + let state_home = resolve_state_home(&host_home)?; + let service = SandboxService::new(Rc::new( + MicrosandboxProvider::open(state_home.join("microsandbox")).await?, + )); + match arguments.command { + Some(Command::Delete) => { + progress::wait_for_operation("Delete Sandbox", service.delete(&sandbox_name)).await?; + return Ok(()); + } + None => {} + } + + let paths = resolve_host_paths(&host_home, repository)?; + let request = EnsureSandboxRequest::new( + sandbox_name.clone(), + SandboxSpec { + image: ImageSource::Build { + context: PathBuf::from(env!("CARGO_MANIFEST_DIR")), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: native_linux_platform(), + resources: SandboxResources::new( + arguments.cpu, + arguments.memory, + RootFilesystem::direct(arguments.root_filesystem), + ), + init_system: sandbox::init::InitSystem::Image, + retention_policy: RetentionPolicy::Retain, + }, + ) + .with_mounts([ + Mount::Bind { + source: paths.codex_home, + target: SandboxPath::new(format!("{SANDBOX_HOME}/.codex")), + read_only: false, + }, + Mount::Bind { + source: paths.claude_home, + target: SandboxPath::new(format!("{SANDBOX_HOME}/.claude")), + read_only: false, + }, + Mount::Bind { + source: paths.repository, + target: SandboxPath::new(SANDBOX_REPOSITORY), + read_only: false, + }, + Mount::Tmpfs { + target: SandboxPath::new("/tmp"), + capacity: "4Gi".parse()?, + }, + ]) + .requiring_features([SandboxFeature::NestedContainers, SandboxFeature::TerminalAttach]); + let sandbox = progress::wait_for_sandbox(service.ensure(&request)).await?; + + let run_result = match sandbox + .attach_terminal(AttachTerminalRequest::new(interactive_spec(arguments.harness))) + .await + { + Ok(TerminalAttachOutcome::Exited(status)) => Ok(status), + Ok(TerminalAttachOutcome::Detached) => Ok(ExitStatus { code: 0 }), + Ok(_) => Err(io::Error::other("unsupported terminal attachment outcome").into()), + Err(error) => Err(Box::new(error) as Box), + }; + let release_result = progress::wait_for_operation("Stop Sandbox", sandbox.release()).await; + let status = combine_run_and_release(run_result, release_result)?; + if !status.success() { + std::process::exit(status.code); + } + Ok(()) +} + +fn combine_run_and_release( + run: Result>, + release: Result<(), Box>, +) -> Result> { + match (run, release) { + (Ok(status), Ok(())) => Ok(status), + (Err(run), Ok(())) => Err(run), + (Ok(_), Err(release)) => Err(release), + (Err(run), Err(release)) => Err(io::Error::other(format!( + "Terminal Execution failed: {run}; stopping the Sandbox also failed: {release}" + )) + .into()), + } +} + +fn interactive_spec(harness: Harness) -> ExecutionSpec { + ExecutionSpec::new(harness.program()) + .with_working_directory(SandboxPath::new(SANDBOX_WORKSPACE)) + .with_environment(sandbox_environment()) +} + +impl Harness { + fn program(self) -> Program { + let (executable, args) = match self { + Self::Codex => ("/usr/local/bin/codex", vec!["--yolo".to_string()]), + Self::Claude => ( + "/usr/local/bin/claude", + vec!["--dangerously-skip-permissions".to_string()], + ), + }; + Program::Command { + executable: SandboxPath::new(executable), + args, + } + } +} + +fn sandbox_environment() -> BTreeMap { + BTreeMap::from([ + ("CARGO_HOME".to_string(), format!("{SANDBOX_HOME}/.cargo")), + ( + "CARGO_TARGET_DIR".to_string(), + format!("{SANDBOX_HOME}/.cache/sandbox-worktree/target"), + ), + ("CODEX_HOME".to_string(), format!("{SANDBOX_HOME}/.codex")), + ("CLAUDE_CONFIG_DIR".to_string(), format!("{SANDBOX_HOME}/.claude")), + ("HOME".to_string(), SANDBOX_HOME.to_string()), + ]) +} + +fn resolve_host_paths(host_home: &Path, repository: PathBuf) -> Result> { + let codex_home = harness_home("CODEX_HOME", ".codex", "Codex home", host_home)?; + let claude_home = harness_home("CLAUDE_CONFIG_DIR", ".claude", "Claude home", host_home)?; + Ok(HostPaths { + claude_home, + codex_home, + repository, + }) +} + +fn worktree_repository() -> Result> { + let repository = current_git_repository()?; + if !repository.join("Cargo.toml").is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{} does not contain the Altinn Studio Rust workspace", + repository.display() + ), + ) + .into()); + } + Ok(repository) +} + +fn worktree_sandbox_name(repository: &Path) -> Result { + let digest = Sha256::digest(repository.as_os_str().as_encoded_bytes()); + let mut suffix = String::with_capacity(WORKTREE_ID_HEX_LENGTH); + for &byte in &digest[..WORKTREE_ID_HEX_LENGTH / 2] { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + suffix.push(char::from(DIGITS[usize::from(byte >> 4)])); + suffix.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + SandboxName::new(format!("worktree-{suffix}")) +} + +fn harness_home(variable: &str, default: &str, label: &str, host_home: &Path) -> Result { + let path = env::var_os(variable).map_or_else(|| host_home.join(default), PathBuf::from); + std::fs::create_dir_all(&path)?; + canonical_directory(&path, label) +} + +fn resolve_state_home(host_home: &Path) -> Result { + let state_home = host_home.join(".sandbox/worktree"); + std::fs::create_dir_all(&state_home)?; + canonical_directory(&state_home, "Sandbox state home") +} + +fn current_git_repository() -> Result> { + let output = ProcessCommand::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output()?; + if !output.status.success() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "current directory is not inside a Git repository", + ) + .into()); + } + let path = String::from_utf8(output.stdout)?; + Ok(canonical_directory(Path::new(path.trim()), "repository")?) +} + +fn canonical_directory(path: &Path, label: &str) -> Result { + let path = std::fs::canonicalize(path)?; + if !path.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{label} {} is not a directory", path.display()), + )); + } + Ok(path) +} + +fn host_home() -> Result { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set")) +} + +fn native_linux_platform() -> Platform { + Platform::new( + "linux", + match env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }, + ) +} diff --git a/sandbox/sandbox/examples/worktree/src/progress.rs b/sandbox/sandbox/examples/worktree/src/progress.rs new file mode 100644 index 0000000..5d55164 --- /dev/null +++ b/sandbox/sandbox/examples/worktree/src/progress.rs @@ -0,0 +1,320 @@ +use std::{ + error::Error as StdError, + future::Future, + io::{self, IsTerminal as _}, + pin::pin, + time::{Duration, Instant}, +}; + +use futures_util::StreamExt as _; +use sandbox::{ + Error, OperationEvent, Outcome, PendingSandbox, ProgressUnit, SandboxHandle, + progress::{Measurement, Progress, ProgressCursor, Update}, +}; +use tokio::time::{MissedTickBehavior, interval}; + +const SPINNER_INTERVAL: Duration = Duration::from_millis(80); +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +/// Output lines shown when an operation fails in an interactive terminal. +const FAILURE_OUTPUT_LINES: usize = 40; + +pub(crate) async fn wait_for_sandbox(mut pending: PendingSandbox<'_>) -> Result> { + let mut display = ProgressDisplay::stderr(); + let mut progress = Progress::new(); + let mut ticker = spinner_ticker(); + + loop { + tokio::select! { + event = pending.next() => match event { + Some(Ok(OperationEvent::Progress(event))) => { + progress.apply(&event); + display.show(&progress)?; + } + Some(Ok(OperationEvent::Ready(sandbox))) => { + progress.succeed(); + display.show(&progress)?; + display.ready()?; + return Ok(sandbox); + } + Some(Ok(_)) => {} + Some(Err(error)) => { + progress.fail(error.to_string()); + display.failed(&progress)?; + return Err(error.into()); + } + None => { + progress.fail(Error::OperationStreamEnded.to_string()); + display.failed(&progress)?; + return Err(Error::OperationStreamEnded.into()); + } + }, + _ = ticker.tick() => display.tick(&progress)?, + } + } +} + +pub(crate) async fn wait_for_operation( + label: &str, + operation: impl Future>, +) -> Result> +where + E: StdError + 'static, +{ + let mut display = ProgressDisplay::stderr(); + display.start(label)?; + let started = Instant::now(); + let mut operation = pin!(operation); + let mut ticker = spinner_ticker(); + + loop { + tokio::select! { + result = &mut operation => { + let elapsed = started.elapsed(); + return match result { + Ok(value) => { + display.operation_completed(label, elapsed)?; + Ok(value) + } + Err(error) => { + display.operation_failed(label, elapsed)?; + Err(Box::new(error)) + } + }; + } + _ = ticker.tick() => display.tick_label(label)?, + } + } +} + +fn spinner_ticker() -> tokio::time::Interval { + let mut ticker = interval(SPINNER_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + ticker +} + +struct ProgressDisplay { + output: W, + interactive: bool, + cursor: ProgressCursor, + /// Phase and steps already announced in plain output. + announced: Option<(String, Vec)>, + frame: usize, + line_visible: bool, +} + +impl ProgressDisplay { + fn stderr() -> Self { + let output = io::stderr(); + let interactive = output.is_terminal(); + Self::new(output, interactive) + } +} + +impl ProgressDisplay { + fn new(output: W, interactive: bool) -> Self { + Self { + output, + interactive, + cursor: ProgressCursor::default(), + announced: None, + frame: 0, + line_visible: false, + } + } + + /// Prints what finished since the last call, then the activity in progress. + fn show(&mut self, progress: &Progress) -> io::Result<()> { + let updates = self.cursor.updates(progress); + if !updates.is_empty() { + self.clear_line()?; + } + for update in updates { + match update { + Update::OutputSkipped(count) if !self.interactive => { + writeln!(self.output, " … {count} lines skipped")?; + } + Update::Output(line) if !self.interactive => writeln!(self.output, " {}", line.text)?, + Update::StepFinished(step) if !self.interactive => { + let elapsed = Duration::from_millis(step.elapsed_ms); + match (step.outcome, step.measurement) { + (Outcome::Failed, _) => writeln!(self.output, " ✗ {} ({})", step.name, duration(elapsed))?, + (_, Some(measurement)) => writeln!( + self.output, + " ✓ {}: {} ({})", + step.name, + format_measurement(measurement), + duration(elapsed) + )?, + _ => writeln!(self.output, " ✓ {} ({})", step.name, duration(elapsed))?, + } + } + Update::PhaseFinished(phase) => { + let elapsed = duration(Duration::from_millis(phase.elapsed_ms)); + match phase.outcome { + Outcome::Reused => writeln!(self.output, "✓ Reused {} ({elapsed})", phase.phase.label)?, + Outcome::Failed => writeln!(self.output, "✗ {} ({elapsed})", phase.phase.label)?, + _ => writeln!(self.output, "✓ {} ({elapsed})", phase.phase.label)?, + } + } + Update::OutputSkipped(_) | Update::Output(_) | Update::StepFinished(_) => {} + } + } + if self.interactive { + self.render(progress) + } else { + self.announce(progress) + } + } + + /// Names a newly started phase or step in plain output. + fn announce(&mut self, progress: &Progress) -> io::Result<()> { + let Some(current) = progress.current() else { + return Ok(()); + }; + if self + .announced + .as_ref() + .is_none_or(|(phase, _)| *phase != current.phase.id) + { + writeln!(self.output, "→ {}", current.phase.label)?; + self.announced = Some((current.phase.id.to_string(), Vec::new())); + } + if let Some((_, steps)) = &mut self.announced { + for step in ¤t.steps { + let id = step.id.to_string(); + if !steps.contains(&id) { + writeln!(self.output, " → {}", step.name)?; + steps.push(id); + } + } + } + Ok(()) + } + + fn ready(&mut self) -> io::Result<()> { + self.clear_line()?; + writeln!(self.output, "✓ Sandbox ready") + } + + fn failed(&mut self, progress: &Progress) -> io::Result<()> { + self.show(progress)?; + self.clear_line()?; + if self.interactive && !progress.output().is_empty() { + writeln!(self.output, " Backend output:")?; + for line in progress.output().tail(FAILURE_OUTPUT_LINES) { + writeln!(self.output, " {}", line.text)?; + } + } + Ok(()) + } + + fn operation_completed(&mut self, label: &str, elapsed: Duration) -> io::Result<()> { + self.clear_line()?; + writeln!(self.output, "✓ {label} ({})", duration(elapsed)) + } + + fn operation_failed(&mut self, label: &str, elapsed: Duration) -> io::Result<()> { + self.clear_line()?; + writeln!(self.output, "✗ {label} ({})", duration(elapsed)) + } + + fn start(&mut self, label: &str) -> io::Result<()> { + if self.interactive { + self.render_line(label) + } else { + writeln!(self.output, "→ {label}") + } + } + + fn tick(&mut self, progress: &Progress) -> io::Result<()> { + if !self.interactive || progress.current().is_none() { + return Ok(()); + } + self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); + self.render(progress) + } + + fn tick_label(&mut self, label: &str) -> io::Result<()> { + if !self.interactive { + return Ok(()); + } + self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); + self.render_line(label) + } + + fn render(&mut self, progress: &Progress) -> io::Result<()> { + let Some(current) = progress.current() else { + return Ok(()); + }; + let mut line = current.phase.label.to_string(); + if let Some(step) = progress.current_step() { + line.push_str(" · "); + line.push_str(&step.name); + if let Some(measurement) = step.measurement { + line.push_str(": "); + line.push_str(&format_measurement(measurement)); + } + } + self.render_line(&line) + } + + fn render_line(&mut self, line: &str) -> io::Result<()> { + write!(self.output, "\r\x1b[2K{} {line}", SPINNER_FRAMES[self.frame])?; + self.output.flush()?; + self.line_visible = true; + Ok(()) + } + + fn clear_line(&mut self) -> io::Result<()> { + if self.interactive && self.line_visible { + write!(self.output, "\r\x1b[2K")?; + self.output.flush()?; + self.line_visible = false; + } + Ok(()) + } +} + +fn format_measurement(measurement: Measurement) -> String { + format_progress(measurement.completed, measurement.total, measurement.unit) +} + +fn format_progress(completed: u64, total: Option, unit: ProgressUnit) -> String { + match (unit, total) { + (ProgressUnit::Bytes, Some(total)) => format!("{} / {}", bytes(completed), bytes(total)), + (ProgressUnit::Bytes, None) => bytes(completed), + (ProgressUnit::Items, Some(total)) => format!("{completed} / {total}"), + _ => completed.to_string(), + } +} + +fn bytes(value: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + const GIB: u64 = MIB * 1024; + if value >= GIB { + scaled_bytes(value, GIB, "GiB") + } else if value >= MIB { + scaled_bytes(value, MIB, "MiB") + } else if value >= KIB { + scaled_bytes(value, KIB, "KiB") + } else { + format!("{value} B") + } +} + +fn scaled_bytes(value: u64, unit: u64, suffix: &str) -> String { + let whole = value / unit; + let decimal = (value % unit).saturating_mul(10) / unit; + format!("{whole}.{decimal} {suffix}") +} + +fn duration(value: Duration) -> String { + if value.as_secs() >= 60 { + format!("{}m {:02}s", value.as_secs() / 60, value.as_secs() % 60) + } else if value.as_secs() > 0 { + format!("{:.1}s", value.as_secs_f64()) + } else { + format!("{}ms", value.as_millis()) + } +} diff --git a/sandbox/sandbox/examples/worktree/tmpfiles.conf b/sandbox/sandbox/examples/worktree/tmpfiles.conf new file mode 100644 index 0000000..1111441 --- /dev/null +++ b/sandbox/sandbox/examples/worktree/tmpfiles.conf @@ -0,0 +1,2 @@ +d /home/agent 0755 agent agent - +z /dev/kvm 0660 root kvm - diff --git a/sandbox/sandbox/src/backend.rs b/sandbox/sandbox/src/backend.rs new file mode 100644 index 0000000..9b1c291 --- /dev/null +++ b/sandbox/sandbox/src/backend.rs @@ -0,0 +1,293 @@ +//! Interfaces implemented by Sandbox Providers. +//! +//! Application code normally provisions through [`crate::SandboxService`] and +//! operates the resulting [`crate::SandboxHandle`]. + +use std::{collections::BTreeMap, future::Future, pin::Pin}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub use crate::feature::SandboxBackendCapabilities; + +use crate::{ + Error, Hostname, PendingOperation, Platform, RootFilesystem, SandboxName, SandboxPath, execution, file_transfer, + image, + init::InitSystem, + mount::Mount, + network, + resource::{ByteQuantity, CpuQuantity}, + terminal, volume, +}; + +/// A non-`Send` future executed by a Tokio local runtime. +pub type LocalFuture<'a, T> = Pin + 'a>>; + +/// Identifies one materialization independently of backend-specific identifiers. +/// +/// The Sandbox lifecycle service assigns a fresh ID before calling a Backend's +/// create operation. Deleting and recreating the same [`SandboxName`] produces +/// a different ID. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SandboxId(Uuid); + +impl SandboxId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the UUID representation. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for SandboxId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for SandboxId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// The lifecycle state reported by a sandbox backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SandboxState { + /// The Sandbox exists but is not running. + Stopped, + /// The Sandbox is running. + Running, +} + +/// Host-observed evidence that a running guest is making progress. +/// +/// A Backend reports it without a round trip to the guest, so a guest that no +/// longer responds still reports its last heartbeat. Only a change of the +/// sequence is meaningful: it restarts whenever the Sandbox starts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GuestHeartbeat(u64); + +impl GuestHeartbeat { + /// Wraps a heartbeat sequence observed by a Backend. + #[must_use] + pub const fn new(sequence: u64) -> Self { + Self(sequence) + } + + /// Returns the observed sequence. + #[must_use] + pub const fn sequence(self) -> u64 { + self.0 + } +} + +/// Desired compute and writable root filesystem resources assigned to one Sandbox. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SandboxResources { + cpu: CpuQuantity, + memory: ByteQuantity, + root_filesystem: RootFilesystem, +} + +impl SandboxResources { + /// Creates a positive, normalized resource assignment. + #[must_use] + pub const fn new(cpu: CpuQuantity, memory: ByteQuantity, root_filesystem: RootFilesystem) -> Self { + Self { + cpu, + memory, + root_filesystem, + } + } + + /// Returns the desired CPU quantity. + #[must_use] + pub const fn cpu(self) -> CpuQuantity { + self.cpu + } + + /// Returns the desired Sandbox memory quantity. + #[must_use] + pub const fn memory(self) -> ByteQuantity { + self.memory + } + + /// Returns the desired writable root filesystem capacity. + #[must_use] + pub const fn root_filesystem(self) -> RootFilesystem { + self.root_filesystem + } +} + +/// Materialized inputs passed to a sandbox backend. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateSandboxRequest { + /// Backend-neutral identity assigned by the lifecycle owner. + pub id: SandboxId, + /// The resolved image to run. + pub image: image::ResolvedImage, + /// The stable caller-provided name. + pub name: SandboxName, + /// Hostname reported inside the Sandbox, resolved by the lifecycle owner. + pub hostname: Hostname, + /// Desired mutable compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + pub init_system: InitSystem, + /// Filesystem attachments materialized when the Sandbox is created. + pub mounts: Vec, + /// Non-secret environment inherited by image init and Sandbox Executions. + pub environment: BTreeMap, + /// Immutable Network attachment selected by the caller. + pub network: Option, +} + +/// A backend-neutral view of a materialized sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Sandbox { + /// The image used to create this Sandbox. + pub image: image::ResolvedImage, + /// The stable backend-neutral identifier. + pub id: SandboxId, + /// The caller-provided name. + pub name: SandboxName, + /// Hostname the Sandbox reports to its guest. + pub hostname: Hostname, + /// Current desired compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + pub init_system: InitSystem, + /// The current lifecycle state. + pub state: SandboxState, + /// The latest guest heartbeat, when the Backend observes one for a running + /// Sandbox. Absent while the guest boots and when the Backend cannot tell. + pub guest_heartbeat: Option, + /// Filesystem attachments materialized in the Sandbox. + pub mounts: Vec, + /// Non-secret environment inherited by image init and Sandbox Executions. + pub environment: BTreeMap, + /// Immutable Network attachment materialized with the Sandbox. + pub network: Option, +} + +/// Provides core Sandbox lifecycle, execution, runtime file transfer, storage, +/// and mount behavior. +/// +/// Network enforcement and agent automation intentionally remain outside this +/// interface. A Backend reports and opens data-plane endpoints that an +/// independently implemented [`network::NetworkBackend`] can consume. +pub trait SandboxBackend { + /// Reports functionality implemented for a supported Platform. + /// + /// An offered endpoint must be the exclusive path for Sandbox egress. The + /// Backend blocks traffic not represented by the selected endpoint rather + /// than allowing it to bypass the Network Backend. + fn capabilities<'a>(&'a self, platform: &'a Platform) + -> LocalFuture<'a, Result>; + + /// Creates a stopped Sandbox. + fn create(&self, request: CreateSandboxRequest) -> PendingOperation<'_, Sandbox>; + + /// Reconciles the mutable resource assignment of an existing Sandbox. + fn update_resources<'a>(&'a self, id: &'a SandboxId, resources: SandboxResources) -> PendingOperation<'a, Sandbox>; + + /// Replaces the non-secret environment of a stopped Sandbox. + fn update_environment<'a>( + &'a self, + id: &'a SandboxId, + environment: BTreeMap, + ) -> PendingOperation<'a, Sandbox>; + + /// Finds a Sandbox by its stable caller-provided name. + fn find<'a>(&'a self, name: &'a SandboxName) -> LocalFuture<'a, Result>; + + /// Inspects a Sandbox by identifier. + fn inspect<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result>; + + /// Starts a Sandbox. + fn start<'a>(&'a self, id: &'a SandboxId) -> PendingOperation<'a, ()>; + + /// Stops a Sandbox. + fn stop<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; + + /// Deletes a Sandbox. + fn delete<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; + + /// Opens the data-plane endpoint for the Sandbox's immutable Network attachment. + fn open_network_endpoint<'a>( + &'a self, + id: &'a SandboxId, + ) -> LocalFuture<'a, Result>; + + /// Starts an Execution with its SDK-assigned identity and opens its transient event stream. + fn start_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: execution::StartExecutionRequest, + ) -> LocalFuture<'a, Result>; + + /// Starts a terminal Execution with its SDK-assigned identity and bidirectional input and output. + fn start_terminal_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> LocalFuture<'a, Result>; + + /// Attaches the caller's terminal to an interactive Execution. + fn attach_terminal<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::AttachTerminalRequest, + ) -> LocalFuture<'a, Result>; + + /// Requests graceful termination of a live Execution. + fn terminate_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Forces a live Execution to stop. + fn kill_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Opens one regular file in a running Sandbox for streamed reading. + fn read_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + ) -> LocalFuture<'a, Result>; + + /// Creates or replaces one regular file in a running Sandbox from a byte stream. + /// + /// The replacement is atomic: a concurrent reader in the Sandbox observes either the previous + /// file or the complete new one, never a truncated or partially written file. A replaced + /// regular file keeps its mode and ownership. + fn write_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + contents: file_transfer::ByteReader, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Creates or returns a named Volume, using the SDK-assigned identity when materializing it. + fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> LocalFuture<'_, Result>; + + /// Finds a Volume by name. + fn find_volume<'a>(&'a self, name: &'a volume::VolumeName) -> LocalFuture<'a, Result>; + + /// Deletes a Volume. + fn delete_volume<'a>(&'a self, id: &'a volume::VolumeId) -> LocalFuture<'a, Result<(), Error>>; +} diff --git a/sandbox/sandbox/src/execution.rs b/sandbox/sandbox/src/execution.rs new file mode 100644 index 0000000..02c0ec9 --- /dev/null +++ b/sandbox/sandbox/src/execution.rs @@ -0,0 +1,270 @@ +//! Streaming command execution inside a running Sandbox. + +use std::{collections::BTreeMap, future::poll_fn, pin::Pin}; + +use bytes::{Bytes, BytesMut}; +use futures_core::Stream; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Error, SandboxPath}; + +/// Identifies a live Execution within a Sandbox. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct ExecutionId(Uuid); + +impl ExecutionId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the UUID representation. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for ExecutionId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for ExecutionId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Program selection for an Execution. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Program { + /// Use the default OCI entrypoint and command from the Image. + ImageEntrypoint, + /// Run one executable with explicit arguments. + Command { + /// Executable path inside the Sandbox. + executable: SandboxPath, + /// Arguments passed directly to the executable. + args: Vec, + }, +} + +/// Desired command and process environment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ExecutionSpec { + /// Program to run. + program: Program, + /// Working directory inside the Sandbox, or the Image default when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + working_directory: Option, + /// Environment additions for the process. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + environment: BTreeMap, +} + +impl ExecutionSpec { + /// Creates a process specification from a program selection. + #[must_use] + pub const fn new(program: Program) -> Self { + Self { + program, + working_directory: None, + environment: BTreeMap::new(), + } + } + + /// Uses the Image's default OCI entrypoint and command. + #[must_use] + pub const fn image_entrypoint() -> Self { + Self::new(Program::ImageEntrypoint) + } + + /// Runs one executable with explicit arguments. + #[must_use] + pub fn command(executable: SandboxPath, args: impl IntoIterator) -> Self { + Self::new(Program::Command { + executable, + args: args.into_iter().collect(), + }) + } + + /// Sets the working directory inside the Sandbox. + #[must_use] + pub fn with_working_directory(mut self, path: SandboxPath) -> Self { + self.working_directory = Some(path); + self + } + + /// Adds environment variables for the process. + #[must_use] + pub fn with_environment(mut self, values: impl IntoIterator) -> Self { + self.environment.extend(values); + self + } + + /// Returns the selected program. + #[must_use] + pub const fn program(&self) -> &Program { + &self.program + } + + /// Returns the optional working directory. + #[must_use] + pub const fn working_directory(&self) -> Option<&SandboxPath> { + self.working_directory.as_ref() + } + + /// Returns process environment additions. + #[must_use] + pub const fn environment(&self) -> &BTreeMap { + &self.environment + } +} + +/// Starts an Execution in a running Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StartExecutionRequest { + /// Backend-neutral identity assigned before dispatch. + id: ExecutionId, + /// Desired command and process environment. + spec: ExecutionSpec, +} + +impl StartExecutionRequest { + /// Creates a request with a freshly assigned Execution identifier. + #[must_use] + pub fn new(spec: ExecutionSpec) -> Self { + Self { + id: ExecutionId::generate(), + spec, + } + } + + /// Returns the assigned Execution identifier. + #[must_use] + pub const fn id(&self) -> &ExecutionId { + &self.id + } + + /// Returns the desired command and process environment. + #[must_use] + pub const fn spec(&self) -> &ExecutionSpec { + &self.spec + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (ExecutionId, ExecutionSpec) { + (self.id, self.spec) + } +} + +/// Process exit status reported by a Sandbox Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ExitStatus { + /// Process exit code. + pub code: i32, +} + +impl ExitStatus { + /// Reports whether the process exited with code zero. + #[must_use] + pub const fn success(self) -> bool { + self.code == 0 + } +} + +/// One event from a live Execution. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ExecutionEvent { + /// The process started. + Started { + /// Backend-reported process identifier, when available. + process_id: Option, + }, + /// Raw standard-output bytes. + Stdout(Bytes), + /// Raw standard-error bytes. + Stderr(Bytes), + /// The process exited. + Exited(ExitStatus), + /// The process could not be started. + Failed { + /// Backend-neutral failure description. + message: String, + }, +} + +/// A non-`Send` stream of events from one live Execution. +pub type ExecutionEventStream = Pin>>>; + +/// A newly started, addressable Execution and its transient event stream. +pub struct StartedExecution { + /// Identifier used for Execution control operations. + pub id: ExecutionId, + /// Events emitted until the Execution exits or fails. + pub events: ExecutionEventStream, +} + +/// Collected output from a completed Execution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutionOutput { + /// Addressable Execution identifier. + pub id: ExecutionId, + /// Process exit status. + pub status: ExitStatus, + /// Complete raw standard output. + pub stdout: Bytes, + /// Complete raw standard error. + pub stderr: Bytes, +} + +impl StartedExecution { + /// Consumes the live event stream and collects all process output in memory. + /// + /// Callers expecting unbounded output should consume [`Self::events`] + /// directly. + /// + /// # Errors + /// + /// Returns an error when the process cannot start or the event stream ends + /// without an exit status. + pub async fn collect(mut self) -> Result { + let execution_id = self.id.clone(); + let mut stdout = BytesMut::new(); + let mut stderr = BytesMut::new(); + + while let Some(event) = poll_fn(|context| self.events.as_mut().poll_next(context)).await { + match event? { + ExecutionEvent::Started { .. } => {} + ExecutionEvent::Stdout(chunk) => stdout.extend_from_slice(&chunk), + ExecutionEvent::Stderr(chunk) => stderr.extend_from_slice(&chunk), + ExecutionEvent::Exited(status) => { + return Ok(ExecutionOutput { + id: execution_id, + status, + stdout: stdout.freeze(), + stderr: stderr.freeze(), + }); + } + ExecutionEvent::Failed { message } => { + return Err(Error::ExecutionFailed { + id: execution_id, + message, + }); + } + } + } + + Err(Error::ExecutionStreamEnded { id: execution_id }) + } +} diff --git a/sandbox/sandbox/src/feature.rs b/sandbox/sandbox/src/feature.rs new file mode 100644 index 0000000..a487bae --- /dev/null +++ b/sandbox/sandbox/src/feature.rs @@ -0,0 +1,170 @@ +//! Discoverable functionality reported by Sandbox SDK interfaces. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::{ + image::ImageOperationCapabilities, mount::MountKindSet, network::NetworkEndpointCapabilities, + root_filesystem::RootFilesystemModeSet, +}; + +/// Optional functionality that callers may require from a Sandbox implementation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum SandboxFeature { + /// Run addressable commands inside a Sandbox. + Execution, + /// Run bidirectional terminal Executions inside a Sandbox. + TerminalExecution, + /// Attach the caller's terminal to an interactive Sandbox Execution. + TerminalAttach, + /// Stream regular files to and from a running Sandbox. + FileTransfer, + /// Attach storage whose lifecycle is independent of a Sandbox. + PersistentVolumes, + /// Run a container engine inside the Sandbox. + NestedContainers, + /// Hand Sandbox initialization to the init system supplied by the Image. + ImageInit, +} + +/// A deterministic set of Sandbox Features. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct SandboxFeatureSet(BTreeSet); + +impl SandboxFeatureSet { + /// Creates an empty feature set. + #[must_use] + pub const fn new() -> Self { + Self(BTreeSet::new()) + } + + /// Reports whether one feature is available. + #[must_use] + pub fn contains(&self, feature: SandboxFeature) -> bool { + self.0.contains(&feature) + } + + /// Adds a feature. + pub fn insert(&mut self, feature: SandboxFeature) { + self.0.insert(feature); + } + + /// Adds every feature in another set. + pub fn extend(&mut self, other: &Self) { + self.0.extend(other.0.iter().copied()); + } + + /// Iterates over available features in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } +} + +impl From<[SandboxFeature; N]> for SandboxFeatureSet { + fn from(features: [SandboxFeature; N]) -> Self { + Self(features.into_iter().collect()) + } +} + +/// Platform-specific functionality reported by a Sandbox Backend. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SandboxBackendCapabilities { + /// Optional Sandbox operations implemented by the Backend. + pub features: SandboxFeatureSet, + /// Filesystem attachment forms accepted during Sandbox creation. + pub mounts: MountKindSet, + /// Root filesystem materialization modes accepted during Sandbox creation. + pub root_filesystems: RootFilesystemModeSet, + /// Network endpoint forms the Backend can expose. + pub network: NetworkEndpointCapabilities, +} + +/// Consumer-visible functionality available from a configured Sandbox Service. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SandboxCapabilities { + features: SandboxFeatureSet, + mount_kinds: MountKindSet, + root_filesystem_modes: RootFilesystemModeSet, + prepared_image_export: ImageOperationCapabilities, + prepared_image_import: ImageOperationCapabilities, + network_available: bool, +} + +impl SandboxCapabilities { + pub(crate) const fn new( + features: SandboxFeatureSet, + mount_kinds: MountKindSet, + root_filesystem_modes: RootFilesystemModeSet, + prepared_image_export: ImageOperationCapabilities, + prepared_image_import: ImageOperationCapabilities, + network_available: bool, + ) -> Self { + Self { + features, + mount_kinds, + root_filesystem_modes, + prepared_image_export, + prepared_image_import, + network_available, + } + } + + /// Returns optional Sandbox operations supported by the configured Provider. + #[must_use] + pub const fn features(&self) -> &SandboxFeatureSet { + &self.features + } + + /// Returns filesystem attachment forms accepted during Sandbox creation. + #[must_use] + pub const fn mount_kinds(&self) -> &MountKindSet { + &self.mount_kinds + } + + /// Returns root-filesystem modes accepted by both the Sandbox Backend and + /// the Image Backend's resolve operation. + #[must_use] + pub const fn root_filesystem_modes(&self) -> &RootFilesystemModeSet { + &self.root_filesystem_modes + } + + /// Returns prepared-image export support for this Platform. + #[must_use] + pub const fn prepared_image_export(&self) -> &ImageOperationCapabilities { + &self.prepared_image_export + } + + /// Returns prepared-image import support for this Platform. + #[must_use] + pub const fn prepared_image_import(&self) -> &ImageOperationCapabilities { + &self.prepared_image_import + } + + /// Reports whether the configured Network Backend can use this Provider. + #[must_use] + pub const fn network_available(&self) -> bool { + self.network_available + } +} + +impl SandboxBackendCapabilities { + /// Creates one coherent capability report. + #[must_use] + pub const fn new( + features: SandboxFeatureSet, + mounts: MountKindSet, + root_filesystems: RootFilesystemModeSet, + network: NetworkEndpointCapabilities, + ) -> Self { + Self { + features, + mounts, + root_filesystems, + network, + } + } +} diff --git a/sandbox/sandbox/src/file_transfer.rs b/sandbox/sandbox/src/file_transfer.rs new file mode 100644 index 0000000..4e9eda3 --- /dev/null +++ b/sandbox/sandbox/src/file_transfer.rs @@ -0,0 +1,61 @@ +//! Streaming transfer of regular files to and from a running Sandbox. + +use std::{path::Path, pin::Pin}; + +use tokio::{ + fs::File, + io::{AsyncRead, AsyncWriteExt as _}, +}; + +use crate::{Error, SandboxHandle, SandboxPath}; + +/// An owned, non-`Send` asynchronous byte reader. +pub type ByteReader = Pin>; + +/// Streams one host file into a running Sandbox. +/// +/// # Errors +/// +/// Returns an error when the host file cannot be opened or the Backend cannot +/// complete the transfer. +pub async fn copy_file_to_sandbox( + sandbox: &SandboxHandle, + host_path: &Path, + sandbox_path: &SandboxPath, +) -> Result<(), Error> { + let source = File::open(host_path).await.map_err(|source| Error::Io { + operation: "open host file for Sandbox transfer", + source, + })?; + + sandbox.write_file(sandbox_path, Box::pin(source)).await +} + +/// Streams one file from a running Sandbox into a host file. +/// +/// # Errors +/// +/// Returns an error when the Backend cannot read the Sandbox file or the host +/// file cannot be written. +pub async fn copy_file_from_sandbox( + sandbox: &SandboxHandle, + sandbox_path: &SandboxPath, + host_path: &Path, +) -> Result<(), Error> { + let mut source = sandbox.read_file(sandbox_path).await?; + let mut destination = File::create(host_path).await.map_err(|source| Error::Io { + operation: "create host file for Sandbox transfer", + source, + })?; + + tokio::io::copy(&mut source, &mut destination) + .await + .map_err(|source| Error::Io { + operation: "copy Sandbox file to host", + source, + })?; + destination.flush().await.map_err(|source| Error::Io { + operation: "flush host file copied from Sandbox", + source, + }) +} diff --git a/sandbox/sandbox/src/image.rs b/sandbox/sandbox/src/image.rs new file mode 100644 index 0000000..5b484a7 --- /dev/null +++ b/sandbox/sandbox/src/image.rs @@ -0,0 +1,365 @@ +//! Image materialization is separate from Sandbox lifecycle backends. + +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, LocalFuture, PendingOperation, Platform, RootFilesystemMode, RootFilesystemModeSet}; + +/// Transient credentials used while resolving an OCI registry reference. +/// +/// Credentials configure a Provider's image materialization domain. They are +/// never part of a persisted [`ImageSource`] or [`ResolvedImage`]. +#[derive(Clone, Eq, PartialEq)] +pub enum RegistryAuthentication { + /// Access the registry without credentials. + Anonymous, + /// Authenticate with a username and password or access token. + Basic { + /// Registry username. + username: String, + /// Registry password or access token. + password: String, + }, +} + +/// The portable OCI identity form supplied to an Image Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum ImageSourceKind { + /// A Dockerfile and build context. + Build, + /// An OCI registry reference. + Reference, +} + +/// Deterministic set of supported OCI Image Source forms. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ImageSourceKindSet(BTreeSet); + +impl ImageSourceKindSet { + /// Reports whether no Image Source form is supported. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Reports whether an Image Source form is supported. + #[must_use] + pub fn contains(&self, kind: ImageSourceKind) -> bool { + self.0.contains(&kind) + } + + /// Iterates over supported forms in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } +} + +impl From<[ImageSourceKind; N]> for ImageSourceKindSet { + fn from(kinds: [ImageSourceKind; N]) -> Self { + Self(kinds.into_iter().collect()) + } +} + +/// An operation exposed by an Image Backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ImageOperation { + /// Resolve an OCI identity into a provider-consumable image. + Resolve, + /// Export a provider-owned prepared representation. + PreparedImageExport, + /// Import a provider-owned prepared representation. + PreparedImageImport, +} + +/// OCI source forms and materialization modes supported by one Image operation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ImageOperationCapabilities { + /// OCI Image Source forms accepted by the operation. + pub sources: ImageSourceKindSet, + /// Root filesystem modes accepted by the operation. + pub root_filesystem_modes: RootFilesystemModeSet, +} + +impl ImageOperationCapabilities { + /// Creates one operation capability report. + #[must_use] + pub const fn new(sources: ImageSourceKindSet, root_filesystem_modes: RootFilesystemModeSet) -> Self { + Self { + sources, + root_filesystem_modes, + } + } + + /// Reports whether the operation accepts at least one source and mode pair. + /// + /// Operation capabilities describe the Cartesian product of the two sets, + /// so either set being empty makes the operation unavailable. + #[must_use] + pub fn is_available(&self) -> bool { + !self.sources.is_empty() && !self.root_filesystem_modes.is_empty() + } +} + +/// Platform-specific functionality reported by an Image Backend. +/// +/// An operation is unavailable when either of its capability sets is empty. +/// Materialization formats are provider-owned and are not transferable between +/// providers; the portable identity remains the OCI build or registry reference +/// from which a prepared image is derived. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ImageBackendCapabilities { + /// Supported image resolution inputs. + pub resolve: ImageOperationCapabilities, + /// Supported prepared-image exports. + pub prepared_image_export: ImageOperationCapabilities, + /// Supported prepared-image imports. + pub prepared_image_import: ImageOperationCapabilities, +} + +impl ImageBackendCapabilities { + /// Creates one coherent Image Backend capability report. + #[must_use] + pub const fn new( + resolve: ImageOperationCapabilities, + prepared_image_export: ImageOperationCapabilities, + prepared_image_import: ImageOperationCapabilities, + ) -> Self { + Self { + resolve, + prepared_image_export, + prepared_image_import, + } + } +} + +/// Describes how to obtain the immutable image for a sandbox. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase", tag = "type")] +pub enum ImageSource { + /// Build an image from a Dockerfile and context. + Build { + /// Build context, relative to the applied manifest by default. + context: PathBuf, + /// Dockerfile path relative to the build context. + dockerfile: PathBuf, + /// Optional named Dockerfile stage to build. + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, + /// Resolve an image from an OCI registry reference. + Reference { + /// OCI image reference, optionally pinned by digest. Tags are resolved + /// once when the Sandbox is created and do not update it in place. + reference: String, + }, +} + +impl ImageSource { + /// Returns this source's capability kind. + #[must_use] + pub const fn kind(&self) -> ImageSourceKind { + match self { + Self::Build { .. } => ImageSourceKind::Build, + Self::Reference { .. } => ImageSourceKind::Reference, + } + } + + /// Validates fields understood by the generic image layer. + /// + /// # Errors + /// + /// Returns [`Error::Invalid`] when a required path or reference is empty. + pub fn validate(&self) -> Result<(), Error> { + match self { + Self::Build { + context, + dockerfile, + target, + } => { + if context.as_os_str().is_empty() { + return Err(Error::invalid("image.context", "must not be empty")); + } + if dockerfile.as_os_str().is_empty() { + return Err(Error::invalid("image.dockerfile", "must not be empty")); + } + if target.as_ref().is_some_and(|target| target.trim().is_empty()) { + return Err(Error::invalid("image.target", "must not be empty")); + } + } + Self::Reference { reference } if reference.trim().is_empty() => { + return Err(Error::invalid("image.reference", "must not be empty")); + } + Self::Reference { .. } => {} + } + Ok(()) + } + + /// Resolves paths relative to a caller-supplied source directory. + #[must_use] + pub fn resolve_from(&self, source_directory: &std::path::Path) -> Self { + match self { + Self::Build { + context, + dockerfile, + target, + } => Self::Build { + context: if context.is_relative() { + source_directory.join(context) + } else { + context.clone() + }, + dockerfile: dockerfile.clone(), + target: target.clone(), + }, + Self::Reference { .. } => self.clone(), + } + } +} + +/// Inputs for resolving an image for one Sandbox Platform. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolveRequest { + /// Source description for the image. + pub source: ImageSource, + /// Platform the resulting image must support. + pub platform: Platform, + /// Filesystem representation required by the Sandbox consuming the image. + pub root_filesystem_mode: RootFilesystemMode, +} + +impl ResolveRequest { + pub(crate) fn validate(&self) -> Result<(), Error> { + self.source.validate()?; + self.platform.validate() + } +} + +/// An image resolved to a backend-consumable immutable digest and Platform. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResolvedImage { + /// The source used to resolve the image. + pub source: ImageSource, + /// The actual Platform selected from the image. + pub platform: Platform, + /// Immutable digest of the platform-specific OCI image manifest. + /// + /// Resolving a multi-platform image index selects its matching manifest; + /// an index digest is never returned here. + pub manifest_digest: String, +} + +/// Description of a transportable, fully materialized OCI image. +/// +/// A prepared image is a pristine derivative of [`ResolvedImage`], never a +/// parallel image identity. The artifact stored at the caller-selected path is +/// opaque: its representation is owned by the Provider and can only be returned +/// to a compatible Provider. The generic API assumes neither a guest operating +/// system nor a concrete filesystem format. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedImage { + /// Immutable OCI Image represented by the prepared artifact. + pub image: ResolvedImage, + /// Root filesystem representation for which the image was materialized. + pub root_filesystem_mode: RootFilesystemMode, + /// Content digest of the complete filesystem artifact. + pub artifact_digest: String, + /// Logical size of the uncompressed filesystem artifact. + pub virtual_size_bytes: u64, +} + +impl PreparedImage { + /// Checks that backend-returned metadata is coherent with the request. + /// + /// This validates metadata only. Artifact integrity and the binding between + /// the opaque artifact and requested OCI identity are Image Backend + /// obligations. + pub(crate) fn validate_for(&self, request: &ResolveRequest) -> Result<(), Error> { + self.image.validate()?; + if self.image.source != request.source { + return Err(Error::invalid( + "preparedImage.image.source", + "must match the requested Image Source", + )); + } + if !self.image.platform.satisfies(&request.platform) { + return Err(Error::ImagePlatformMismatch { + requested: Box::new(request.platform.clone()), + actual: Box::new(self.image.platform.clone()), + }); + } + if self.root_filesystem_mode != request.root_filesystem_mode { + return Err(Error::invalid( + "preparedImage.rootFilesystemMode", + "must match the requested root filesystem mode", + )); + } + if self.artifact_digest.is_empty() { + return Err(Error::invalid("preparedImage.artifactDigest", "must not be empty")); + } + if self.virtual_size_bytes == 0 { + return Err(Error::invalid( + "preparedImage.virtualSizeBytes", + "must be greater than zero", + )); + } + Ok(()) + } +} + +impl ResolvedImage { + pub(crate) fn validate(&self) -> Result<(), Error> { + self.source.validate()?; + self.platform.validate()?; + if self.manifest_digest.is_empty() { + return Err(Error::invalid("image.manifestDigest", "must not be empty")); + } + Ok(()) + } +} + +/// Owns image materialization for a paired Sandbox Backend. +/// +/// Every operation is capability-discovered per [`Platform`]. Implementations +/// must define all operations explicitly, including providers for which prepared +/// transport is unnecessary and therefore reported with empty capability sets. +pub trait ImageBackend { + /// Reports functionality available for one Platform. + /// + /// Discovery must be side-effect-free and stable for the duration of the + /// caller's operation. Implementations may perform asynchronous host discovery. + fn capabilities<'a>(&'a self, platform: &'a Platform) -> LocalFuture<'a, Result>; + + /// Builds, fetches, or reuses the requested image. + fn resolve<'a>(&'a self, request: &'a ResolveRequest) -> PendingOperation<'a, ResolvedImage>; + + /// Exports a resolved, fully materialized image to an opaque artifact. + /// + /// Returned metadata must be derived from the exported artifact and describe + /// the OCI identity and Platform actually materialized. + fn export_prepared_image<'a>( + &'a self, + request: &'a ResolveRequest, + destination: &'a Path, + ) -> PendingOperation<'a, PreparedImage>; + + /// Validates and imports an opaque prepared image into this Backend's + /// materialization domain. + /// + /// Before returning, the implementation must validate artifact integrity, + /// bind the artifact to the requested OCI identity and Platform, and return + /// metadata that reflects the validated artifact rather than merely asserting + /// values supplied by the request. + fn import_prepared_image<'a>( + &'a self, + request: &'a ResolveRequest, + source: &'a Path, + ) -> PendingOperation<'a, PreparedImage>; +} diff --git a/sandbox/sandbox/src/init.rs b/sandbox/sandbox/src/init.rs new file mode 100644 index 0000000..35d1602 --- /dev/null +++ b/sandbox/sandbox/src/init.rs @@ -0,0 +1,14 @@ +//! Selection of the process responsible for initializing a Sandbox. + +use serde::{Deserialize, Serialize}; + +/// Selects which init system owns the Sandbox after backend setup. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum InitSystem { + /// Keep the Sandbox Backend's built-in init process. + #[default] + Backend, + /// Hand initialization to the init system supplied by the Image. + Image, +} diff --git a/sandbox/sandbox/src/lib.rs b/sandbox/sandbox/src/lib.rs new file mode 100644 index 0000000..8f3d2e1 --- /dev/null +++ b/sandbox/sandbox/src/lib.rs @@ -0,0 +1,40 @@ +//! Backend-neutral sandbox lifecycle building blocks. +//! +//! This crate deliberately contains no agent automation. Higher layers may +//! compose these primitives without making CI users depend on agent code. + +pub mod backend; +pub mod execution; +mod feature; +pub mod file_transfer; +pub mod image; +pub mod init; +pub mod memory; +pub mod mount; +mod name; +pub mod network; +mod path; +mod platform; +pub mod progress; +pub mod provider; +pub mod resource; +mod root_filesystem; +pub mod secret_store; +mod service; +pub mod terminal; +pub mod volume; + +pub use backend::{GuestHeartbeat, LocalFuture, Sandbox, SandboxId, SandboxResources, SandboxState}; +pub use feature::{SandboxCapabilities, SandboxFeature, SandboxFeatureSet}; +pub use name::{Hostname, InvalidHostname, InvalidSandboxName, MAX_SANDBOX_NAME_BYTES, SandboxName}; +pub use path::SandboxPath; +pub use platform::Platform; +pub use progress::{ + MeasuredStep, OperationEvent, Outcome, OutputStream, PendingOperation, PendingSandbox, Phase, PhaseSpan, + ProgressEvent, ProgressReporter, ProgressStep, ProgressUnit, SandboxPhase, SandboxProgress, StepId, +}; +pub use resource::{ByteQuantity, CpuQuantity, ParseQuantityError}; +pub use root_filesystem::{RootFilesystem, RootFilesystemMode, RootFilesystemModeSet}; +pub use service::{ + EnsureSandboxRequest, Error, ErrorKind, ResourceKind, RetentionPolicy, SandboxHandle, SandboxService, SandboxSpec, +}; diff --git a/sandbox/sandbox/src/memory.rs b/sandbox/sandbox/src/memory.rs new file mode 100644 index 0000000..b4129ee --- /dev/null +++ b/sandbox/sandbox/src/memory.rs @@ -0,0 +1,1108 @@ +//! Single-threaded in-memory implementations used for tests and early wiring. + +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet, VecDeque}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + num::{NonZeroU32, NonZeroUsize}, + pin::Pin, + rc::Rc, + task::{Context, Poll}, +}; + +use futures_core::Stream; +use sha2::{Digest as _, Sha256}; +use tokio::{io::AsyncReadExt as _, sync::mpsc}; +use tokio_util::sync::PollSender; +use zeroize::Zeroizing; + +use crate::{ + Error, GuestHeartbeat, LocalFuture, PendingOperation, Platform, ResourceKind, RootFilesystemMode, + RootFilesystemModeSet, Sandbox, SandboxFeature, SandboxId, SandboxName, SandboxPath, SandboxResources, + SandboxState, + backend::{CreateSandboxRequest, SandboxBackend, SandboxBackendCapabilities}, + execution, file_transfer, image, + mount::{MountKind, MountKindSet}, + network, + provider::SandboxProvider, + secret_store::{SecretMaterial, SecretReference, SecretStore}, + terminal, volume, +}; + +impl SandboxProvider for Provider { + fn backend(&self) -> &dyn SandboxBackend { + self + } + + fn image_backend(&self) -> &dyn image::ImageBackend { + &MemoryImageBackend + } +} + +/// In-memory Sandbox Provider for tests and early wiring. +pub struct Provider { + state: Rc>, + supported_platforms: BTreeSet, +} + +#[derive(Default)] +struct BackendState { + by_id: BTreeMap, + by_name: BTreeMap, + executions: BTreeSet<(SandboxId, execution::ExecutionId)>, + files: BTreeMap<(SandboxId, SandboxPath), Vec>, + file_writes: Vec, + execution_specs: Vec, + matched_execution_events: VecDeque, + queued_execution_events: VecDeque>, + queued_terminal_events: VecDeque>, + volumes_by_id: BTreeMap, + volumes_by_name: BTreeMap, + network_endpoints: BTreeMap, + network_properties: BTreeMap, +} + +struct MatchedExecutionEvents { + predicate: Rc bool>, + events: Vec, +} + +impl Provider { + /// Creates an empty Provider. + #[must_use] + pub fn new() -> Self { + Self::with_platforms(test_platform(), []) + } + + /// Creates an empty Provider with one or more supported Platforms. + #[must_use] + pub fn with_platforms(platform: Platform, additional_platforms: impl IntoIterator) -> Self { + let mut supported_platforms: BTreeSet<_> = additional_platforms.into_iter().collect(); + supported_platforms.insert(platform); + Self { + state: Rc::new(RefCell::new(BackendState::default())), + supported_platforms, + } + } + + /// Returns the number of materialized sandboxes. + #[must_use] + pub fn count(&self) -> usize { + self.state.borrow().by_id.len() + } + + /// Supplies the events returned by the next Execution started by this Provider. + pub fn queue_execution_events(&self, events: Vec) { + self.state.borrow_mut().queued_execution_events.push_back(events); + } + + /// Supplies events for the next Execution whose specification matches the predicate. + /// + /// Unlike [`Self::queue_execution_events`], unrelated Executions do not + /// consume this response while exercising multi-command reconciliation. + pub fn queue_execution_events_matching( + &self, + predicate: impl Fn(&execution::ExecutionSpec) -> bool + 'static, + events: Vec, + ) { + self.state + .borrow_mut() + .matched_execution_events + .push_back(MatchedExecutionEvents { + predicate: Rc::new(predicate), + events, + }); + } + + /// Returns every normal Execution specification observed by this Provider. + #[must_use] + pub fn execution_specs(&self) -> Vec { + self.state.borrow().execution_specs.clone() + } + + /// Returns the path of every file write observed by this Provider, in order. + #[must_use] + pub fn file_writes(&self) -> Vec { + self.state.borrow().file_writes.clone() + } + + /// Supplies the events returned by the next terminal Execution. + pub fn queue_terminal_events(&self, events: Vec) { + self.state.borrow_mut().queued_terminal_events.push_back(events); + } + + /// Reports `heartbeat` as the guest's latest heartbeat until it is set + /// again or the Sandbox stops. + /// + /// # Errors + /// + /// Returns an error when the Sandbox does not exist. + pub fn set_guest_heartbeat(&self, id: &SandboxId, heartbeat: Option) -> Result<(), Error> { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + sandbox.guest_heartbeat = heartbeat; + Ok(()) + } + + fn set_state(&self, id: &SandboxId, state: SandboxState) -> Result<(), Error> { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + sandbox.state = state; + if state == SandboxState::Stopped { + sandbox.guest_heartbeat = None; + } + Ok(()) + } + + fn ensure_running(&self, id: &SandboxId) -> Result<(), Error> { + let storage = self.state.borrow(); + let sandbox = storage + .by_id + .get(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + if sandbox.state != SandboxState::Running { + return Err(Error::invalid("sandbox.state", "must be running")); + } + Ok(()) + } + + fn end_execution(&self, sandbox_id: &SandboxId, execution_id: &execution::ExecutionId) -> Result<(), Error> { + if self + .state + .borrow_mut() + .executions + .remove(&(sandbox_id.clone(), execution_id.clone())) + { + Ok(()) + } else { + Err(Error::not_found(ResourceKind::Execution, execution_id)) + } + } +} + +impl Default for Provider { + fn default() -> Self { + Self::new() + } +} + +/// In-memory Secret Store for tests and local composition. +#[derive(Clone, Default)] +pub struct MemorySecretStore { + values: Rc>>>>, +} + +impl SecretStore for MemorySecretStore { + fn set<'a>(&'a self, name: &'a str, value: &'a [u8]) -> LocalFuture<'a, Result> { + Box::pin(async move { + let reference = SecretReference::from_opaque(name); + self.values + .borrow_mut() + .insert(reference.clone(), Zeroizing::new(value.to_vec())); + Ok(reference) + }) + } + + fn resolve<'a>(&'a self, reference: &'a SecretReference) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.values + .borrow() + .get(reference) + .map(|value| SecretMaterial::new(value.to_vec())) + .ok_or_else(|| Error::not_found(ResourceKind::Secret, reference.as_str())) + }) + } +} + +struct MemoryExecutionEventStream { + events: VecDeque, + execution: Option<(SandboxId, execution::ExecutionId)>, + state: Rc>, +} + +struct MemoryTerminalEventStream { + events: VecDeque, + execution: Option<(SandboxId, execution::ExecutionId)>, + state: Rc>, +} + +struct MemoryTerminalControl { + execution: (SandboxId, execution::ExecutionId), + state: Rc>, +} + +impl Stream for MemoryExecutionEventStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + let event = self.events.pop_front(); + if (event.is_none() + || event.as_ref().is_some_and(|event| { + matches!( + event, + execution::ExecutionEvent::Exited(_) | execution::ExecutionEvent::Failed { .. } + ) + })) + && let Some(execution) = self.execution.take() + { + self.state.borrow_mut().executions.remove(&execution); + } + Poll::Ready(event.map(Ok)) + } +} + +impl Stream for MemoryTerminalEventStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + let event = self.events.pop_front(); + if (event.is_none() + || event.as_ref().is_some_and(|event| { + matches!( + event, + terminal::TerminalEvent::Exited(_) | terminal::TerminalEvent::Failed { .. } + ) + })) + && let Some(execution) = self.execution.take() + { + self.state.borrow_mut().executions.remove(&execution); + } + Poll::Ready(event.map(Ok)) + } +} +impl terminal::TerminalControl for MemoryTerminalControl { + fn write_input(&self, _bytes: bytes::Bytes) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.ensure_live() }) + } + + fn close_input(&self) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.ensure_live() }) + } + + fn resize(&self, _size: terminal::TerminalSize) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.ensure_live() }) + } +} + +impl MemoryTerminalControl { + fn ensure_live(&self) -> Result<(), Error> { + if self.state.borrow().executions.contains(&self.execution) { + Ok(()) + } else { + Err(Error::not_found(ResourceKind::Execution, &self.execution.1)) + } + } +} + +impl SandboxBackend for Provider { + fn capabilities<'a>( + &'a self, + platform: &'a Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + if !self.supported_platforms.contains(platform) { + return Err(Error::UnsupportedPlatform(platform.clone())); + } + Ok(SandboxBackendCapabilities::new( + [ + SandboxFeature::Execution, + SandboxFeature::TerminalExecution, + SandboxFeature::FileTransfer, + SandboxFeature::PersistentVolumes, + SandboxFeature::ImageInit, + ] + .into(), + MountKindSet::from([MountKind::Volume, MountKind::Bind, MountKind::Tmpfs]), + RootFilesystemModeSet::from([RootFilesystemMode::Layered, RootFilesystemMode::Direct]), + network::NetworkEndpointCapabilities::new() + .with_packet_medium(network::PacketMedium::Ethernet) + .with_packet_medium(network::PacketMedium::Ip), + )) + }) + } + + fn create(&self, request: CreateSandboxRequest) -> PendingOperation<'_, Sandbox> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + if let Some(id) = storage.by_name.get(&request.name) { + return storage + .by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id)); + } + let id = request.id; + if let Some(network::NetworkAttachment { + endpoint: network::NetworkEndpointSelection::Packet(medium), + .. + }) = &request.network + { + storage + .network_properties + .insert(id.clone(), packet_properties(*medium)?); + } + let sandbox = Sandbox { + id: id.clone(), + image: request.image, + init_system: request.init_system, + name: request.name, + hostname: request.hostname, + resources: request.resources, + state: SandboxState::Stopped, + guest_heartbeat: None, + mounts: request.mounts, + environment: request.environment, + network: request.network, + }; + storage.by_name.insert(sandbox.name.clone(), id.clone()); + storage.by_id.insert(id, sandbox.clone()); + Ok(sandbox) + }) + }) + } + + fn update_resources<'a>(&'a self, id: &'a SandboxId, resources: SandboxResources) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + if sandbox.resources.root_filesystem().mode() != resources.root_filesystem().mode() { + return Err(Error::Immutable("resources.rootFilesystem.mode")); + } + sandbox.resources = resources; + Ok(sandbox.clone()) + }) + }) + } + + fn update_environment<'a>( + &'a self, + id: &'a SandboxId, + environment: BTreeMap, + ) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + if sandbox.state != SandboxState::Stopped { + return Err(Error::invalid("sandbox.state", "must be stopped")); + } + sandbox.environment = environment; + Ok(sandbox.clone()) + }) + }) + } + + fn find<'a>(&'a self, name: &'a SandboxName) -> LocalFuture<'a, Result> { + Box::pin(async move { + let storage = self.state.borrow(); + let id = storage + .by_name + .get(name) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, name))?; + storage + .by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id)) + }) + } + + fn inspect<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.state + .borrow() + .by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id)) + }) + } + + fn start<'a>(&'a self, id: &'a SandboxId) -> PendingOperation<'a, ()> { + PendingOperation::run(move |_progress| Box::pin(async move { self.set_state(id, SandboxState::Running) })) + } + + fn stop<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.set_state(id, SandboxState::Stopped)?; + let mut storage = self.state.borrow_mut(); + storage.executions.retain(|(sandbox_id, _)| sandbox_id != id); + storage.network_endpoints.remove(id); + Ok(()) + }) + } + + fn delete<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .remove(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + storage.by_name.remove(&sandbox.name); + storage.executions.retain(|(sandbox_id, _)| sandbox_id != id); + storage.files.retain(|(sandbox_id, _), _| sandbox_id != id); + storage.network_endpoints.remove(id); + storage.network_properties.remove(id); + Ok(()) + }) + } + + fn open_network_endpoint<'a>( + &'a self, + id: &'a SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + let attachment = sandbox + .network + .as_ref() + .ok_or(Error::invalid("network", "Sandbox has no attachment"))?; + let network::NetworkEndpointSelection::Packet(_) = attachment.endpoint else { + return Err(Error::UnsupportedNetworkEndpoint(attachment.endpoint.clone())); + }; + let capacity = + NonZeroUsize::new(64).ok_or_else(|| Error::Backend("invalid memory queue capacity".into()))?; + let properties = storage + .network_properties + .get(id) + .cloned() + .ok_or_else(|| Error::Backend("missing persisted memory Network interface configuration".into()))?; + let (endpoint, peer) = packet_endpoint_pair(capacity, properties); + storage.network_endpoints.insert(id.clone(), peer); + Ok(network::NetworkEndpoint::Packet(endpoint)) + }) + } + + fn start_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: execution::StartExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let (id, spec) = request.into_parts(); + self.ensure_running(sandbox_id)?; + let mut storage = self.state.borrow_mut(); + let execution_key = (sandbox_id.clone(), id.clone()); + storage.executions.insert(execution_key.clone()); + storage.execution_specs.push(spec.clone()); + let matched = storage + .matched_execution_events + .iter() + .position(|response| (response.predicate)(&spec)) + .and_then(|index| storage.matched_execution_events.remove(index)) + .map(|response| response.events); + let events = matched + .or_else(|| storage.queued_execution_events.pop_front()) + .unwrap_or_else(|| { + vec![ + execution::ExecutionEvent::Started { process_id: None }, + execution::ExecutionEvent::Exited(execution::ExitStatus { code: 0 }), + ] + }); + + Ok(execution::StartedExecution { + id, + events: Box::pin(MemoryExecutionEventStream { + events: events.into(), + execution: Some(execution_key), + state: self.state.clone(), + }), + }) + }) + } + + fn start_terminal_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let (id, _spec, _initial_size) = request.into_parts(); + self.ensure_running(sandbox_id)?; + let mut storage = self.state.borrow_mut(); + let execution_key = (sandbox_id.clone(), id.clone()); + storage.executions.insert(execution_key.clone()); + let events = storage.queued_terminal_events.pop_front().unwrap_or_else(|| { + vec![ + terminal::TerminalEvent::Started { process_id: None }, + terminal::TerminalEvent::Exited(execution::ExitStatus { code: 0 }), + ] + }); + let control = Rc::new(MemoryTerminalControl { + execution: execution_key.clone(), + state: self.state.clone(), + }); + + Ok(terminal::StartedTerminalExecution { + id, + control, + events: Box::pin(MemoryTerminalEventStream { + events: events.into(), + execution: Some(execution_key), + state: self.state.clone(), + }), + }) + }) + } + + fn attach_terminal<'a>( + &'a self, + sandbox_id: &'a SandboxId, + _request: terminal::AttachTerminalRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.ensure_running(sandbox_id)?; + Err(Error::UnsupportedFeature(SandboxFeature::TerminalAttach)) + }) + } + fn terminate_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.end_execution(sandbox_id, execution_id) }) + } + + fn kill_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.end_execution(sandbox_id, execution_id) }) + } + + fn read_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.ensure_running(sandbox_id)?; + let contents = self + .state + .borrow() + .files + .get(&(sandbox_id.clone(), path.clone())) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::File, path.as_str()))?; + Ok(Box::pin(std::io::Cursor::new(contents)) as file_transfer::ByteReader) + }) + } + + fn write_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + mut contents: file_transfer::ByteReader, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.ensure_running(sandbox_id)?; + let mut bytes = Vec::new(); + contents.read_to_end(&mut bytes).await.map_err(|source| Error::Io { + operation: "read Sandbox file-transfer input", + source, + })?; + self.ensure_running(sandbox_id)?; + let mut storage = self.state.borrow_mut(); + storage.files.insert((sandbox_id.clone(), path.clone()), bytes); + storage.file_writes.push(path.clone()); + Ok(()) + }) + } + + fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> LocalFuture<'_, Result> { + Box::pin(async move { + let (id, name) = request.into_parts(); + let mut storage = self.state.borrow_mut(); + if let Some(existing_id) = storage.volumes_by_name.get(&name) { + return storage + .volumes_by_id + .get(existing_id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Volume, existing_id)); + } + let volume = volume::Volume { id: id.clone(), name }; + storage.volumes_by_name.insert(volume.name.clone(), id.clone()); + storage.volumes_by_id.insert(id, volume.clone()); + Ok(volume) + }) + } + + fn find_volume<'a>(&'a self, name: &'a volume::VolumeName) -> LocalFuture<'a, Result> { + Box::pin(async move { + let storage = self.state.borrow(); + let id = storage + .volumes_by_name + .get(name) + .ok_or_else(|| Error::not_found(ResourceKind::Volume, name))?; + storage + .volumes_by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Volume, id)) + }) + } + + fn delete_volume<'a>(&'a self, id: &'a volume::VolumeId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let volume = storage + .volumes_by_id + .remove(id) + .ok_or_else(|| Error::not_found(ResourceKind::Volume, id))?; + storage.volumes_by_name.remove(&volume.name); + Ok(()) + }) + } +} + +/// Sandbox-facing peer paired with an in-memory [`network::PacketEndpoint`]. +/// +/// This type supports endpoint contract tests and keeps the in-memory Sandbox +/// Backend's side of each live connection open. +pub struct PacketPeer { + from_sandbox: Option>, + to_sandbox: mpsc::Receiver, + maximum_packet_length: usize, +} + +impl PacketPeer { + /// Emits one packet as if it came from the Sandbox's virtual network device. + /// + /// # Errors + /// + /// Returns an error when the packet is too large or the Network Backend has + /// closed its receiving direction. + pub async fn emit_from_sandbox(&self, packet: network::NetworkPacket) -> Result<(), network::NetworkEndpointError> { + if packet.len() > self.maximum_packet_length { + return Err(network::NetworkEndpointError::PacketTooLarge { + actual: packet.len(), + maximum: self.maximum_packet_length, + }); + } + let sender = self + .from_sandbox + .as_ref() + .ok_or(network::NetworkEndpointError::Closed)?; + sender + .send(packet) + .await + .map_err(|_| network::NetworkEndpointError::Closed) + } + + /// Receives the next packet addressed to the Sandbox. + pub async fn receive_for_sandbox(&mut self) -> Option { + self.to_sandbox.recv().await + } + + /// Closes the direction in which the Sandbox emits packets. + pub fn close_from_sandbox(&mut self) { + self.from_sandbox = None; + } + + /// Closes the direction in which the Sandbox receives packets. + pub fn close_to_sandbox(&mut self) { + self.to_sandbox.close(); + } +} + +/// Creates a bounded in-memory packet endpoint and its Sandbox-facing peer. +#[must_use] +pub fn packet_endpoint_pair( + capacity: NonZeroUsize, + properties: network::PacketEndpointProperties, +) -> (network::PacketEndpoint, PacketPeer) { + let (from_sandbox, network_receiver) = mpsc::channel(capacity.get()); + let (network_sender, to_sandbox) = mpsc::channel(capacity.get()); + let maximum_packet_length = properties.maximum_frame_length().get() as usize; + ( + network::PacketEndpoint::new( + properties, + MemoryPacketReceiver { + receiver: network_receiver, + }, + MemoryPacketSender { + sender: PollSender::new(network_sender), + maximum_packet_length, + }, + ), + PacketPeer { + from_sandbox: Some(from_sandbox), + to_sandbox, + maximum_packet_length, + }, + ) +} + +struct MemoryPacketReceiver { + receiver: mpsc::Receiver, +} + +impl network::BatchReceiver for MemoryPacketReceiver { + fn poll_receive( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + output: &mut network::NetworkPacketBatch, + ) -> Poll> { + if output.is_full() { + return Poll::Ready(Err(network::NetworkEndpointError::FullReceiveBatch)); + } + + let mut received = 0; + while !output.is_full() { + match self.receiver.poll_recv(context) { + Poll::Ready(Some(packet)) => { + if output.push_back(packet).is_err() { + return Poll::Ready(Err(network::NetworkEndpointError::FullReceiveBatch)); + } + received += 1; + } + Poll::Ready(None) => { + return NonZeroUsize::new(received) + .map_or(Poll::Ready(Ok(network::NetworkTransferProgress::Closed)), |received| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(received))) + }); + } + Poll::Pending => { + return NonZeroUsize::new(received).map_or(Poll::Pending, |received| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(received))) + }); + } + } + } + + NonZeroUsize::new(received).map_or( + Poll::Ready(Err(network::NetworkEndpointError::FullReceiveBatch)), + |received| Poll::Ready(Ok(network::NetworkTransferProgress::Items(received))), + ) + } +} + +struct MemoryPacketSender { + sender: PollSender, + maximum_packet_length: usize, +} + +impl network::BatchSender for MemoryPacketSender { + fn poll_send( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + pending: &mut network::NetworkPacketBatch, + ) -> Poll> { + if pending.is_empty() { + return Poll::Ready(Err(network::NetworkEndpointError::EmptySendBatch)); + } + + let mut sent = 0; + while let Some(packet) = pending.front() { + if packet.len() > self.maximum_packet_length { + return match NonZeroUsize::new(sent) { + Some(sent) => Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))), + None => Poll::Ready(Err(network::NetworkEndpointError::PacketTooLarge { + actual: packet.len(), + maximum: self.maximum_packet_length, + })), + }; + } + + match self.sender.poll_reserve(context) { + Poll::Ready(Ok(())) => { + let Some(packet) = pending.pop_front() else { + return Poll::Ready(Err(network::NetworkEndpointError::EmptySendBatch)); + }; + if let Err(error) = self.sender.send_item(packet) { + if let Some(packet) = error.into_inner() + && pending.push_front(packet).is_err() + { + return Poll::Ready(Err(network::NetworkEndpointError::Backend( + "failed to restore an unaccepted Network packet".into(), + ))); + } + return NonZeroUsize::new(sent) + .map_or(Poll::Ready(Ok(network::NetworkTransferProgress::Closed)), |sent| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))) + }); + } + sent += 1; + } + Poll::Ready(Err(_)) => { + return NonZeroUsize::new(sent) + .map_or(Poll::Ready(Ok(network::NetworkTransferProgress::Closed)), |sent| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))) + }); + } + Poll::Pending => { + return NonZeroUsize::new(sent).map_or(Poll::Pending, |sent| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))) + }); + } + } + } + + NonZeroUsize::new(sent).map_or( + Poll::Ready(Err(network::NetworkEndpointError::EmptySendBatch)), + |sent| Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))), + ) + } + + fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.sender.abort_send(); + self.sender.close(); + Poll::Ready(Ok(())) + } +} + +/// Independently composable in-memory Network Backend. +pub struct NetworkBackend { + id: network::NetworkBackendId, + endpoint: network::NetworkEndpointSelection, + state: RefCell, +} + +#[derive(Default)] +struct NetworkBackendState { + attached: BTreeSet, + endpoints: BTreeMap, +} + +impl NetworkBackend { + /// Creates a Network Backend selecting an explicit endpoint contract. + #[must_use] + pub fn for_endpoint(id: impl Into, endpoint: network::NetworkEndpointSelection) -> Self { + Self { + id: network::NetworkBackendId::new(id), + endpoint, + state: RefCell::new(NetworkBackendState::default()), + } + } + + /// Reports whether one Sandbox retains Network Backend state. + #[must_use] + pub fn is_attached(&self, sandbox_id: &SandboxId) -> bool { + self.state.borrow().attached.contains(sandbox_id) + } +} + +impl network::NetworkBackend for NetworkBackend { + fn id(&self) -> network::NetworkBackendId { + self.id.clone() + } + + fn is_running(&self, sandbox_id: &SandboxId) -> bool { + self.state.borrow().endpoints.contains_key(sandbox_id) + } + + fn select_endpoint( + &self, + available: &network::NetworkEndpointCapabilities, + ) -> Option { + available.supports(&self.endpoint).then(|| self.endpoint.clone()) + } + + fn start(&self, request: network::StartNetworkRequest) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let actual = request.endpoint.selection(); + if actual != self.endpoint { + return Err(Error::NetworkEndpointMismatch { + expected: self.endpoint.clone(), + actual, + }); + } + let mut state = self.state.borrow_mut(); + state.attached.insert(request.sandbox_id.clone()); + state.endpoints.insert(request.sandbox_id, request.endpoint); + Ok(()) + }) + } + + fn stop<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.state.borrow_mut().endpoints.remove(sandbox_id); + Ok(()) + }) + } + + fn delete<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let mut state = self.state.borrow_mut(); + state.endpoints.remove(sandbox_id); + state.attached.remove(sandbox_id); + Ok(()) + }) + } +} + +/// Deterministically resolves OCI Image Sources to synthetic digests. +#[derive(Default)] +pub struct MemoryImageBackend; + +impl image::ImageBackend for MemoryImageBackend { + fn capabilities<'a>( + &'a self, + _platform: &'a Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async { + Ok(image::ImageBackendCapabilities::new( + image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build, image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Layered, RootFilesystemMode::Direct].into(), + ), + image::ImageOperationCapabilities::default(), + image::ImageOperationCapabilities::default(), + )) + }) + } + + fn resolve<'a>(&'a self, request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + Ok(image::ResolvedImage { + source: request.source.clone(), + platform: request.platform.clone(), + manifest_digest: memory_manifest_digest(request), + }) + }) + }) + } + + fn export_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageExport) + } + + fn import_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageImport) + } +} + +fn memory_manifest_digest(request: &image::ResolveRequest) -> String { + let mut digest = Sha256::new(); + digest.update(b"sandbox.memory-image-manifest.v1\0"); + match &request.source { + image::ImageSource::Build { + context, + dockerfile, + target, + } => { + update_digest_part(&mut digest, b"build"); + update_digest_part(&mut digest, context.as_os_str().as_encoded_bytes()); + update_digest_part(&mut digest, dockerfile.as_os_str().as_encoded_bytes()); + update_optional_digest_part(&mut digest, target.as_deref()); + } + image::ImageSource::Reference { reference } => { + update_digest_part(&mut digest, b"reference"); + update_digest_part(&mut digest, reference.as_bytes()); + } + } + update_digest_part(&mut digest, request.platform.os.as_bytes()); + update_digest_part(&mut digest, request.platform.architecture.as_bytes()); + update_optional_digest_part(&mut digest, request.platform.variant.as_deref()); + update_optional_digest_part(&mut digest, request.platform.os_version.as_deref()); + for feature in &request.platform.os_features { + update_digest_part(&mut digest, feature.as_bytes()); + } + let mut encoded = String::with_capacity("sha256:".len() + 64); + encoded.push_str("sha256:"); + for byte in digest.finalize() { + const HEX: &[u8; 16] = b"0123456789abcdef"; + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +fn update_optional_digest_part(digest: &mut Sha256, value: Option<&str>) { + match value { + Some(value) => { + digest.update([1]); + update_digest_part(digest, value.as_bytes()); + } + None => digest.update([0]), + } +} + +fn update_digest_part(digest: &mut Sha256, value: &[u8]) { + digest.update(value.len().to_le_bytes()); + digest.update(value); +} + +fn unsupported_prepared_image<'a>(operation: image::ImageOperation) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |_progress| Box::pin(async move { Err(Error::UnsupportedImageOperation(operation)) })) +} + +fn test_platform() -> Platform { + let architecture = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + Platform::new("linux", architecture) +} + +fn packet_properties(medium: network::PacketMedium) -> Result { + let mtu = NonZeroU32::new(1_500).ok_or_else(|| Error::Backend("invalid memory Network MTU".into()))?; + let maximum_frame_length = NonZeroU32::new(match medium { + network::PacketMedium::Ethernet => 1_514, + network::PacketMedium::Ip => 1_500, + }) + .ok_or_else(|| Error::Backend("invalid memory Network frame length".into()))?; + let ipv4 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)); + let ipv6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 2)); + let addresses = vec![ + network::InterfaceAddress::new(ipv4, 24) + .map_err(|error| Error::Backend(format!("invalid memory IPv4 configuration: {error}")))?, + network::InterfaceAddress::new(ipv6, 64) + .map_err(|error| Error::Backend(format!("invalid memory IPv6 configuration: {error}")))?, + ]; + let interface = network::NetworkInterfaceConfiguration::new( + network::MacAddress::new([0x02, 0, 0, 0, 0, 2]), + mtu, + addresses, + vec![ + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + ], + vec![ + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 53)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 53)), + ], + ); + Ok(network::PacketEndpointProperties::new( + medium, + interface, + maximum_frame_length, + )) +} diff --git a/sandbox/sandbox/src/mount.rs b/sandbox/sandbox/src/mount.rs new file mode 100644 index 0000000..0bf259d --- /dev/null +++ b/sandbox/sandbox/src/mount.rs @@ -0,0 +1,78 @@ +//! Attachments materialized inside a Sandbox. + +use std::{collections::BTreeSet, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{ByteQuantity, SandboxPath, volume::VolumeId}; + +/// One form of filesystem attachment supported by a Sandbox Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum MountKind { + /// Persistent SDK-managed storage. + Volume, + /// A caller-selected host path. + Bind, + /// Anonymous in-memory storage. + Tmpfs, +} + +/// Deterministic set of supported Mount forms. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MountKindSet(BTreeSet); + +impl MountKindSet { + /// Reports whether a Mount form is supported. + #[must_use] + pub fn contains(&self, kind: MountKind) -> bool { + self.0.contains(&kind) + } + + /// Iterates over supported forms in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } +} + +impl From<[MountKind; N]> for MountKindSet { + fn from(kinds: [MountKind; N]) -> Self { + Self(kinds.into_iter().collect()) + } +} + +/// One attachment inside a Sandbox. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Mount { + /// Persistent storage managed through the Sandbox SDK. + Volume { + id: VolumeId, + target: SandboxPath, + read_only: bool, + }, + /// A host path made visible to the Sandbox. + Bind { + source: PathBuf, + target: SandboxPath, + read_only: bool, + }, + /// Anonymous in-memory storage with an explicit capacity limit. + Tmpfs { + target: SandboxPath, + capacity: ByteQuantity, + }, +} + +impl Mount { + /// Returns this attachment's capability kind. + #[must_use] + pub const fn kind(&self) -> MountKind { + match self { + Self::Volume { .. } => MountKind::Volume, + Self::Bind { .. } => MountKind::Bind, + Self::Tmpfs { .. } => MountKind::Tmpfs, + } + } +} diff --git a/sandbox/sandbox/src/name.rs b/sandbox/sandbox/src/name.rs new file mode 100644 index 0000000..2fed099 --- /dev/null +++ b/sandbox/sandbox/src/name.rs @@ -0,0 +1,197 @@ +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, de}; +use thiserror::Error; + +/// Maximum length of a portable Sandbox name. +pub const MAX_SANDBOX_NAME_BYTES: usize = 63; + +/// A portable, user-visible Sandbox name. +/// +/// Names use the Kubernetes DNS-1123 label form: lowercase ASCII letters, +/// digits, and hyphens, with an alphanumeric character at both ends. This is +/// also a strict subset of the names accepted by Microsandbox. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SandboxName(String); + +impl SandboxName { + /// Validates and creates a Sandbox name. + /// + /// # Errors + /// + /// Returns an error when the value is empty, exceeds 63 bytes, or is not a + /// DNS-1123 label. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate(&value)?; + Ok(Self(value)) + } + + /// Returns whether `character` may appear anywhere in a Sandbox name. + /// + /// This is the per-keystroke filter for interactive input; the positional + /// rules (alphanumeric first and last byte) still apply at validation. + #[must_use] + pub const fn accepts(character: char) -> bool { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + } + + /// Returns the name as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for SandboxName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for SandboxName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for SandboxName { + type Err = InvalidSandboxName; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl TryFrom for SandboxName { + type Error = InvalidSandboxName; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for SandboxName { + type Error = InvalidSandboxName; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl<'de> Deserialize<'de> for SandboxName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} + +/// The hostname a Sandbox reports to its guest. +/// +/// Hostnames share the portable DNS-1123 label form of [`SandboxName`], which +/// keeps them within the Linux UTS limit and usable as a DNS label. A Sandbox +/// defaults to its own name as hostname; a caller supplies a different value +/// when the user-facing identity differs from the Sandbox name. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Hostname(SandboxName); + +impl Hostname { + /// Validates and creates a Sandbox hostname. + /// + /// # Errors + /// + /// Returns an error unless the value is a lowercase DNS label of at most + /// [`MAX_SANDBOX_NAME_BYTES`] bytes. + pub fn new(value: impl Into) -> Result { + SandboxName::new(value).map(Self).map_err(InvalidHostname) + } + + /// Returns the hostname as text. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl From for Hostname { + fn from(name: SandboxName) -> Self { + Self(name) + } +} + +impl AsRef for Hostname { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Hostname { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for Hostname { + type Err = InvalidHostname; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +/// Why a value cannot be used as a Sandbox hostname. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvalidHostname(InvalidSandboxName); + +impl fmt::Display for InvalidHostname { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "hostname is not a portable DNS label: {}", self.0) + } +} + +impl std::error::Error for InvalidHostname {} + +/// Why a value cannot be used as a portable Sandbox name. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum InvalidSandboxName { + /// The value was empty. + #[error("Sandbox name must not be empty")] + Empty, + /// The value exceeded the portable length limit. + #[error("Sandbox name must not exceed {MAX_SANDBOX_NAME_BYTES} bytes (got {length})")] + TooLong { + /// Actual UTF-8 byte length. + length: usize, + }, + /// The value was not a DNS-1123 label. + #[error( + "Sandbox name must contain only lowercase ASCII letters, digits, or hyphens and must start and end with a letter or digit" + )] + InvalidSyntax, +} + +fn validate(value: &str) -> Result<(), InvalidSandboxName> { + if value.is_empty() { + return Err(InvalidSandboxName::Empty); + } + if value.len() > MAX_SANDBOX_NAME_BYTES { + return Err(InvalidSandboxName::TooLong { length: value.len() }); + } + let bytes = value.as_bytes(); + if !is_alphanumeric(bytes[0]) + || !is_alphanumeric(bytes[bytes.len() - 1]) + || !bytes.iter().all(|byte| is_alphanumeric(*byte) || *byte == b'-') + { + return Err(InvalidSandboxName::InvalidSyntax); + } + Ok(()) +} + +const fn is_alphanumeric(byte: u8) -> bool { + byte.is_ascii_lowercase() || byte.is_ascii_digit() +} diff --git a/sandbox/sandbox/src/network.rs b/sandbox/sandbox/src/network.rs new file mode 100644 index 0000000..45e6b7a --- /dev/null +++ b/sandbox/sandbox/src/network.rs @@ -0,0 +1,1018 @@ +//! Independently implemented networking attached to a Sandbox at creation. + +use std::{ + collections::{BTreeSet, VecDeque}, + net::{IpAddr, SocketAddr}, + num::{NonZeroU32, NonZeroUsize}, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use thiserror::Error as ThisError; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::{Error, LocalFuture, SandboxId, SandboxName}; + +/// Stable identity of one configured Network Backend. +/// +/// The identity is stored with the Sandbox so another implementation cannot be +/// substituted without recreating it. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct NetworkBackendId(String); + +impl NetworkBackendId { + /// Creates a stable Network Backend identity. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the identity as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for NetworkBackendId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Packet representation exposed by a Sandbox Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PacketMedium { + /// Complete Ethernet frames without a frame check sequence. + Ethernet, + /// Complete IPv4 or IPv6 packets without a link-layer header. + Ip, +} + +/// Stable identity of a versioned control protocol implemented by a Sandbox Backend. +/// +/// The protocol remains opaque to the generic Sandbox SDK. Its concrete +/// Sandbox and Network Backend implementations jointly define the messages and +/// their semantics. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct NetworkControlProtocolId(String); + +impl NetworkControlProtocolId { + /// Creates a control protocol identity. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the identity as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for NetworkControlProtocolId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Network endpoint forms a Sandbox Backend can expose. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct NetworkEndpointCapabilities { + packet_media: BTreeSet, + intercepted: bool, + control_protocols: BTreeSet, +} + +impl NetworkEndpointCapabilities { + /// Creates an empty set of endpoint capabilities. + #[must_use] + pub const fn new() -> Self { + Self { + packet_media: BTreeSet::new(), + intercepted: false, + control_protocols: BTreeSet::new(), + } + } + + /// Adds a packet medium. + #[must_use] + pub fn with_packet_medium(mut self, medium: PacketMedium) -> Self { + self.packet_media.insert(medium); + self + } + + /// Adds intercepted TCP-stream and UDP-datagram support. + #[must_use] + pub const fn with_intercepted(mut self) -> Self { + self.intercepted = true; + self + } + + /// Adds a versioned control protocol. + #[must_use] + pub fn with_control_protocol(mut self, protocol: NetworkControlProtocolId) -> Self { + self.control_protocols.insert(protocol); + self + } + + /// Reports whether no packet or intercepted endpoint is available. + #[must_use] + pub fn is_empty(&self) -> bool { + self.packet_media.is_empty() && !self.intercepted && self.control_protocols.is_empty() + } + + /// Reports whether the Sandbox Backend can materialize a selection. + #[must_use] + pub fn supports(&self, selection: &NetworkEndpointSelection) -> bool { + match selection { + NetworkEndpointSelection::Packet(medium) => self.packet_media.contains(medium), + NetworkEndpointSelection::Intercepted => self.intercepted, + NetworkEndpointSelection::Control(protocol) => self.control_protocols.contains(protocol), + } + } + + /// Iterates over available packet media in stable order. + pub fn packet_media(&self) -> impl Iterator + '_ { + self.packet_media.iter().copied() + } + + /// Reports whether intercepted TCP streams and UDP datagrams are available. + #[must_use] + pub const fn supports_intercepted(&self) -> bool { + self.intercepted + } + + /// Iterates over available control protocols in stable order. + pub fn control_protocols(&self) -> impl Iterator { + self.control_protocols.iter() + } +} + +/// Immutable endpoint contract selected for one Sandbox Network. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkEndpointSelection { + /// Exchange raw packets in the selected representation. + Packet(PacketMedium), + /// Exchange intercepted TCP streams and UDP datagrams. + Intercepted, + /// Exchange messages using a jointly implemented, versioned control protocol. + Control(NetworkControlProtocolId), +} + +/// Immutable association between a Sandbox, Network Backend and endpoint contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NetworkAttachment { + /// Network Backend selected when the Sandbox was created. + pub backend: NetworkBackendId, + /// Endpoint contract negotiated when the Sandbox was created. + pub endpoint: NetworkEndpointSelection, +} + +/// A bounded, reusable queue used by Network endpoint operations. +/// +/// A receiver appends items and a sender removes accepted items from the front. +/// Keeping the allocation in the caller lets successive polls reuse it and +/// bounds the work performed in one poll. +#[derive(Debug)] +pub struct NetworkBatch { + items: VecDeque, + limit: NonZeroUsize, +} + +impl NetworkBatch { + /// Creates an empty batch with a fixed item limit. + #[must_use] + pub fn new(limit: NonZeroUsize) -> Self { + Self { + items: VecDeque::with_capacity(limit.get()), + limit, + } + } + + /// Returns the maximum number of items held by this batch. + #[must_use] + pub const fn limit(&self) -> NonZeroUsize { + self.limit + } + + /// Returns the current number of items. + #[must_use] + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns whether the batch contains no items. + #[must_use] + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Returns whether the batch has reached its fixed limit. + #[must_use] + pub fn is_full(&self) -> bool { + self.items.len() == self.limit.get() + } + + /// Returns the number of items that can still be appended. + #[must_use] + pub fn remaining(&self) -> usize { + self.limit.get() - self.items.len() + } + + /// Appends an item, returning it unchanged when the batch is full. + /// + /// # Errors + /// + /// Returns the supplied item when the batch is already full. + pub fn push_back(&mut self, item: T) -> Result<(), T> { + if self.is_full() { + Err(item) + } else { + self.items.push_back(item); + Ok(()) + } + } + + /// Prepends an item, returning it unchanged when the batch is full. + /// + /// # Errors + /// + /// Returns the supplied item when the batch is already full. + pub fn push_front(&mut self, item: T) -> Result<(), T> { + if self.is_full() { + Err(item) + } else { + self.items.push_front(item); + Ok(()) + } + } + + /// Returns the first item without removing it. + #[must_use] + pub fn front(&self) -> Option<&T> { + self.items.front() + } + + /// Removes and returns the first item. + pub fn pop_front(&mut self) -> Option { + self.items.pop_front() + } +} + +/// One complete packet transferred across a [`PacketEndpoint`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NetworkPacket(Bytes); + +impl NetworkPacket { + /// Wraps the bytes of one complete packet. + #[must_use] + pub fn new(bytes: impl Into) -> Self { + Self(bytes.into()) + } + + /// Returns the packet bytes. + #[must_use] + pub const fn as_bytes(&self) -> &Bytes { + &self.0 + } + + /// Consumes the packet and returns its bytes. + #[must_use] + pub fn into_bytes(self) -> Bytes { + self.0 + } + + /// Returns the complete packet length. + #[must_use] + pub const fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether the packet has no bytes. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl AsRef<[u8]> for NetworkPacket { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl From for NetworkPacket { + fn from(bytes: Bytes) -> Self { + Self(bytes) + } +} + +/// A bounded batch of raw packets. +pub type NetworkPacketBatch = NetworkBatch; + +/// Six-octet MAC address assigned to a Sandbox network interface. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(transparent)] +pub struct MacAddress([u8; 6]); + +impl MacAddress { + /// Creates a MAC address from its six octets. + #[must_use] + pub const fn new(octets: [u8; 6]) -> Self { + Self(octets) + } + + /// Returns the six address octets. + #[must_use] + pub const fn octets(self) -> [u8; 6] { + self.0 + } +} + +impl std::fmt::Display for MacAddress { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let octets = self.0; + write!( + formatter, + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + octets[0], octets[1], octets[2], octets[3], octets[4], octets[5] + ) + } +} + +/// An IP address assigned to an interface and its routing prefix length. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct InterfaceAddress { + address: IpAddr, + prefix_length: u8, +} + +impl InterfaceAddress { + /// Creates an interface address when the prefix fits its IP family. + /// + /// # Errors + /// + /// Returns [`InvalidPrefixLength`] for IPv4 prefixes above 32 or IPv6 + /// prefixes above 128. + pub const fn new(address: IpAddr, prefix_length: u8) -> Result { + let maximum = if address.is_ipv4() { 32 } else { 128 }; + if prefix_length > maximum { + Err(InvalidPrefixLength { address, prefix_length }) + } else { + Ok(Self { address, prefix_length }) + } + } + + /// Returns the assigned IPv4 or IPv6 address. + #[must_use] + pub const fn address(self) -> IpAddr { + self.address + } + + /// Returns the CIDR prefix length. + #[must_use] + pub const fn prefix_length(self) -> u8 { + self.prefix_length + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SerializedInterfaceAddress { + address: IpAddr, + prefix_length: u8, +} + +impl<'de> Deserialize<'de> for InterfaceAddress { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = SerializedInterfaceAddress::deserialize(deserializer)?; + Self::new(value.address, value.prefix_length).map_err(serde::de::Error::custom) + } +} + +/// An interface prefix length that is invalid for its IP family. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)] +#[error("prefix length {prefix_length} is invalid for {address}")] +pub struct InvalidPrefixLength { + address: IpAddr, + prefix_length: u8, +} + +/// Immutable network configuration assigned by a Sandbox Backend. +/// +/// This is the small, backend-neutral equivalent of a CNI result. The Sandbox +/// Backend persists it with the materialization and every compatible packet +/// Network Backend consumes the same values. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NetworkInterfaceConfiguration { + mac_address: MacAddress, + mtu: NonZeroU32, + addresses: Vec, + default_gateways: Vec, + dns_servers: Vec, +} + +impl NetworkInterfaceConfiguration { + /// Creates immutable Sandbox interface configuration. + #[must_use] + pub const fn new( + mac_address: MacAddress, + mtu: NonZeroU32, + addresses: Vec, + default_gateways: Vec, + dns_servers: Vec, + ) -> Self { + Self { + mac_address, + mtu, + addresses, + default_gateways, + dns_servers, + } + } + + /// Returns the MAC address configured inside the Sandbox. + #[must_use] + pub const fn mac_address(&self) -> MacAddress { + self.mac_address + } + + /// Returns the interface maximum transmission unit. + #[must_use] + pub const fn mtu(&self) -> NonZeroU32 { + self.mtu + } + + /// Returns all IPv4 and IPv6 addresses assigned to the interface. + #[must_use] + pub fn addresses(&self) -> &[InterfaceAddress] { + &self.addresses + } + + /// Returns default gateways for the configured address families. + #[must_use] + pub fn default_gateways(&self) -> &[IpAddr] { + &self.default_gateways + } + + /// Returns DNS server addresses supplied to the Sandbox. + #[must_use] + pub fn dns_servers(&self) -> &[IpAddr] { + &self.dns_servers + } +} + +/// Immutable properties shared by both directions of a packet endpoint. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PacketEndpointProperties { + medium: PacketMedium, + interface: NetworkInterfaceConfiguration, + maximum_frame_length: NonZeroU32, +} + +impl PacketEndpointProperties { + /// Creates packet endpoint properties. + #[must_use] + pub const fn new( + medium: PacketMedium, + interface: NetworkInterfaceConfiguration, + maximum_frame_length: NonZeroU32, + ) -> Self { + Self { + medium, + interface, + maximum_frame_length, + } + } + + /// Returns the packet representation used by the endpoint. + #[must_use] + pub const fn medium(&self) -> PacketMedium { + self.medium + } + + /// Returns the Sandbox interface configuration chosen by the Backend. + #[must_use] + pub const fn interface(&self) -> &NetworkInterfaceConfiguration { + &self.interface + } + + /// Returns the maximum complete frame or IP packet length accepted by the endpoint. + #[must_use] + pub const fn maximum_frame_length(&self) -> NonZeroU32 { + self.maximum_frame_length + } +} + +/// An error encountered while driving a Network endpoint. +#[derive(Debug, ThisError)] +pub enum NetworkEndpointError { + /// The caller supplied a full receive batch, so no progress was possible. + #[error("cannot receive Network items into a full batch")] + FullReceiveBatch, + /// The caller supplied an empty send batch, so no progress was possible. + #[error("cannot send Network items from an empty batch")] + EmptySendBatch, + /// A packet exceeded the maximum length supported by the endpoint. + #[error("Network packet length {actual} exceeds endpoint maximum {maximum}")] + PacketTooLarge { + /// Actual complete packet length. + actual: usize, + /// Maximum complete packet length supported by the endpoint. + maximum: usize, + }, + /// A control message exceeded the maximum length supported by the endpoint. + #[error("Network control message length {actual} exceeds endpoint maximum {maximum}")] + ControlMessageTooLarge { + /// Actual complete message length. + actual: usize, + /// Maximum complete message length supported by the endpoint. + maximum: usize, + }, + /// The Sandbox-facing endpoint closed during an operation. + #[error("Sandbox Network endpoint is closed")] + Closed, + /// A platform I/O operation failed. + #[error("{operation}: {source}")] + Io { + /// Operation being performed. + operation: &'static str, + /// Underlying platform error. + #[source] + source: std::io::Error, + }, + /// An endpoint implementation failed without a more specific portable representation. + #[error("Network endpoint implementation error: {0}")] + Backend(String), +} + +/// Progress made during a bounded Network transfer operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NetworkTransferProgress { + /// One or more items were transferred. + Items(NonZeroUsize), + /// The Sandbox-facing direction closed after queued items were drained. + Closed, +} + +/// Receives a bounded batch of Network items emitted by a Sandbox. +/// +/// Implementations append no more than [`NetworkBatch::remaining`] items. +/// They register the current waker before returning [`Poll::Pending`] and +/// return progress instead of `Pending` after appending any items. +pub trait BatchReceiver { + /// Polls for a bounded batch of items. + fn poll_receive( + self: Pin<&mut Self>, + context: &mut Context<'_>, + output: &mut NetworkBatch, + ) -> Poll>; +} + +/// Sends a bounded batch of Network items to a Sandbox with explicit backpressure. +/// +/// Implementations remove only accepted items from the front of `pending`. +/// They register the current waker before returning [`Poll::Pending`] and +/// return progress instead of `Pending` after accepting any items. +pub trait BatchSender { + /// Polls to accept a bounded batch of items for the Sandbox. + fn poll_send( + self: Pin<&mut Self>, + context: &mut Context<'_>, + pending: &mut NetworkBatch, + ) -> Poll>; + + /// Polls until every accepted item has been flushed. + fn poll_flush(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll>; + + /// Gracefully and idempotently closes the direction used to send items. + fn poll_shutdown(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll>; +} + +/// Independently driven directions of a [`PacketEndpoint`]. +pub struct PacketEndpointParts { + /// Properties applying to both directions. + pub properties: PacketEndpointProperties, + /// Packets emitted by the Sandbox and consumed by the Network Backend. + pub from_sandbox: Pin + Send>>, + /// Packets emitted by the Network Backend and consumed by the Sandbox. + pub to_sandbox: Pin + Send>>, +} + +/// Owned, bidirectional raw-packet endpoint exposed by a Sandbox Backend. +pub struct PacketEndpoint { + parts: PacketEndpointParts, +} + +impl PacketEndpoint { + /// Combines independently implemented receive and send directions. + #[must_use] + pub fn new(properties: PacketEndpointProperties, from_sandbox: R, to_sandbox: S) -> Self + where + R: BatchReceiver + Send + 'static, + S: BatchSender + Send + 'static, + { + Self { + parts: PacketEndpointParts { + properties, + from_sandbox: Box::pin(from_sandbox), + to_sandbox: Box::pin(to_sandbox), + }, + } + } + + /// Returns properties applying to both endpoint directions. + #[must_use] + pub const fn properties(&self) -> &PacketEndpointProperties { + &self.parts.properties + } + + /// Splits the endpoint into directions that can be driven independently. + #[must_use] + pub fn into_parts(self) -> PacketEndpointParts { + self.parts + } +} + +/// One opaque message transferred across a [`NetworkControlEndpoint`]. +#[derive(Clone, Eq, PartialEq)] +pub struct NetworkControlMessage(zeroize::Zeroizing>); + +impl NetworkControlMessage { + /// Wraps one complete protocol message. + #[must_use] + pub fn new(bytes: impl AsRef<[u8]>) -> Self { + Self(zeroize::Zeroizing::new(bytes.as_ref().to_vec())) + } + + /// Returns the complete message bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } + + /// Consumes the message and returns its bytes. + #[must_use] + pub fn into_bytes(self) -> zeroize::Zeroizing> { + self.0 + } + + /// Returns the complete message length. + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether the message contains no bytes. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl AsRef<[u8]> for NetworkControlMessage { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl std::fmt::Debug for NetworkControlMessage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NetworkControlMessage") + .field("length", &self.len()) + .finish_non_exhaustive() + } +} + +impl From for NetworkControlMessage { + fn from(bytes: Bytes) -> Self { + Self::new(bytes) + } +} + +impl From>> for NetworkControlMessage { + fn from(bytes: zeroize::Zeroizing>) -> Self { + Self(bytes) + } +} + +/// A bounded batch of opaque Network control messages. +pub type NetworkControlMessageBatch = NetworkBatch; + +/// Immutable properties shared by both directions of a control endpoint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NetworkControlEndpointProperties { + protocol: NetworkControlProtocolId, + maximum_message_length: NonZeroUsize, +} + +impl NetworkControlEndpointProperties { + /// Creates control endpoint properties. + #[must_use] + pub const fn new(protocol: NetworkControlProtocolId, maximum_message_length: NonZeroUsize) -> Self { + Self { + protocol, + maximum_message_length, + } + } + + /// Returns the jointly implemented protocol identity. + #[must_use] + pub const fn protocol(&self) -> &NetworkControlProtocolId { + &self.protocol + } + + /// Returns the maximum complete control message length. + #[must_use] + pub const fn maximum_message_length(&self) -> NonZeroUsize { + self.maximum_message_length + } +} + +/// Independently driven directions of a [`NetworkControlEndpoint`]. +pub struct NetworkControlEndpointParts { + /// Properties applying to both directions. + pub properties: NetworkControlEndpointProperties, + /// Messages emitted by the Sandbox Backend's trusted runtime. + pub from_sandbox: Pin + Send>>, + /// Messages returned by the Network Backend to the trusted runtime. + pub to_sandbox: Pin + Send>>, +} + +/// Owned endpoint for a versioned protocol between compatible Sandbox and Network Backends. +pub struct NetworkControlEndpoint { + parts: NetworkControlEndpointParts, +} + +impl NetworkControlEndpoint { + /// Combines independently implemented receive and send directions. + #[must_use] + pub fn new(properties: NetworkControlEndpointProperties, from_sandbox: R, to_sandbox: S) -> Self + where + R: BatchReceiver + Send + 'static, + S: BatchSender + Send + 'static, + { + Self { + parts: NetworkControlEndpointParts { + properties, + from_sandbox: Box::pin(from_sandbox), + to_sandbox: Box::pin(to_sandbox), + }, + } + } + + /// Returns properties applying to both endpoint directions. + #[must_use] + pub const fn properties(&self) -> &NetworkControlEndpointProperties { + &self.parts.properties + } + + /// Splits the endpoint into directions that can be driven independently. + #[must_use] + pub fn into_parts(self) -> NetworkControlEndpointParts { + self.parts + } +} + +/// Network destination identified by either an address or a host name. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum NetworkHost { + /// An IPv4 or IPv6 address. + Ip(IpAddr), + /// A name supplied by the intercepted connection mechanism. + Name(String), +} + +/// Destination of an intercepted transport-layer flow. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct NetworkDestination { + /// Destination host or address. + pub host: NetworkHost, + /// Destination transport port. + pub port: u16, +} + +/// Bidirectional byte stream carried by an intercepted endpoint. +pub trait NetworkByteStream: AsyncRead + AsyncWrite {} + +impl NetworkByteStream for T where T: AsyncRead + AsyncWrite + ?Sized {} + +/// One outbound stream accepted from a Sandbox. +pub struct OutboundStream { + /// Source address when the Sandbox Backend can report it. + pub source: Option, + /// Original destination requested by the Sandbox. + pub destination: NetworkDestination, + /// Bidirectional stream bytes. + pub stream: Pin>, +} + +/// Identifies a datagram flow for routing responses back to a Sandbox. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct DatagramFlowId(u64); + +impl DatagramFlowId { + /// Creates a flow identifier scoped to one live intercepted endpoint. + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } +} + +/// One datagram emitted by a Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutboundDatagram { + /// Opaque route used to deliver a response to the originating flow. + pub flow: DatagramFlowId, + /// Source address when the Sandbox Backend can report it. + pub source: Option, + /// Original destination requested by the Sandbox. + pub destination: NetworkDestination, + /// Complete transport payload. + pub payload: Bytes, +} + +/// One datagram returned to a Sandbox flow. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InboundDatagram { + /// Opaque route copied from an outbound datagram. + pub flow: DatagramFlowId, + /// Complete transport payload. + pub payload: Bytes, +} + +/// A bounded batch of datagrams emitted by a Sandbox. +pub type OutboundDatagramBatch = NetworkBatch; + +/// A bounded batch of datagrams returned to a Sandbox. +pub type InboundDatagramBatch = NetworkBatch; + +/// Accepts intercepted outbound TCP streams from a Sandbox. +pub trait StreamAcceptor { + /// Polls for the next outbound stream, or `None` after stream interception closes. + /// + /// The implementation registers the current waker before returning + /// [`Poll::Pending`]. + fn poll_accept( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, NetworkEndpointError>>; +} + +/// Independently driven directions of a [`DatagramEndpoint`]. +pub struct DatagramEndpointParts { + /// UDP datagrams emitted by the Sandbox and consumed by the Network Backend. + pub from_sandbox: Pin + Send>>, + /// UDP datagrams emitted by the Network Backend and consumed by the Sandbox. + pub to_sandbox: Pin + Send>>, +} + +/// Owned, bidirectional endpoint for intercepted UDP datagrams. +pub struct DatagramEndpoint { + parts: DatagramEndpointParts, +} + +impl DatagramEndpoint { + /// Combines independently implemented receive and send directions. + #[must_use] + pub fn new(from_sandbox: R, to_sandbox: S) -> Self + where + R: BatchReceiver + Send + 'static, + S: BatchSender + Send + 'static, + { + Self { + parts: DatagramEndpointParts { + from_sandbox: Box::pin(from_sandbox), + to_sandbox: Box::pin(to_sandbox), + }, + } + } + + /// Splits the endpoint into directions that can be driven independently. + #[must_use] + pub fn into_parts(self) -> DatagramEndpointParts { + self.parts + } +} + +/// Independently driven parts of an [`InterceptedEndpoint`]. +pub struct InterceptedEndpointParts { + /// Intercepted outbound TCP streams. + pub streams: Pin>, + /// Intercepted bidirectional UDP datagrams. + pub datagrams: DatagramEndpoint, +} + +/// Owned endpoint exposing intercepted TCP streams and UDP datagrams. +pub struct InterceptedEndpoint { + parts: InterceptedEndpointParts, +} + +impl InterceptedEndpoint { + /// Combines TCP stream acceptance with a UDP datagram endpoint. + #[must_use] + pub fn new(streams: A, datagrams: DatagramEndpoint) -> Self + where + A: StreamAcceptor + Send + 'static, + { + Self { + parts: InterceptedEndpointParts { + streams: Box::pin(streams), + datagrams, + }, + } + } + + /// Splits the endpoint into independently driven TCP and UDP parts. + #[must_use] + pub fn into_parts(self) -> InterceptedEndpointParts { + self.parts + } +} + +/// Owned Network data plane opened by a Sandbox Backend. +pub enum NetworkEndpoint { + /// Raw Ethernet or IP packet exchange. + Packet(PacketEndpoint), + /// Intercepted TCP streams and UDP datagrams. + Intercepted(InterceptedEndpoint), + /// Versioned control integration with a trusted Sandbox runtime. + Control(NetworkControlEndpoint), +} + +impl NetworkEndpoint { + /// Returns the immutable contract represented by this endpoint. + #[must_use] + pub fn selection(&self) -> NetworkEndpointSelection { + match self { + Self::Packet(endpoint) => NetworkEndpointSelection::Packet(endpoint.properties().medium()), + Self::Intercepted(_) => NetworkEndpointSelection::Intercepted, + Self::Control(endpoint) => NetworkEndpointSelection::Control(endpoint.properties().protocol().clone()), + } + } +} + +/// Inputs for starting or reconnecting one Sandbox's Network Backend. +pub struct StartNetworkRequest { + /// Sandbox whose immutable Network attachment is being started. + pub sandbox_id: SandboxId, + /// Stable name used for caller-owned Network Backend configuration. + pub sandbox_name: SandboxName, + /// Fresh data-plane endpoint opened by the Sandbox Backend. + pub endpoint: NetworkEndpoint, +} + +/// Implements network processing and enforcement independently of a Sandbox Backend. +/// +/// Implementations may use `sandbox-authorization`, a Secret Store, and their +/// own protocol-specific policy enforcement without adding those concerns to +/// the generic Sandbox lifecycle contract. +pub trait NetworkBackend { + /// Returns the stable identity persisted in each attached Sandbox. + fn id(&self) -> NetworkBackendId; + + /// Reports whether this process already drives the Sandbox's live endpoint. + /// + /// The lifecycle service uses this to keep repeated reconciliation + /// idempotent without opening a second endpoint. A newly started control + /// plane returns `false` and re-establishes the retained attachment. + fn is_running(&self, sandbox_id: &SandboxId) -> bool; + + /// Selects one endpoint contract from those offered by a Sandbox Backend. + /// + /// The implementation owns preference and completeness requirements. For + /// example, one implementation may require Ethernet packets while another + /// requires both intercepted TCP streams and UDP datagrams. + /// + /// Returns `None` when no offered endpoint can meet the Network Backend's requirements. + fn select_endpoint(&self, available: &NetworkEndpointCapabilities) -> Option; + + /// Starts or reconnects the Network attached to a Sandbox. + /// + /// This operation must be idempotent for the same Sandbox and attachment. + /// It takes unique ownership of the fresh endpoint and returns only after + /// the Network Backend is ready to process Sandbox traffic. + fn start(&self, request: StartNetworkRequest) -> LocalFuture<'_, Result<(), Error>>; + + /// Stops live network processing while retaining the immutable attachment. + fn stop<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; + + /// Deletes all Network Backend state belonging to a deleted Sandbox. + fn delete<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; +} diff --git a/sandbox/sandbox/src/path.rs b/sandbox/sandbox/src/path.rs new file mode 100644 index 0000000..b7009eb --- /dev/null +++ b/sandbox/sandbox/src/path.rs @@ -0,0 +1,22 @@ +//! Paths interpreted inside a Sandbox. + +use serde::{Deserialize, Serialize}; + +/// A path interpreted inside a Sandbox rather than on the caller's host. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SandboxPath(String); + +impl SandboxPath { + /// Creates a Sandbox path from its backend-neutral representation. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the path as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/sandbox/sandbox/src/platform.rs b/sandbox/sandbox/src/platform.rs new file mode 100644 index 0000000..18398f9 --- /dev/null +++ b/sandbox/sandbox/src/platform.rs @@ -0,0 +1,120 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::Error; + +/// OCI-aligned operating-system and architecture requirements for a Sandbox. +/// +/// Values remain open strings so the generic SDK does not need a release for +/// every platform value introduced by an image or Sandbox Backend. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Platform { + /// Operating system, such as `linux` or `windows`. + pub os: String, + /// CPU architecture, such as `amd64` or `arm64`. + pub architecture: String, + /// Architecture variant, such as an ARM version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, + /// Operating-system version required for compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os_version: Option, + /// Operating-system features required by the image. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub os_features: BTreeSet, +} + +impl Platform { + /// Creates a Platform without variant or operating-system constraints. + #[must_use] + pub fn new(os: impl Into, architecture: impl Into) -> Self { + Self { + os: os.into(), + architecture: architecture.into(), + variant: None, + os_version: None, + os_features: BTreeSet::new(), + } + } + + /// Creates a Platform for the host CPU architecture and the requested + /// guest operating system. + /// + /// Architecture names use their OCI spelling so the result can be used + /// directly for image and Sandbox selection. + #[must_use] + pub fn native(os: impl Into) -> Self { + let architecture = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + Self::new(os, architecture) + } + + /// Returns whether this concrete Platform satisfies a requested Platform. + /// + /// Omitted variant and operating-system constraints act as wildcards. A + /// concrete image may therefore add, but not contradict, those details. + #[must_use] + pub fn satisfies(&self, requested: &Self) -> bool { + self.os == requested.os + && self.architecture == requested.architecture + && requested + .variant + .as_ref() + .is_none_or(|variant| self.variant.as_ref() == Some(variant)) + && requested + .os_version + .as_ref() + .is_none_or(|version| self.os_version.as_ref() == Some(version)) + && self.os_features.is_superset(&requested.os_features) + } + + pub(crate) fn validate(&self) -> Result<(), Error> { + if self.os.is_empty() { + return Err(Error::invalid("platform.os", "must not be empty")); + } + if self.architecture.is_empty() { + return Err(Error::invalid("platform.architecture", "must not be empty")); + } + if self.variant.as_ref().is_some_and(String::is_empty) { + return Err(Error::invalid("platform.variant", "must not be empty when present")); + } + if self.os_version.as_ref().is_some_and(String::is_empty) { + return Err(Error::invalid("platform.osVersion", "must not be empty when present")); + } + if self.os_features.contains("") { + return Err(Error::invalid("platform.osFeatures", "must not contain an empty value")); + } + Ok(()) + } +} + +impl std::fmt::Display for Platform { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}/{}", self.os, self.architecture)?; + if let Some(variant) = &self.variant { + write!(formatter, "/{variant}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::Platform; + + #[test] + fn native_uses_oci_architecture_names() { + let expected = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + + assert_eq!(Platform::native("linux"), Platform::new("linux", expected)); + } +} diff --git a/sandbox/sandbox/src/progress/fold.rs b/sandbox/sandbox/src/progress/fold.rs new file mode 100644 index 0000000..5be2788 --- /dev/null +++ b/sandbox/sandbox/src/progress/fold.rs @@ -0,0 +1,913 @@ +//! What an operation's progress means: its events folded into one value. +//! +//! Renderers and observers read a [`Progress`] instead of interpreting events +//! themselves. A [`ProgressCursor`] yields what finished and what was printed +//! since it last looked, so a late or slow observer reads the same value as +//! one that saw every event. + +use std::collections::{HashMap, VecDeque}; + +use time::OffsetDateTime; + +use super::{Outcome, OutputStream, Phase, ProgressEvent, ProgressUnit, StepId}; + +/// Output lines retained per operation. +const OUTPUT_LINES: usize = 1_000; +/// Output text retained per operation, so that a serialized [`Progress`] +/// stays small enough to send in one message even when escaping multiplies it. +const OUTPUT_BYTES: usize = 512 * 1_024; +/// Finished steps retained per phase. +const FINISHED_STEPS: usize = 64; +/// Longest retained output line; longer output is split. +const LINE_BYTES: usize = 4_096; + +/// Progress of one operation, folded from its events. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Progress { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + finished: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + current: Option, + #[serde(default, skip_serializing_if = "OutputLog::is_unused")] + output: OutputLog, + #[serde(default)] + status: OperationStatus, +} + +/// Whether the operation is still running and how it ended. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum OperationStatus { + /// The operation has not ended. + #[default] + Running, + /// The operation produced its result. + Succeeded, + /// The operation failed. + Failed { + /// Failure detail. + detail: String, + }, +} + +/// A phase that ended. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FinishedPhase { + /// Phase identity. + pub phase: Phase, + /// How it ended. + pub outcome: Outcome, + /// Time spent in the phase, in milliseconds. + pub elapsed_ms: u64, + /// The phase's most recent finished steps. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub steps: Vec, + /// Earlier finished steps no longer retained. + #[serde(default, skip_serializing_if = "is_zero")] + pub omitted_steps: u64, + /// Output lines produced before the phase ended, which orders it among them. + pub output_sequence: u64, +} + +/// The phase in progress. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivePhase { + /// Phase identity. + pub phase: Phase, + /// When the phase started. + #[serde(with = "time::serde::rfc3339")] + pub started_at: OffsetDateTime, + /// Steps in progress, in the order they started. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub steps: Vec, + /// The phase's most recent finished steps. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub finished_steps: Vec, + /// Earlier finished steps no longer retained. + #[serde(default, skip_serializing_if = "is_zero")] + pub omitted_steps: u64, +} + +/// A step in progress. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveStep { + /// Step occurrence identity. + pub id: StepId, + /// Human-readable step name. + pub name: String, + /// When the step started. + #[serde(with = "time::serde::rfc3339")] + pub started_at: OffsetDateTime, + /// The step's quantity, for a measured step once it has reported one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub measurement: Option, + /// Unit of a measured step, fixed when it started. + #[serde(skip)] + unit: Option, +} + +/// A step that ended. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FinishedStep { + /// Human-readable step name. + pub name: String, + /// How it ended. + pub outcome: Outcome, + /// Time spent in the step, in milliseconds. + pub elapsed_ms: u64, + /// The step's final quantity, for a measured step that reported one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub measurement: Option, + /// Output lines produced before the step ended, which orders it among them. + pub output_sequence: u64, +} + +/// The single quantity a measured step reports. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Measurement { + /// Unit, fixed when the step started. + pub unit: ProgressUnit, + /// Work completed so far. + pub completed: u64, + /// Total work, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, +} + +/// The most recent output lines of an operation, numbered in the order produced. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutputLog { + lines: VecDeque, + next_sequence: u64, + #[serde(skip)] + bytes: usize, + #[serde(skip)] + partial: HashMap<(StepId, OutputStream), Partial>, +} + +/// One complete line of step output. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutputLine { + /// Position in the operation's output. + pub sequence: u64, + /// Name of the step that produced it. + pub step: String, + /// Stream it was produced on. + pub stream: OutputStream, + /// Line text without its terminator. + pub text: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Partial { + step: String, + stream: OutputStream, + bytes: Vec, +} + +/// Something that happened since a [`ProgressCursor`] last looked. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Update<'a> { + /// Output lines were no longer retained when the cursor caught up. + OutputSkipped(u64), + /// One new output line. + Output(&'a OutputLine), + /// A step finished. + StepFinished(&'a FinishedStep), + /// A phase finished. + PhaseFinished(&'a FinishedPhase), +} + +/// Position of an observer in one [`Progress`]. +/// +/// A cursor that is ahead of the progress it reads, as happens when a new +/// operation starts, starts over. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProgressCursor { + phases: usize, + steps: u64, + output: u64, +} + +impl Progress { + /// Creates the progress of an operation that has not reported anything yet. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Phases that ended, in order. + #[must_use] + pub fn finished(&self) -> &[FinishedPhase] { + &self.finished + } + + /// The phase in progress. + #[must_use] + pub const fn current(&self) -> Option<&ActivePhase> { + self.current.as_ref() + } + + /// The operation's retained output. + #[must_use] + pub const fn output(&self) -> &OutputLog { + &self.output + } + + /// Whether the operation is running, succeeded or failed. + #[must_use] + pub const fn status(&self) -> &OperationStatus { + &self.status + } + + /// The newest step in progress, which renderers show as the current activity. + #[must_use] + pub fn current_step(&self) -> Option<&ActiveStep> { + self.current.as_ref().and_then(|phase| phase.steps.last()) + } + + /// A smaller copy for listings: finished phases keep their outcome but not + /// their steps, and only the last `output_lines` lines are kept. + #[must_use] + pub fn summary(&self, output_lines: usize) -> Self { + let finished = self + .finished + .iter() + .map(|phase| FinishedPhase { + steps: Vec::new(), + omitted_steps: phase.omitted_steps + phase.steps.len() as u64, + ..phase.clone() + }) + .collect(); + let current = self.current.as_ref().map(|phase| ActivePhase { + finished_steps: Vec::new(), + omitted_steps: phase.omitted_steps + phase.finished_steps.len() as u64, + ..phase.clone() + }); + Self { + finished, + current, + output: OutputLog::retained(self.output.tail(output_lines).cloned(), self.output.next_sequence), + status: self.status.clone(), + } + } + + /// A copy without the output lines before `sequence`, for an observer that + /// already has them. + #[must_use] + pub fn output_from(&self, sequence: u64) -> Self { + Self { + output: OutputLog::retained( + self.output + .lines + .iter() + .filter(|line| line.sequence >= sequence) + .cloned(), + self.output.next_sequence, + ), + ..self.clone() + } + } + + /// Folds one event into the progress. + pub fn apply(&mut self, event: &ProgressEvent) { + match event { + ProgressEvent::PhaseStarted { phase } => self.start_phase(phase.clone()), + ProgressEvent::PhaseEnded { + phase, + outcome, + elapsed, + } => { + if self.current.as_ref().is_some_and(|current| current.phase == *phase) { + self.end_phase(*outcome, milliseconds(*elapsed)); + } + } + ProgressEvent::StepStarted { id, name, unit, total } => { + // A step outside any phase breaks the contract and is not shown. + if let Some(current) = &mut self.current { + current.steps.push(ActiveStep { + id: id.clone(), + name: name.clone(), + started_at: OffsetDateTime::now_utc(), + // A figure is shown once there is one: a step that never + // reports its quantity shows none rather than zero. + measurement: unit.zip(*total).map(|(unit, total)| Measurement { + unit, + completed: 0, + total: Some(total), + }), + unit: *unit, + }); + } + } + ProgressEvent::StepProgress { id, completed, total } => { + if let Some(step) = self.active_step_mut(id) + && let Some(unit) = step.unit + { + let previous = step.measurement.and_then(|measurement| measurement.total); + step.measurement = Some(Measurement { + unit, + completed: *completed, + total: total.or(previous), + }); + } + } + ProgressEvent::StepOutput { id, stream, bytes } => { + if let Some(step) = self.active_step(id).map(|step| step.name.clone()) { + self.output.append(id, &step, *stream, bytes); + } + } + ProgressEvent::StepEnded { id, outcome, elapsed } => { + self.output.flush(Some(id)); + self.end_step(id, *outcome, Some(milliseconds(*elapsed))); + } + } + } + + /// Records that the operation produced its result. + pub fn succeed(&mut self) { + self.close(Outcome::Completed); + self.status = OperationStatus::Succeeded; + } + + /// Records that the operation failed. + pub fn fail(&mut self, detail: impl Into) { + self.close(Outcome::Failed); + self.status = OperationStatus::Failed { detail: detail.into() }; + } + + fn close(&mut self, outcome: Outcome) { + self.output.flush(None); + if self.current.is_some() { + let elapsed = self.current.as_ref().map_or(0, |current| since(current.started_at)); + self.end_phase(outcome, elapsed); + } + } + + fn start_phase(&mut self, phase: Phase) { + if self.current.is_some() { + self.close(Outcome::Failed); + } + self.current = Some(ActivePhase { + phase, + started_at: OffsetDateTime::now_utc(), + steps: Vec::new(), + finished_steps: Vec::new(), + omitted_steps: 0, + }); + } + + fn end_phase(&mut self, outcome: Outcome, elapsed_ms: u64) { + let open = self + .current + .as_ref() + .map(|current| current.steps.iter().map(|step| step.id.clone()).collect::>()) + .unwrap_or_default(); + for id in open { + self.output.flush(Some(&id)); + self.end_step(&id, Outcome::Failed, None); + } + if let Some(current) = self.current.take() { + self.finished.push(FinishedPhase { + phase: current.phase, + outcome, + elapsed_ms, + steps: current.finished_steps, + omitted_steps: current.omitted_steps, + output_sequence: self.output.next_sequence, + }); + } + } + + fn end_step(&mut self, id: &StepId, outcome: Outcome, elapsed_ms: Option) { + let Some(current) = &mut self.current else { + return; + }; + let Some(position) = current.steps.iter().position(|step| step.id == *id) else { + return; + }; + let step = current.steps.remove(position); + if current.finished_steps.len() == FINISHED_STEPS { + current.finished_steps.remove(0); + current.omitted_steps += 1; + } + current.finished_steps.push(FinishedStep { + elapsed_ms: elapsed_ms.unwrap_or_else(|| since(step.started_at)), + name: step.name, + outcome, + measurement: step.measurement, + output_sequence: self.output.next_sequence, + }); + } + + fn active_step(&self, id: &StepId) -> Option<&ActiveStep> { + self.current.as_ref()?.steps.iter().find(|step| step.id == *id) + } + + fn active_step_mut(&mut self, id: &StepId) -> Option<&mut ActiveStep> { + self.current.as_mut()?.steps.iter_mut().find(|step| step.id == *id) + } +} + +impl OutputLog { + /// Retained lines, oldest first. + #[must_use] + pub fn lines(&self) -> impl ExactSizeIterator { + self.lines.iter() + } + + /// Whether no line has been retained. + #[must_use] + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } + + /// The most recent `count` lines, oldest first. + pub fn tail(&self, count: usize) -> impl Iterator { + self.lines.iter().skip(self.lines.len().saturating_sub(count)) + } + + /// Whether no line was ever produced; only then can the log be omitted, + /// since its next sequence tells an observer where the output stands. + const fn is_unused(&self) -> bool { + self.next_sequence == 0 + } + + fn retained(lines: impl Iterator, next_sequence: u64) -> Self { + let lines = lines.collect::>(); + Self { + bytes: lines.iter().map(|line| line.text.len()).sum(), + lines, + next_sequence, + partial: HashMap::new(), + } + } + + fn first_sequence(&self) -> u64 { + self.lines.front().map_or(self.next_sequence, |line| line.sequence) + } + + fn append(&mut self, id: &StepId, step: &str, stream: OutputStream, bytes: &[u8]) { + let key = (id.clone(), stream); + let mut partial = self.partial.remove(&key).unwrap_or_else(|| Partial { + step: step.to_owned(), + stream, + bytes: Vec::new(), + }); + for &byte in bytes { + if byte == b'\n' { + self.push(&partial.step, partial.stream, &std::mem::take(&mut partial.bytes)); + } else { + partial.bytes.push(byte); + if partial.bytes.len() == LINE_BYTES { + self.push(&partial.step, partial.stream, &std::mem::take(&mut partial.bytes)); + } + } + } + if !partial.bytes.is_empty() { + self.partial.insert(key, partial); + } + } + + /// Ends unterminated lines of one step, or of every step. + fn flush(&mut self, id: Option<&StepId>) { + let keys = self + .partial + .keys() + .filter(|(step, _)| id.is_none_or(|id| step == id)) + .cloned() + .collect::>(); + for key in keys { + if let Some(partial) = self.partial.remove(&key) { + self.push(&partial.step, partial.stream, &partial.bytes); + } + } + } + + fn push(&mut self, step: &str, stream: OutputStream, bytes: &[u8]) { + let text = String::from_utf8_lossy(bytes); + let text = text.trim_end(); + if text.trim_start().is_empty() { + return; + } + while self.lines.len() >= OUTPUT_LINES || (!self.lines.is_empty() && self.bytes + text.len() > OUTPUT_BYTES) { + if let Some(line) = self.lines.pop_front() { + self.bytes -= line.text.len(); + } + } + self.bytes += text.len(); + self.lines.push_back(OutputLine { + sequence: self.next_sequence, + step: step.to_owned(), + stream, + text: text.to_owned(), + }); + self.next_sequence += 1; + } +} + +impl ProgressCursor { + /// Creates a cursor at the start of an operation. + #[must_use] + pub const fn new() -> Self { + Self { + phases: 0, + steps: 0, + output: 0, + } + } + + /// Sequence number of the first output line not yet returned. + #[must_use] + pub const fn output_sequence(&self) -> u64 { + self.output + } + + /// Returns what happened since the last call, in the order it happened, + /// and moves past it. + pub fn updates<'a>(&mut self, progress: &'a Progress) -> Vec> { + if self.phases > progress.finished.len() || self.output > progress.output.next_sequence { + *self = Self::default(); + } + let mut finished = Vec::new(); + for phase in &progress.finished[self.phases..] { + push_steps(&mut finished, &phase.steps, phase.omitted_steps, self.steps); + finished.push((phase.output_sequence, Update::PhaseFinished(phase))); + self.steps = 0; + } + self.phases = progress.finished.len(); + if let Some(current) = &progress.current { + push_steps( + &mut finished, + ¤t.finished_steps, + current.omitted_steps, + self.steps, + ); + self.steps = current.omitted_steps + current.finished_steps.len() as u64; + } + + let mut updates = Vec::new(); + let first = progress.output.first_sequence(); + if self.output < first { + updates.push(Update::OutputSkipped(first - self.output)); + self.output = first; + } + let mut finished = finished.into_iter().peekable(); + for line in progress.output.lines.iter().filter(|line| line.sequence >= self.output) { + while let Some((_, update)) = finished.next_if(|(before, _)| *before <= line.sequence) { + updates.push(update); + } + updates.push(Update::Output(line)); + } + updates.extend(finished.map(|(_, update)| update)); + self.output = progress.output.next_sequence; + updates + } +} + +/// Adds the steps not yet seen, each with the output position it ended at. +fn push_steps<'a>(finished: &mut Vec<(u64, Update<'a>)>, steps: &'a [FinishedStep], omitted: u64, seen: u64) { + let skip = usize::try_from(seen.saturating_sub(omitted)).unwrap_or(usize::MAX); + finished.extend( + steps + .iter() + .skip(skip) + .map(|step| (step.output_sequence, Update::StepFinished(step))), + ); +} + +fn milliseconds(duration: std::time::Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn since(started_at: OffsetDateTime) -> u64 { + u64::try_from((OffsetDateTime::now_utc() - started_at).whole_milliseconds()).unwrap_or(0) +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_zero(value: &u64) -> bool { + *value == 0 +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::SandboxPhase; + + fn phase_started() -> ProgressEvent { + ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + } + } + + fn step_started(id: &StepId, name: &str, unit: Option) -> ProgressEvent { + ProgressEvent::StepStarted { + id: id.clone(), + name: name.into(), + unit, + total: None, + } + } + + fn output(id: &StepId, text: &str) -> ProgressEvent { + ProgressEvent::StepOutput { + id: id.clone(), + stream: OutputStream::Stdout, + bytes: text.as_bytes().to_vec().into(), + } + } + + fn ended(id: &StepId) -> ProgressEvent { + ProgressEvent::StepEnded { + id: id.clone(), + outcome: Outcome::Completed, + elapsed: Duration::from_millis(5), + } + } + + #[test] + fn a_measured_step_shows_no_figure_until_it_reports_one() { + let mut progress = Progress::new(); + let build = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&build, "Build", Some(ProgressUnit::Bytes))); + assert_eq!(progress.current_step().expect("step").measurement, None); + + let export = StepId::generate(); + progress.apply(&step_started(&export, "Export", Some(ProgressUnit::Bytes))); + progress.apply(&ProgressEvent::StepProgress { + id: export.clone(), + completed: 7, + total: None, + }); + progress.apply(&ended(&build)); + progress.apply(&ended(&export)); + progress.succeed(); + + let steps = &progress.finished()[0].steps; + assert_eq!(steps[0].measurement, None, "a step that never reported has no figure"); + assert_eq!( + steps[1].measurement, + Some(Measurement { + unit: ProgressUnit::Bytes, + completed: 7, + total: None, + }) + ); + } + + #[test] + fn retained_output_is_bounded_by_bytes_as_well_as_lines() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + let long = "x".repeat(LINE_BYTES - 1); + for _ in 0..OUTPUT_LINES { + progress.apply(&output(&step, &format!("{long}\n"))); + } + + let retained = progress + .output() + .tail(usize::MAX) + .map(|line| line.text.len()) + .sum::(); + assert!(retained <= OUTPUT_BYTES, "{retained} bytes retained"); + assert_eq!( + progress.output().tail(usize::MAX).count(), + OUTPUT_BYTES / (LINE_BYTES - 1), + "the oldest lines make room" + ); + assert_eq!( + progress.output().next_sequence, + OUTPUT_LINES as u64, + "every line is still numbered" + ); + let from = progress.output_from(0); + assert_eq!( + from.output().tail(usize::MAX).count(), + progress.output().tail(usize::MAX).count() + ); + } + + #[test] + fn folds_phases_measured_steps_and_line_output() { + let mut progress = Progress::new(); + let pull = StepId::generate(); + progress.apply(&ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + }); + progress.apply(&step_started(&pull, "Pull", Some(ProgressUnit::Bytes))); + progress.apply(&ProgressEvent::StepProgress { + id: pull.clone(), + completed: 40, + total: Some(100), + }); + progress.apply(&output(&pull, "layer 1 do")); + progress.apply(&output(&pull, "ne\nlayer 2 done\npartial")); + + let step = progress.current_step().expect("step in progress"); + assert_eq!( + step.measurement, + Some(Measurement { + unit: ProgressUnit::Bytes, + completed: 40, + total: Some(100), + }) + ); + let lines = progress + .output() + .lines() + .map(|line| line.text.as_str()) + .collect::>(); + assert_eq!(lines, ["layer 1 done", "layer 2 done"]); + + progress.apply(&ended(&pull)); + progress.apply(&ProgressEvent::PhaseEnded { + phase: SandboxPhase::ImageResolve.phase(), + outcome: Outcome::Completed, + elapsed: Duration::from_millis(9), + }); + progress.succeed(); + assert_eq!( + progress.output().lines().last().map(|line| line.text.as_str()), + Some("partial") + ); + assert_eq!(progress.finished()[0].steps[0].name, "Pull"); + assert_eq!(progress.status(), &OperationStatus::Succeeded); + } + + #[test] + fn failing_ends_open_work_and_keeps_the_detail() { + let mut progress = Progress::new(); + let build = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&build, "Build", None)); + progress.fail("build failed"); + assert!(progress.current().is_none()); + let phase = &progress.finished()[0]; + assert_eq!(phase.outcome, Outcome::Failed); + assert_eq!(phase.steps[0].outcome, Outcome::Failed); + assert_eq!( + progress.status(), + &OperationStatus::Failed { + detail: "build failed".into() + } + ); + } + + #[test] + fn a_cursor_yields_each_line_and_finished_step_once_and_reports_skipped_output() { + let mut progress = Progress::new(); + let mut cursor = ProgressCursor::default(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&output(&step, "one\n")); + assert_eq!(cursor.updates(&progress).len(), 1); + assert!(cursor.updates(&progress).is_empty(), "nothing new"); + + for line in 0..(OUTPUT_LINES + 5) { + progress.apply(&output(&step, &format!("{line}\n"))); + } + progress.apply(&ended(&step)); + let updates = cursor.updates(&progress); + assert_eq!(updates[0], Update::OutputSkipped(5)); + assert!(matches!( + updates.last(), + Some(Update::StepFinished(step)) if step.name == "Build" + )); + assert_eq!( + updates + .iter() + .filter(|update| matches!(update, Update::Output(_))) + .count(), + OUTPUT_LINES + ); + + progress.succeed(); + assert!( + matches!(cursor.updates(&progress).as_slice(), [Update::PhaseFinished(_)]), + "the phase's step was already reported" + ); + } + + #[test] + fn a_late_cursor_sees_the_whole_retained_history_and_a_stale_one_starts_over() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&ended(&step)); + progress.succeed(); + let mut cursor = ProgressCursor::default(); + assert_eq!(cursor.updates(&progress).len(), 2); + + let fresh = Progress::new(); + assert!(cursor.updates(&fresh).is_empty()); + assert_eq!(cursor, ProgressCursor::default()); + } + + #[test] + fn a_summary_keeps_outcomes_and_the_output_tail() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&output(&step, "one\ntwo\nthree\n")); + progress.apply(&ended(&step)); + progress.fail("build failed"); + let summary = progress.summary(2); + assert!(summary.finished()[0].steps.is_empty()); + assert_eq!(summary.finished()[0].omitted_steps, 1); + assert_eq!(summary.finished()[0].outcome, Outcome::Failed); + let lines = summary + .output() + .lines() + .map(|line| line.text.as_str()) + .collect::>(); + assert_eq!(lines, ["two", "three"]); + assert_eq!(summary.status(), progress.status()); + } + + #[test] + fn output_from_keeps_later_lines_and_a_cursor_reads_them_as_new() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&output(&step, "one\ntwo\n")); + let mut cursor = ProgressCursor::default(); + assert_eq!(cursor.updates(&progress.output_from(0)).len(), 2); + progress.apply(&output(&step, "three\n")); + let trimmed = progress.output_from(2); + assert_eq!(trimmed.output().lines().count(), 1); + assert!(matches!( + cursor.updates(&trimmed).as_slice(), + [Update::Output(line)] if line.text == "three" + )); + } + + #[test] + fn updates_keep_output_and_endings_in_the_order_they_happened() { + let mut progress = Progress::new(); + let first = StepId::generate(); + let second = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&first, "First", None)); + progress.apply(&output(&first, "first output\n")); + progress.apply(&ended(&first)); + progress.apply(&step_started(&second, "Second", None)); + progress.apply(&output(&second, "second output\n")); + let order = ProgressCursor::new() + .updates(&progress) + .into_iter() + .map(|update| match update { + Update::Output(line) => line.text.clone(), + Update::StepFinished(step) => format!("end {}", step.name), + other => format!("{other:?}"), + }) + .collect::>(); + assert_eq!(order, ["first output", "end First", "second output"]); + } + + #[test] + fn progress_round_trips_through_json_without_partial_lines() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", Some(ProgressUnit::Items))); + progress.apply(&ProgressEvent::StepProgress { + id: step.clone(), + completed: 1, + total: Some(2), + }); + progress.apply(&output(&step, "done\nhalf")); + let json = serde_json::to_value(&progress).expect("progress JSON"); + assert_eq!(json["current"]["phase"]["id"], "imageResolve"); + assert_eq!(json["current"]["steps"][0]["measurement"]["unit"], "items"); + assert_eq!(json["status"]["state"], "running"); + let decoded: Progress = serde_json::from_value(json).expect("progress from JSON"); + assert_eq!(decoded.output().lines().count(), 1); + + let trimmed = serde_json::to_value(progress.output_from(1)).expect("trimmed progress JSON"); + let decoded: Progress = serde_json::from_value(trimmed).expect("trimmed progress from JSON"); + assert_eq!(decoded.output().lines().count(), 0); + assert_eq!( + decoded.output().next_sequence, + 1, + "an observer that has every line still learns where the output stands" + ); + } +} diff --git a/sandbox/sandbox/src/progress/mod.rs b/sandbox/sandbox/src/progress/mod.rs new file mode 100644 index 0000000..b0fe0da --- /dev/null +++ b/sandbox/sandbox/src/progress/mod.rs @@ -0,0 +1,651 @@ +//! Observable progress for Sandbox operations. +//! +//! Implementations report through phase spans and step tokens, and consumers +//! read the events folded into a [`Progress`]. A step belongs to the phase in +//! progress and reports at most one quantity, in the unit it started with. +//! Every phase and step ends once, as completed, reused or failed: a +//! producer ends the ones it finishes, and the fold ends whatever is still +//! open when the operation ends. + +use std::{ + borrow::Cow, + fmt, + future::{IntoFuture, poll_fn}, + pin::Pin, + rc::Rc, + task::{Context, Poll}, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use futures_core::{Stream, stream::FusedStream}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::{Error, LocalFuture, SandboxHandle}; + +mod fold; + +pub use fold::{ + ActivePhase, ActiveStep, FinishedPhase, FinishedStep, Measurement, OperationStatus, OutputLine, OutputLog, + Progress, ProgressCursor, Update, +}; + +const EVENT_CAPACITY: usize = 64; + +/// One stable phase of ensuring that a Sandbox is ready. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum SandboxPhase { + /// Validate the backend-neutral request. + Validate, + /// Look up an existing Sandbox with the requested name. + Lookup, + /// Discover and verify required Backend Features. + FeatureDiscovery, + /// Resolve the immutable Image. + ImageResolve, + /// Export or import a prepared Image. + ImagePrepare, + /// Materialize the Sandbox around the resolved Image. + SandboxCreate, + /// Reconcile mutable Sandbox configuration. + SandboxUpdate, + /// Start or reconnect the independently selected Network Backend. + NetworkStart, + /// Start the Sandbox. + SandboxStart, + /// Inspect the resulting Sandbox state. + Inspect, +} + +impl SandboxPhase { + /// Returns the stable identifier and label reported for this phase. + #[must_use] + pub const fn phase(self) -> Phase { + let (id, label) = match self { + Self::Validate => ("validate", "Validate Sandbox request"), + Self::Lookup => ("lookup", "Look up Sandbox"), + Self::FeatureDiscovery => ("featureDiscovery", "Discover Sandbox Capabilities"), + Self::ImageResolve => ("imageResolve", "Resolve Sandbox Image"), + Self::ImagePrepare => ("imagePrepare", "Prepare Sandbox Image"), + Self::SandboxCreate => ("sandboxCreate", "Create Sandbox"), + Self::SandboxUpdate => ("sandboxUpdate", "Update Sandbox"), + Self::NetworkStart => ("networkStart", "Start Sandbox Network"), + Self::SandboxStart => ("sandboxStart", "Start Sandbox"), + Self::Inspect => ("inspect", "Inspect Sandbox"), + }; + Phase::new(id, label) + } +} + +impl fmt::Display for SandboxPhase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.phase().label) + } +} + +/// Identity of one reported phase: a stable machine identifier and a label. +/// +/// Sandbox phases come from [`SandboxPhase`]; callers that extend an operation +/// with work of their own report it under identifiers of their own. +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Phase { + /// Stable identifier, such as `imageResolve`. + pub id: Cow<'static, str>, + /// Human-readable label. + pub label: Cow<'static, str>, +} + +impl Phase { + /// Creates a phase identity from static text. + #[must_use] + pub const fn new(id: &'static str, label: &'static str) -> Self { + Self { + id: Cow::Borrowed(id), + label: Cow::Borrowed(label), + } + } +} + +impl From for Phase { + fn from(phase: SandboxPhase) -> Self { + phase.phase() + } +} + +/// Correlates the events of one step occurrence independently of its name. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)] +#[serde(transparent)] +pub struct StepId(Uuid); + +impl StepId { + /// Creates a new unique step identity. + #[must_use] + pub fn generate() -> Self { + Self(Uuid::new_v4()) + } +} + +impl fmt::Display for StepId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +/// How a phase or step ended. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum Outcome { + /// The work was performed. + Completed, + /// Existing materialized state already satisfied it. + Reused, + /// It did not finish: the operation failed or the work was abandoned. + Failed, +} + +/// Unit of a measured step's quantity. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum ProgressUnit { + /// A byte count. + Bytes, + /// A count of discrete items. + Items, +} + +/// Output stream of one step. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum OutputStream { + /// Normal diagnostic output. + Stdout, + /// Warning or error diagnostic output. + Stderr, +} + +/// One non-terminal event emitted while an operation is running. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ProgressEvent { + /// A phase started. + PhaseStarted { + /// Phase being performed. + phase: Phase, + }, + /// A phase ended. + PhaseEnded { + /// Phase that ended. + phase: Phase, + /// How it ended. + outcome: Outcome, + /// Time spent in the phase. + elapsed: Duration, + }, + /// An implementation-specific step started within the phase in progress. + StepStarted { + /// Correlation identity for this step occurrence. + id: StepId, + /// Human-readable step name. It is not a stable identifier. + name: String, + /// Unit of the step's quantity, for a measured step. + unit: Option, + /// Total quantity, when it is known at the start. + total: Option, + }, + /// The quantity of a measured step advanced. + StepProgress { + /// Step being measured. + id: StepId, + /// Work completed so far, in the step's unit. + completed: u64, + /// Total work, when it is known. + total: Option, + }, + /// Raw diagnostic output from a step. + StepOutput { + /// Step producing the output. + id: StepId, + /// Stream the output was produced on. + stream: OutputStream, + /// Raw output bytes; not necessarily whole lines. + bytes: Bytes, + }, + /// A step ended. + StepEnded { + /// Step that ended. + id: StepId, + /// How it ended. + outcome: Outcome, + /// Time spent in the step. + elapsed: Duration, + }, +} + +/// One item yielded by a [`PendingOperation`]. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OperationEvent { + /// Non-terminal observable progress. + Progress(ProgressEvent), + /// Successful terminal result. + Ready(T), +} + +/// Reports the steps of the phase in progress. +/// +/// Sandbox and Image Backend implementations obtain one from +/// [`PendingOperation::run`]; a caller that extends an operation with work of +/// its own gets one from [`ProgressReporter::steps`]. +#[derive(Clone)] +pub struct SandboxProgress { + reporter: ProgressReporter, +} + +impl SandboxProgress { + /// Starts a step without a quantity and returns its reporting token. + pub async fn start_step(&self, name: impl Into) -> ProgressStep { + self.start(name.into(), None, None).await + } + + /// Starts a step that reports one quantity in `unit`. + pub async fn start_measured_step( + &self, + name: impl Into, + unit: ProgressUnit, + total: Option, + ) -> MeasuredStep { + MeasuredStep { + step: self.start(name.into(), Some(unit), total).await, + } + } + + async fn start(&self, name: String, unit: Option, total: Option) -> ProgressStep { + let id = StepId::generate(); + self.reporter + .emit(ProgressEvent::StepStarted { + id: id.clone(), + name, + unit, + total, + }) + .await; + ProgressStep { + reporter: self.reporter.clone(), + id, + started: Instant::now(), + } + } +} + +/// Reporting token for one in-flight step. +/// +/// A step that is never completed ends as failed when its operation ends. +#[must_use = "a step that is never completed ends as failed"] +pub struct ProgressStep { + reporter: ProgressReporter, + id: StepId, + started: Instant, +} + +impl ProgressStep { + /// Reports raw output from the step. + pub async fn output(&self, stream: OutputStream, bytes: impl Into) { + self.reporter + .emit(ProgressEvent::StepOutput { + id: self.id.clone(), + stream, + bytes: bytes.into(), + }) + .await; + } + + /// Ends the step as completed. + pub async fn complete(self) { + self.reporter + .emit(ProgressEvent::StepEnded { + id: self.id, + outcome: Outcome::Completed, + elapsed: self.started.elapsed(), + }) + .await; + } +} + +/// Reporting token for one in-flight step that measures a single quantity. +#[must_use = "a step that is never completed ends as failed"] +pub struct MeasuredStep { + step: ProgressStep, +} + +impl MeasuredStep { + /// Reports the quantity completed so far and the total, when known. + pub async fn report(&self, completed: u64, total: Option) { + self.step + .reporter + .emit(ProgressEvent::StepProgress { + id: self.step.id.clone(), + completed, + total, + }) + .await; + } + + /// Reports raw output from the step. + pub async fn output(&self, stream: OutputStream, bytes: impl Into) { + self.step.output(stream, bytes).await; + } + + /// Ends the step as completed. + pub async fn complete(self) { + self.step.complete().await; + } +} + +/// One started phase. A phase that is never ended ends as failed when its +/// operation ends. +#[must_use = "a phase that is never ended ends as failed"] +pub struct PhaseSpan { + reporter: ProgressReporter, + phase: Phase, + started: Instant, +} + +impl PhaseSpan { + /// Ends the phase with `outcome`. + pub async fn end(self, outcome: Outcome) { + self.reporter + .emit(ProgressEvent::PhaseEnded { + phase: self.phase, + outcome, + elapsed: self.started.elapsed(), + }) + .await; + } + + /// Ends the phase as completed. + pub async fn complete(self) { + self.end(Outcome::Completed).await; + } +} + +/// An observable operation that terminates with either one value or an Error. +/// +/// Polling this value as a [`Stream`] drives the operation and exposes its +/// progress. Awaiting it through [`IntoFuture`] drains progress and returns +/// only the terminal result. Dropping it cancels the in-flight future. +/// +/// Phases and steps still open when it terminates are ended by the fold of +/// its events; see [`Progress::fail`] and [`Progress::succeed`]. +pub struct PendingOperation<'a, T> { + events: mpsc::Receiver, + driver: Option>>, + result: Option>, + terminated: bool, +} + +/// An operation that terminates with a ready, operable Sandbox handle. +pub type PendingSandbox<'a> = PendingOperation<'a, SandboxHandle>; + +impl<'a, T> PendingOperation<'a, T> { + /// Creates an observable operation whose steps belong to the phase its + /// caller has in progress. + /// + /// The operation owns its progress reporter. Consumers only receive the + /// returned stream/future and cannot inject a sink. + pub fn run(operation: F) -> Self + where + F: FnOnce(SandboxProgress) -> LocalFuture<'a, Result>, + { + let (events, receiver) = ProgressReporter::channel(); + let progress = events.steps(); + drop(events); + Self::new(receiver, operation(progress)) + } + + pub(crate) fn with_events(operation: F) -> Self + where + F: FnOnce(ProgressReporter) -> LocalFuture<'a, Result>, + { + let (events, receiver) = ProgressReporter::channel(); + let driver = operation(events); + Self::new(receiver, driver) + } + + fn new(events: mpsc::Receiver, driver: LocalFuture<'a, Result>) -> Self { + Self { + events, + driver: Some(driver), + result: None, + terminated: false, + } + } + + /// Drives the operation to completion while discarding progress events. + /// + /// # Errors + /// + /// Returns the terminal operation Error, or an invariant error if the + /// operation ends without producing a value or Error. + pub async fn finish(mut self) -> Result { + while let Some(event) = poll_fn(|context| Pin::new(&mut self).poll_next(context)).await { + match event? { + OperationEvent::Ready(value) => return Ok(value), + OperationEvent::Progress(_) => {} + } + } + Err(Error::OperationStreamEnded) + } + + /// Drives the operation to completion while reporting its progress to `events`. + /// + /// # Errors + /// + /// Returns the terminal operation Error, or an invariant error if the + /// operation ends without producing a value or Error. + pub async fn forward(mut self, events: &ProgressReporter) -> Result { + while let Some(event) = poll_fn(|context| Pin::new(&mut self).poll_next(context)).await { + match event? { + OperationEvent::Progress(event) => events.emit(event).await, + OperationEvent::Ready(value) => return Ok(value), + } + } + Err(Error::OperationStreamEnded) + } +} + +impl Unpin for PendingOperation<'_, T> {} + +impl Stream for PendingOperation<'_, T> { + type Item = Result, Error>; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + if self.terminated { + return Poll::Ready(None); + } + + if let Poll::Ready(Some(event)) = self.events.poll_recv(context) { + return Poll::Ready(Some(Ok(OperationEvent::Progress(event)))); + } + + if self.result.is_none() + && let Some(driver) = self.driver.as_mut() + && let Poll::Ready(result) = driver.as_mut().poll(context) + { + self.driver = None; + self.result = Some(result); + } + + if let Poll::Ready(Some(event)) = self.events.poll_recv(context) { + return Poll::Ready(Some(Ok(OperationEvent::Progress(event)))); + } + + if let Some(result) = self.result.take() { + self.terminated = true; + return Poll::Ready(Some(result.map(OperationEvent::Ready))); + } + + Poll::Pending + } +} + +impl FusedStream for PendingOperation<'_, T> { + fn is_terminated(&self) -> bool { + self.terminated + } +} + +impl<'a, T: 'a> IntoFuture for PendingOperation<'a, T> { + type Output = Result; + type IntoFuture = LocalFuture<'a, Self::Output>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.finish()) + } +} + +/// Where progress events go: the stream of a [`PendingOperation`], or a +/// caller's callback. +#[derive(Clone)] +pub struct ProgressReporter { + sink: Sink, +} + +#[derive(Clone)] +enum Sink { + Operation(mpsc::Sender), + Callback(Rc), +} + +impl ProgressReporter { + /// Reports to `callback`. Whoever folds the events ends the work that is + /// still open when the operation ends, as [`Progress::succeed`] and + /// [`Progress::fail`] do. + pub fn from_callback(callback: impl Fn(ProgressEvent) + 'static) -> Self { + Self { + sink: Sink::Callback(Rc::new(callback)), + } + } + + fn channel() -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(EVENT_CAPACITY); + ( + Self { + sink: Sink::Operation(sender), + }, + receiver, + ) + } + + /// Starts a phase. + pub async fn start_phase(&self, phase: impl Into) -> PhaseSpan { + let phase = phase.into(); + self.emit(ProgressEvent::PhaseStarted { phase: phase.clone() }).await; + PhaseSpan { + reporter: self.clone(), + phase, + started: Instant::now(), + } + } + + /// Returns the reporter for steps of the phase in progress. + #[must_use] + pub fn steps(&self) -> SandboxProgress { + SandboxProgress { reporter: self.clone() } + } + + async fn emit(&self, event: ProgressEvent) { + match &self.sink { + Sink::Operation(sender) => { + let _ = sender.send(event).await; + } + Sink::Callback(callback) => callback(event), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives an operation and folds its events, as a consumer does. + async fn fold(mut operation: PendingOperation<'_, T>) -> Progress { + let mut progress = Progress::new(); + while let Some(item) = poll_fn(|context| Pin::new(&mut operation).poll_next(context)).await { + match item { + Ok(OperationEvent::Progress(event)) => progress.apply(&event), + Ok(OperationEvent::Ready(_)) => { + progress.succeed(); + break; + } + Err(error) => { + progress.fail(error.to_string()); + break; + } + } + } + progress + } + + #[tokio::test] + async fn a_failed_operation_leaves_no_phase_or_step_open() { + let operation = PendingOperation::<()>::with_events(|events| { + Box::pin(async move { + let _span = events.start_phase(SandboxPhase::ImageResolve).await; + let step = events + .steps() + .start_measured_step("Pull", ProgressUnit::Bytes, Some(10)) + .await; + step.report(4, Some(10)).await; + Err(Error::Backend("registry unavailable".into())) + }) + }); + let progress = fold(operation).await; + assert!(progress.current().is_none()); + let phase = &progress.finished()[0]; + assert_eq!(phase.outcome, Outcome::Failed); + assert_eq!(phase.steps[0].outcome, Outcome::Failed); + assert_eq!( + phase.steps[0].measurement.map(|measurement| measurement.completed), + Some(4) + ); + } + + #[tokio::test] + async fn steps_belong_to_the_phase_in_progress_and_an_abandoned_one_fails() { + let operation = PendingOperation::<()>::with_events(|events| { + Box::pin(async move { + let span = events.start_phase(SandboxPhase::SandboxUpdate).await; + let backend = PendingOperation::run(|progress| { + Box::pin(async move { + drop(progress.start_step("Abandoned").await); + progress.start_step("Finished").await.complete().await; + Ok(()) + }) + }); + backend.forward(&events).await?; + span.end(Outcome::Reused).await; + Ok(()) + }) + }); + let progress = fold(operation).await; + let [phase] = progress.finished() else { + panic!("one phase: {progress:?}"); + }; + assert_eq!(phase.phase, SandboxPhase::SandboxUpdate.phase()); + assert_eq!(phase.outcome, Outcome::Reused); + let steps = phase + .steps + .iter() + .map(|step| (step.name.as_str(), step.outcome)) + .collect::>(); + assert_eq!( + steps, + [("Finished", Outcome::Completed), ("Abandoned", Outcome::Failed)] + ); + } +} diff --git a/sandbox/sandbox/src/provider.rs b/sandbox/sandbox/src/provider.rs new file mode 100644 index 0000000..636c80f --- /dev/null +++ b/sandbox/sandbox/src/provider.rs @@ -0,0 +1,16 @@ +//! Cohesive Sandbox Backend and Image Backend composition. + +use crate::{backend::SandboxBackend, image}; + +/// Supplies the backend components that share one image materialization domain. +/// +/// A provider keeps Sandbox lifecycle paired with the Image Backend that owns +/// its shared image materialization domain. Consumers compose a provider with +/// an optional Network Backend instead of pairing these components independently. +pub trait SandboxProvider { + /// Returns the Sandbox Backend owned by this provider. + fn backend(&self) -> &dyn SandboxBackend; + + /// Returns the Image Backend whose materialized images the Sandbox Backend consumes. + fn image_backend(&self) -> &dyn image::ImageBackend; +} diff --git a/sandbox/sandbox/src/resource.rs b/sandbox/sandbox/src/resource.rs new file mode 100644 index 0000000..e2233e2 --- /dev/null +++ b/sandbox/sandbox/src/resource.rs @@ -0,0 +1,461 @@ +//! Kubernetes-style quantities used by Sandbox resource assignments. + +use std::{fmt, num::NonZeroU64, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use thiserror::Error; + +const MILLICPUS_PER_CPU: u64 = 1_000; +const MAX_QUANTITY: u128 = i64::MAX as u128; + +const BINARY_SUFFIXES: [(u64, &str); 6] = [ + (1_u64 << 60, "Ei"), + (1_u64 << 50, "Pi"), + (1_u64 << 40, "Ti"), + (1_u64 << 30, "Gi"), + (1_u64 << 20, "Mi"), + (1_u64 << 10, "Ki"), +]; + +const DECIMAL_SUFFIXES: [(u64, &str); 6] = [ + (1_000_000_000_000_000_000, "E"), + (1_000_000_000_000_000, "P"), + (1_000_000_000_000, "T"), + (1_000_000_000, "G"), + (1_000_000, "M"), + (1_000, "k"), +]; + +/// Failure to parse or construct a resource quantity. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum ParseQuantityError { + /// The quantity contains no value. + #[error("resource quantity must not be empty")] + Empty, + /// The quantity does not follow the supported Kubernetes syntax. + #[error("invalid resource quantity")] + Invalid, + /// Sandbox resource assignments must be greater than zero. + #[error("resource quantity must be greater than zero")] + NonPositive, + /// The value cannot be represented exactly in the resource's base unit. + #[error("resource quantity has unsupported precision")] + Precision, + /// The value exceeds the Kubernetes quantity range. + #[error("resource quantity is too large")] + Overflow, +} + +/// A positive CPU quantity stored as an exact number of millicpus. +/// +/// The accepted syntax follows Kubernetes CPU conventions, including `0.5`, +/// `500m`, and whole CPU values such as `4`. Precision finer than one +/// millicpu is rejected. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CpuQuantity(NonZeroU64); + +impl CpuQuantity { + /// Creates a quantity from a non-zero number of millicpus. + #[must_use] + pub const fn from_millicpus(millicpus: NonZeroU64) -> Self { + Self(millicpus) + } + + /// Creates a quantity from a positive number of whole CPUs. + /// + /// # Errors + /// + /// Returns an error for zero or when conversion to millicpus overflows. + pub fn from_cpus(cpus: u64) -> Result { + let millicpus = cpus + .checked_mul(MILLICPUS_PER_CPU) + .ok_or(ParseQuantityError::Overflow)?; + Self::try_from_millicpus(millicpus) + } + + /// Creates a quantity from a positive number of millicpus. + /// + /// # Errors + /// + /// Returns an error when the value is zero or exceeds the Kubernetes + /// quantity range. + pub fn try_from_millicpus(millicpus: u64) -> Result { + if u128::from(millicpus) > MAX_QUANTITY { + return Err(ParseQuantityError::Overflow); + } + NonZeroU64::new(millicpus) + .map(Self) + .ok_or(ParseQuantityError::NonPositive) + } + + /// Returns the normalized value in millicpus. + #[must_use] + pub const fn millicpus(self) -> u64 { + self.0.get() + } + + /// Returns the value as whole CPUs when it has no fractional CPU. + #[must_use] + pub const fn whole_cpus(self) -> Option { + let millicpus = self.millicpus(); + if millicpus.is_multiple_of(MILLICPUS_PER_CPU) { + Some(millicpus / MILLICPUS_PER_CPU) + } else { + None + } + } +} + +impl FromStr for CpuQuantity { + type Err = ParseQuantityError; + + fn from_str(value: &str) -> Result { + let (number, suffix) = split_number_and_suffix(value)?; + let decimal = parse_decimal(number)?; + let multiplier = match suffix { + "" => u128::from(MILLICPUS_PER_CPU), + "m" => 1, + _ => return Err(ParseQuantityError::Invalid), + }; + let millicpus = decimal.to_exact_integer(multiplier, 0)?; + let millicpus = u64::try_from(millicpus).map_err(|_| ParseQuantityError::Overflow)?; + Self::try_from_millicpus(millicpus) + } +} + +impl TryFrom<&str> for CpuQuantity { + type Error = ParseQuantityError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +impl fmt::Display for CpuQuantity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let millicpus = self.millicpus(); + if let Some(cpus) = self.whole_cpus() { + write!(formatter, "{cpus}") + } else { + write!(formatter, "{millicpus}m") + } + } +} + +impl Serialize for CpuQuantity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for CpuQuantity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)?.parse().map_err(D::Error::custom) + } +} + +/// A positive byte quantity using Kubernetes binary and decimal SI syntax. +/// +/// Values such as `256Mi`, `2Gi`, `1.5Gi`, `2G`, and plain byte counts are +/// accepted when they resolve to an exact whole number of bytes. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ByteQuantity(NonZeroU64); + +impl ByteQuantity { + /// Creates a quantity from a non-zero number of bytes. + #[must_use] + pub const fn from_bytes(bytes: NonZeroU64) -> Self { + Self(bytes) + } + + /// Creates a quantity from a positive number of bytes. + /// + /// # Errors + /// + /// Returns an error when the value is zero or exceeds the Kubernetes + /// quantity range. + pub fn try_from_bytes(bytes: u64) -> Result { + if u128::from(bytes) > MAX_QUANTITY { + return Err(ParseQuantityError::Overflow); + } + NonZeroU64::new(bytes).map(Self).ok_or(ParseQuantityError::NonPositive) + } + + /// Creates a quantity from a positive number of mebibytes. + /// + /// # Errors + /// + /// Returns an error for zero or when conversion to bytes overflows. + pub fn from_mebibytes(mebibytes: u64) -> Result { + Self::from_units(mebibytes, 1_u64 << 20) + } + + /// Creates a quantity from a positive number of gibibytes. + /// + /// # Errors + /// + /// Returns an error for zero or when conversion to bytes overflows. + pub fn from_gibibytes(gibibytes: u64) -> Result { + Self::from_units(gibibytes, 1_u64 << 30) + } + + fn from_units(value: u64, multiplier: u64) -> Result { + let bytes = value.checked_mul(multiplier).ok_or(ParseQuantityError::Overflow)?; + Self::try_from_bytes(bytes) + } + + /// Returns the normalized value in bytes. + #[must_use] + pub const fn bytes(self) -> u64 { + self.0.get() + } + + /// Returns the value in whole mebibytes when it is exactly representable. + #[must_use] + pub const fn whole_mebibytes(self) -> Option { + const MEBIBYTE: u64 = 1_u64 << 20; + let bytes = self.bytes(); + if bytes.is_multiple_of(MEBIBYTE) { + Some(bytes / MEBIBYTE) + } else { + None + } + } +} + +impl FromStr for ByteQuantity { + type Err = ParseQuantityError; + + fn from_str(value: &str) -> Result { + let (number, suffix) = split_number_and_suffix(value)?; + let decimal = parse_decimal(number)?; + let (multiplier, exponent) = byte_scale(suffix)?; + let bytes = decimal.to_exact_integer(multiplier, exponent)?; + if bytes > MAX_QUANTITY { + return Err(ParseQuantityError::Overflow); + } + let bytes = u64::try_from(bytes).map_err(|_| ParseQuantityError::Overflow)?; + Self::try_from_bytes(bytes) + } +} + +impl TryFrom<&str> for ByteQuantity { + type Error = ParseQuantityError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +impl fmt::Display for ByteQuantity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let bytes = self.bytes(); + let mut canonical = bytes.to_string(); + for (multiplier, suffix) in BINARY_SUFFIXES.into_iter().chain(DECIMAL_SUFFIXES) { + if bytes.is_multiple_of(multiplier) { + let candidate = format!("{}{suffix}", bytes / multiplier); + if candidate.len() < canonical.len() { + canonical = candidate; + } + } + } + formatter.write_str(&canonical) + } +} + +impl Serialize for ByteQuantity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for ByteQuantity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)?.parse().map_err(D::Error::custom) + } +} + +#[derive(Clone, Copy)] +struct Decimal { + mantissa: u128, + scale: u32, +} + +impl Decimal { + fn to_exact_integer(self, multiplier: u128, exponent: i32) -> Result { + let mut numerator = self + .mantissa + .checked_mul(multiplier) + .ok_or(ParseQuantityError::Overflow)?; + let mut denominator = checked_power(10, self.scale)?; + if exponent >= 0 { + numerator = numerator + .checked_mul(checked_power(10, exponent.unsigned_abs())?) + .ok_or(ParseQuantityError::Overflow)?; + } else { + denominator = denominator + .checked_mul(checked_power(10, exponent.unsigned_abs())?) + .ok_or(ParseQuantityError::Overflow)?; + } + if !numerator.is_multiple_of(denominator) { + return Err(ParseQuantityError::Precision); + } + let value = numerator / denominator; + if value == 0 { + return Err(ParseQuantityError::NonPositive); + } + Ok(value) + } +} + +fn split_number_and_suffix(value: &str) -> Result<(&str, &str), ParseQuantityError> { + if value.is_empty() { + return Err(ParseQuantityError::Empty); + } + let mut number_end = 0; + for (index, character) in value.char_indices() { + let sign = index == 0 && matches!(character, '+' | '-'); + if sign || character.is_ascii_digit() || character == '.' { + number_end = index + character.len_utf8(); + } else { + break; + } + } + if number_end == 0 { + return Err(ParseQuantityError::Invalid); + } + Ok(value.split_at(number_end)) +} + +fn parse_decimal(value: &str) -> Result { + let value = match value.strip_prefix('+') { + Some(value) => value, + None if value.starts_with('-') => return Err(ParseQuantityError::NonPositive), + None => value, + }; + let mut parts = value.split('.'); + let integer = parts.next().ok_or(ParseQuantityError::Invalid)?; + let fraction = parts.next(); + if parts.next().is_some() || (integer.is_empty() && fraction.is_none_or(str::is_empty)) { + return Err(ParseQuantityError::Invalid); + } + if !integer.bytes().all(|byte| byte.is_ascii_digit()) + || fraction.is_some_and(|digits| !digits.bytes().all(|byte| byte.is_ascii_digit())) + { + return Err(ParseQuantityError::Invalid); + } + let fraction = fraction.unwrap_or_default(); + let scale = u32::try_from(fraction.len()).map_err(|_| ParseQuantityError::Overflow)?; + let mut digits = String::with_capacity(integer.len() + fraction.len()); + digits.push_str(integer); + digits.push_str(fraction); + if digits.is_empty() { + return Err(ParseQuantityError::Invalid); + } + let mantissa = digits.parse().map_err(|_| ParseQuantityError::Overflow)?; + Ok(Decimal { mantissa, scale }) +} + +fn byte_scale(suffix: &str) -> Result<(u128, i32), ParseQuantityError> { + let multiplier = match suffix { + "" => 1, + "Ki" => 1_u128 << 10, + "Mi" => 1_u128 << 20, + "Gi" => 1_u128 << 30, + "Ti" => 1_u128 << 40, + "Pi" => 1_u128 << 50, + "Ei" => 1_u128 << 60, + "k" => 1_000, + "M" => 1_000_000, + "G" => 1_000_000_000, + "T" => 1_000_000_000_000, + "P" => 1_000_000_000_000_000, + "E" => 1_000_000_000_000_000_000, + _ => return decimal_exponent(suffix).map(|exponent| (1, exponent)), + }; + Ok((multiplier, 0)) +} + +fn decimal_exponent(suffix: &str) -> Result { + let exponent = suffix + .strip_prefix('e') + .or_else(|| suffix.strip_prefix('E')) + .ok_or(ParseQuantityError::Invalid)?; + if exponent.is_empty() { + return Err(ParseQuantityError::Invalid); + } + exponent.parse().map_err(|_| ParseQuantityError::Invalid) +} + +fn checked_power(base: u128, exponent: u32) -> Result { + base.checked_pow(exponent).ok_or(ParseQuantityError::Overflow) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::{ByteQuantity, CpuQuantity, ParseQuantityError}; + + #[test] + fn cpu_quantities_normalize_to_millicpus() { + let decimal: CpuQuantity = "0.5".parse().expect("decimal CPU should parse"); + let milli: CpuQuantity = "500m".parse().expect("millicpu should parse"); + let whole: CpuQuantity = "2.0".parse().expect("whole CPU should parse"); + + assert_eq!(decimal, milli); + assert_eq!(decimal.millicpus(), 500); + assert_eq!(decimal.to_string(), "500m"); + assert_eq!(whole.whole_cpus(), Some(2)); + assert_eq!(whole.to_string(), "2"); + } + + #[test] + fn cpu_quantities_reject_sub_millicpu_precision() { + assert_eq!("0.0001".parse::(), Err(ParseQuantityError::Precision)); + assert_eq!("0.5m".parse::(), Err(ParseQuantityError::Precision)); + } + + #[test] + fn byte_quantities_accept_binary_decimal_and_exponent_forms() { + let binary: ByteQuantity = "1.5Gi".parse().expect("binary quantity should parse"); + let binary_canonical: ByteQuantity = "1536Mi".parse().expect("canonical binary quantity should parse"); + let decimal: ByteQuantity = "2G".parse().expect("decimal quantity should parse"); + let exponent: ByteQuantity = "2e9".parse().expect("exponent quantity should parse"); + + assert_eq!(binary, binary_canonical); + assert_eq!(binary.to_string(), "1536Mi"); + assert_eq!(decimal, exponent); + assert_eq!(decimal.to_string(), "2G"); + } + + #[test] + fn byte_quantities_require_whole_positive_bytes() { + assert_eq!("0".parse::(), Err(ParseQuantityError::NonPositive)); + assert_eq!("0.5".parse::(), Err(ParseQuantityError::Precision)); + assert_eq!("-1Gi".parse::(), Err(ParseQuantityError::NonPositive)); + } + + #[test] + fn quantities_serialize_as_strings() { + let cpu: CpuQuantity = serde_json::from_str(r#""0.5""#).expect("CPU should deserialize"); + let bytes: ByteQuantity = serde_json::from_str(r#""1.5Gi""#).expect("bytes should deserialize"); + + assert_eq!(serde_json::to_string(&cpu).expect("CPU should serialize"), r#""500m""#); + assert_eq!( + serde_json::to_string(&bytes).expect("bytes should serialize"), + r#""1536Mi""# + ); + } +} diff --git a/sandbox/sandbox/src/root_filesystem.rs b/sandbox/sandbox/src/root_filesystem.rs new file mode 100644 index 0000000..968e0d5 --- /dev/null +++ b/sandbox/sandbox/src/root_filesystem.rs @@ -0,0 +1,93 @@ +//! Writable root filesystem configuration. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::ByteQuantity; + +/// How an Image is materialized as a writable Sandbox root filesystem. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum RootFilesystemMode { + /// Share an immutable Image and record Sandbox writes in a private layer. + Layered, + /// Materialize the complete Image into a private writable filesystem. + Direct, +} + +/// Deterministic set of root filesystem materialization modes. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RootFilesystemModeSet(BTreeSet); + +impl RootFilesystemModeSet { + /// Reports whether no root filesystem mode is supported. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Reports whether a root filesystem mode is supported. + #[must_use] + pub fn contains(&self, mode: RootFilesystemMode) -> bool { + self.0.contains(&mode) + } + + /// Iterates over supported modes in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } + + /// Returns the modes present in both sets. + #[must_use] + pub fn intersection(&self, other: &Self) -> Self { + Self(self.0.intersection(&other.0).copied().collect()) + } +} + +impl From<[RootFilesystemMode; N]> for RootFilesystemModeSet { + fn from(modes: [RootFilesystemMode; N]) -> Self { + Self(modes.into_iter().collect()) + } +} + +/// Desired capacity and immutable materialization mode of a Sandbox root filesystem. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RootFilesystem { + capacity: ByteQuantity, + mode: RootFilesystemMode, +} + +impl RootFilesystem { + /// Creates a root filesystem configuration. + #[must_use] + pub const fn new(capacity: ByteQuantity, mode: RootFilesystemMode) -> Self { + Self { capacity, mode } + } + + /// Creates a capacity-efficient layered root filesystem. + #[must_use] + pub const fn layered(capacity: ByteQuantity) -> Self { + Self::new(capacity, RootFilesystemMode::Layered) + } + + /// Creates a fully materialized private root filesystem. + #[must_use] + pub const fn direct(capacity: ByteQuantity) -> Self { + Self::new(capacity, RootFilesystemMode::Direct) + } + + /// Returns the desired writable capacity. + #[must_use] + pub const fn capacity(self) -> ByteQuantity { + self.capacity + } + + /// Returns the immutable materialization mode. + #[must_use] + pub const fn mode(self) -> RootFilesystemMode { + self.mode + } +} diff --git a/sandbox/sandbox/src/secret_store.rs b/sandbox/sandbox/src/secret_store.rs new file mode 100644 index 0000000..9fcc49a --- /dev/null +++ b/sandbox/sandbox/src/secret_store.rs @@ -0,0 +1,56 @@ +//! Host-owned secret references and material used by trusted mediators. + +use crate::{Error, LocalFuture}; +use zeroize::Zeroizing; + +/// An opaque reference to secret material kept outside sandbox state. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SecretReference(String); + +impl SecretReference { + /// Restores an opaque reference issued by a Secret Store implementation. + /// + /// Application code normally obtains references from [`SecretStore::set`]. + #[must_use] + pub fn from_opaque(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the opaque reference value. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Secret bytes that are zeroed when their host-side owner releases them. +pub struct SecretMaterial(Zeroizing>); + +impl SecretMaterial { + /// Takes ownership of secret bytes. + #[must_use] + pub fn new(value: Vec) -> Self { + Self(Zeroizing::new(value)) + } + + /// Borrows the secret bytes without creating another copy. + #[must_use] + pub fn expose(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl std::fmt::Debug for SecretMaterial { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("SecretMaterial([REDACTED])") + } +} + +/// Stores secret material separately from sandbox disks and networking. +pub trait SecretStore { + /// Creates or replaces a named value and returns its opaque reference. + fn set<'a>(&'a self, name: &'a str, value: &'a [u8]) -> LocalFuture<'a, Result>; + + /// Resolves current material for an already-authorized host-mediated use. + fn resolve<'a>(&'a self, reference: &'a SecretReference) -> LocalFuture<'a, Result>; +} diff --git a/sandbox/sandbox/src/service.rs b/sandbox/sandbox/src/service.rs new file mode 100644 index 0000000..e525d16 --- /dev/null +++ b/sandbox/sandbox/src/service.rs @@ -0,0 +1,1278 @@ +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + Hostname, PendingOperation, Platform, Sandbox, SandboxCapabilities, SandboxFeature, SandboxFeatureSet, SandboxName, + SandboxPath, SandboxResources, SandboxState, + backend::{SandboxBackend, SandboxBackendCapabilities}, + execution, file_transfer, image, + init::InitSystem, + mount::{Mount, MountKind}, + network, + progress::{Outcome, PendingSandbox, ProgressReporter, SandboxPhase, SandboxProgress}, + provider::SandboxProvider, + terminal, volume, +}; + +/// Errors produced by the backend-neutral Sandbox SDK. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResourceKind { + /// A Sandbox. + Sandbox, + /// An Execution. + Execution, + /// A Volume. + Volume, + /// An immutable Image or cache entry. + Image, + /// A regular file inside a Sandbox. + File, + /// A live Network Backend attachment. + Network, + /// Host-owned secret material. + Secret, +} + +impl std::fmt::Display for ResourceKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Sandbox => "Sandbox", + Self::Execution => "Execution", + Self::Volume => "Volume", + Self::Image => "Image", + Self::File => "file", + Self::Network => "Sandbox Network", + Self::Secret => "secret", + }) + } +} + +/// Stable category for programmatic Sandbox error handling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ErrorKind { + /// A requested resource does not exist. + NotFound, + /// Caller input violates a generic contract. + InvalidRequest, + /// An immutable field changed. + Immutable, + /// A provider cannot support the requested operation or value. + Unsupported, + /// Execution start or observation failed. + Execution, + /// Host I/O failed. + Io, + /// A provider-specific operation failed. + Backend, +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + /// A requested object does not exist. + #[error("{resource} {id:?} not found")] + NotFound { + /// Resource category. + resource: ResourceKind, + /// Stable name or identifier used for lookup. + id: String, + }, + /// An immutable field differs from the materialized Sandbox. + #[error("immutable Sandbox field changed: {0}")] + Immutable(&'static str), + /// A request violates the generic Sandbox contract. + #[error("invalid Sandbox field {field}: {reason}")] + Invalid { + /// Stable field or parameter path. + field: &'static str, + /// Human-readable invariant that was violated. + reason: &'static str, + }, + /// A requested Sandbox Feature is unavailable. + #[error("unsupported Sandbox Feature: {0:?}")] + UnsupportedFeature(SandboxFeature), + /// A requested Mount form is unavailable. + #[error("unsupported Sandbox Mount kind: {0:?}")] + UnsupportedMountKind(MountKind), + /// A requested root filesystem mode is unavailable. + #[error("unsupported Sandbox root filesystem mode: {0:?}")] + UnsupportedRootFilesystemMode(crate::RootFilesystemMode), + /// An Image Backend operation is unavailable for the requested Platform. + #[error("unsupported Image operation: {0:?}")] + UnsupportedImageOperation(image::ImageOperation), + /// An Image Backend operation does not accept the requested OCI source form. + #[error("unsupported Image Source kind {source_kind:?} for operation {operation:?}")] + UnsupportedImageSourceKind { + /// Operation being requested. + operation: image::ImageOperation, + /// OCI source form rejected by the Image Backend. + source_kind: image::ImageSourceKind, + }, + /// An Image Backend operation cannot materialize the requested root mode. + #[error("unsupported root filesystem mode {mode:?} for Image operation {operation:?}")] + UnsupportedImageRootFilesystemMode { + /// Operation being requested. + operation: image::ImageOperation, + /// Root filesystem mode rejected by the Image Backend. + mode: crate::RootFilesystemMode, + }, + /// A Sandbox Backend cannot materialize the requested Platform. + #[error("unsupported Sandbox Platform: {0}")] + UnsupportedPlatform(Platform), + /// A Sandbox Backend cannot represent a resource value exactly. + #[error("unsupported Sandbox resource value for {resource}: {value} ({reason})")] + UnsupportedResourceValue { + /// Stable field name in [`SandboxResources`]. + resource: &'static str, + /// Requested Kubernetes-style quantity. + value: String, + /// Backend-specific representation constraint. + reason: &'static str, + }, + /// A Sandbox Backend cannot apply a requested resource transition. + #[error("unsupported Sandbox resource change for {resource}: {current} -> {requested} ({reason})")] + UnsupportedResourceChange { + /// Stable field name in [`SandboxResources`]. + resource: &'static str, + /// Currently materialized quantity. + current: String, + /// Requested quantity. + requested: String, + /// Backend-specific transition constraint. + reason: &'static str, + }, + /// A Sandbox Backend returned an identity different from the create request. + #[error("Sandbox Backend returned ID {actual}; expected lifecycle-assigned ID {expected}")] + SandboxIdMismatch { + /// ID assigned before the create call. + expected: crate::SandboxId, + /// ID returned by the Sandbox Backend. + actual: crate::SandboxId, + }, + /// The service does not contain the Network Backend attached to a Sandbox. + #[error("configured service cannot operate Sandbox Network Backend {0}")] + NetworkBackendUnavailable(network::NetworkBackendId), + /// The Network Backend cannot use any endpoint offered by the Sandbox Backend. + #[error("Network Backend {0} has no compatible Sandbox Network endpoint")] + NetworkEndpointUnavailable(network::NetworkBackendId), + /// A Network Backend selected an endpoint not offered by the Sandbox Backend. + #[error("Sandbox Backend does not support selected Network endpoint {0:?}")] + UnsupportedNetworkEndpoint(network::NetworkEndpointSelection), + /// A Sandbox Backend opened a different endpoint than the immutable attachment. + #[error("Sandbox Backend opened Network endpoint {actual:?}; expected {expected:?}")] + NetworkEndpointMismatch { + /// Endpoint recorded when the Sandbox was created. + expected: network::NetworkEndpointSelection, + /// Endpoint returned by the Sandbox Backend. + actual: network::NetworkEndpointSelection, + }, + /// A resolved Image does not support the requested Sandbox Platform. + #[error("Image Platform {actual} does not satisfy requested Sandbox Platform {requested}")] + ImagePlatformMismatch { + /// Platform requested by the Sandbox specification. + requested: Box, + /// Platform reported by the built Image. + actual: Box, + }, + /// An asynchronous file operation failed. + #[error("{operation}: {source}")] + Io { + /// The operation being performed. + operation: &'static str, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + /// An Execution failed before it could produce an exit status. + #[error("Execution {id} failed: {message}")] + ExecutionFailed { + /// Identity assigned before dispatch. + id: execution::ExecutionId, + /// Backend-neutral failure description. + message: String, + }, + /// An Execution event stream ended without an exit status. + #[error("Execution {id} event stream ended before completion")] + ExecutionStreamEnded { + /// Identity assigned before dispatch. + id: execution::ExecutionId, + }, + /// An observable operation ended without a value or Error. + #[error("Sandbox operation stream ended before completion")] + OperationStreamEnded, + /// A Sandbox SDK component failed. + #[error("{operation}: {source}")] + Component { + /// The operation that failed. + operation: &'static str, + /// The component error. + #[source] + source: Box, + }, + /// An implementation-specific failure. + #[error("Sandbox implementation error: {0}")] + Backend(String), +} + +impl Error { + /// Creates a structured not-found error. + #[must_use] + pub fn not_found(resource: ResourceKind, id: &(impl ToString + ?Sized)) -> Self { + Self::NotFound { + resource, + id: id.to_string(), + } + } + + /// Creates a structured invalid-request error. + #[must_use] + pub const fn invalid(field: &'static str, reason: &'static str) -> Self { + Self::Invalid { field, reason } + } + + /// Returns a stable category suitable for control-flow decisions. + #[must_use] + pub const fn kind(&self) -> ErrorKind { + match self { + Self::NotFound { .. } => ErrorKind::NotFound, + Self::Immutable(_) | Self::SandboxIdMismatch { .. } | Self::NetworkEndpointMismatch { .. } => { + ErrorKind::Immutable + } + Self::Invalid { .. } | Self::ImagePlatformMismatch { .. } => ErrorKind::InvalidRequest, + Self::UnsupportedFeature(_) + | Self::UnsupportedMountKind(_) + | Self::UnsupportedRootFilesystemMode(_) + | Self::UnsupportedImageOperation(_) + | Self::UnsupportedImageSourceKind { .. } + | Self::UnsupportedImageRootFilesystemMode { .. } + | Self::UnsupportedPlatform(_) + | Self::UnsupportedResourceValue { .. } + | Self::UnsupportedResourceChange { .. } + | Self::NetworkBackendUnavailable(_) + | Self::NetworkEndpointUnavailable(_) + | Self::UnsupportedNetworkEndpoint(_) => ErrorKind::Unsupported, + Self::Io { .. } => ErrorKind::Io, + Self::ExecutionFailed { .. } | Self::ExecutionStreamEnded { .. } => ErrorKind::Execution, + Self::OperationStreamEnded | Self::Backend(_) => ErrorKind::Backend, + Self::Component { source, .. } => source.kind(), + } + } + + /// Reports whether this error or its component cause is not-found. + #[must_use] + pub const fn is_not_found(&self) -> bool { + matches!(self.kind(), ErrorKind::NotFound) + } + + /// Returns the missing resource and lookup value, including through component context. + #[must_use] + pub fn not_found_target(&self) -> Option<(ResourceKind, &str)> { + match self { + Self::NotFound { resource, id } => Some((*resource, id)), + Self::Component { source, .. } => source.not_found_target(), + _ => None, + } + } + + fn component(operation: &'static str, source: Self) -> Self { + Self::Component { + operation, + source: Box::new(source), + } + } +} + +/// Controls what an owner does with a released Sandbox. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum RetentionPolicy { + /// Stop the Sandbox and retain its storage for later re-adoption. + #[default] + Retain, + /// Stop and delete the Sandbox. + Delete, +} + +/// Backend-neutral Sandbox configuration used by higher layers. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SandboxSpec { + /// Source of the immutable Image. + pub image: image::ImageSource, + /// Desired Sandbox Platform. + pub platform: Platform, + /// Desired mutable compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + #[serde(default)] + pub init_system: InitSystem, + /// Whether an owner retains the Sandbox when releasing it. + #[serde(default)] + pub retention_policy: RetentionPolicy, +} + +impl SandboxSpec { + /// Validates fields interpreted by the generic Sandbox layer. + /// + /// # Errors + /// + /// Returns an error when the Image Source is incomplete. + pub fn validate(&self) -> Result<(), Error> { + self.image.validate()?; + self.platform.validate()?; + Ok(()) + } + + /// Resolves relative Image Source paths against a caller-supplied directory. + #[must_use] + pub fn resolve_from(&self, source_directory: &std::path::Path) -> Self { + Self { + image: self.image.resolve_from(source_directory), + ..self.clone() + } + } +} + +/// Desired materialization of one named Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EnsureSandboxRequest { + /// Stable Sandbox name. + name: SandboxName, + /// Hostname overriding the Sandbox name inside the guest. + hostname: Option, + /// Desired backend-neutral configuration. + spec: SandboxSpec, + /// Attachments contributed by the caller or a higher platform layer. + mounts: Vec, + /// Non-secret environment contributed by the caller or a higher platform layer. + environment: std::collections::BTreeMap, + /// Optional functionality required from the selected Sandbox Backend. + required_features: SandboxFeatureSet, +} + +impl EnsureSandboxRequest { + /// Creates a request without mounts or optional feature requirements. + #[must_use] + pub const fn new(name: SandboxName, spec: SandboxSpec) -> Self { + Self { + name, + hostname: None, + spec, + mounts: Vec::new(), + environment: std::collections::BTreeMap::new(), + required_features: SandboxFeatureSet::new(), + } + } + + /// Reports a hostname other than the Sandbox name inside the guest. + /// + /// The hostname is applied when the Sandbox is created; an existing Sandbox + /// keeps the hostname it was created with. + #[must_use] + pub fn with_hostname(mut self, hostname: Hostname) -> Self { + self.hostname = Some(hostname); + self + } + + /// Adds filesystem attachments materialized with the Sandbox. + #[must_use] + pub fn with_mounts(mut self, mounts: impl IntoIterator) -> Self { + self.mounts = mounts.into_iter().collect(); + self + } + + /// Adds non-secret environment inherited by image init and Sandbox Executions. + #[must_use] + pub fn with_environment(mut self, environment: impl IntoIterator) -> Self { + self.environment.extend(environment); + self + } + + /// Adds optional Backend Features required by the caller. + #[must_use] + pub fn requiring_features(mut self, features: impl Into) -> Self { + self.required_features.extend(&features.into()); + self + } + + /// Returns the stable Sandbox name. + #[must_use] + pub const fn name(&self) -> &SandboxName { + &self.name + } + + /// Returns the hostname the guest reports, defaulting to the Sandbox name. + #[must_use] + pub fn hostname(&self) -> Hostname { + self.hostname.clone().unwrap_or_else(|| self.name.clone().into()) + } + + /// Returns the desired Sandbox configuration. + #[must_use] + pub const fn spec(&self) -> &SandboxSpec { + &self.spec + } + + /// Mutably borrows the desired Sandbox configuration before dispatch. + #[must_use] + pub const fn spec_mut(&mut self) -> &mut SandboxSpec { + &mut self.spec + } + + /// Returns the desired filesystem attachments. + #[must_use] + pub fn mounts(&self) -> &[Mount] { + &self.mounts + } + + /// Returns the desired non-secret Sandbox environment. + #[must_use] + pub const fn environment(&self) -> &std::collections::BTreeMap { + &self.environment + } + + /// Returns explicit optional Feature requirements. + #[must_use] + pub const fn required_features(&self) -> &SandboxFeatureSet { + &self.required_features + } + + fn effective_required_features(&self) -> SandboxFeatureSet { + let mut required = self.required_features.clone(); + for mount in &self.mounts { + match mount { + Mount::Volume { .. } => required.insert(SandboxFeature::PersistentVolumes), + Mount::Bind { .. } | Mount::Tmpfs { .. } => {} + } + } + if self.spec.init_system == InitSystem::Image { + required.insert(SandboxFeature::ImageInit); + } + required + } +} + +/// Coordinates backend-neutral Sandbox operations. +#[derive(Clone)] +pub struct SandboxService { + provider: Rc, + network_backend: Option>, +} + +impl SandboxService { + /// Creates a service from a coherently paired Sandbox provider. + #[must_use] + pub fn new(provider: Rc) -> Self { + Self { + provider, + network_backend: None, + } + } + + fn backend(&self) -> &dyn SandboxBackend { + self.provider.backend() + } + + /// Reports consumer-visible functionality for one Sandbox Platform. + /// + /// # Errors + /// + /// Returns an error when the Provider does not support the Platform or the + /// configured Network Backend cannot use any offered endpoint. + pub async fn capabilities(&self, platform: &Platform) -> Result { + let capabilities = self.backend_capabilities(platform).await?; + let image_capabilities = self.image_backend_capabilities(platform).await?; + let network_available = match &self.network_backend { + Some(network) => { + let backend_id = network.id(); + network + .select_endpoint(&capabilities.network) + .ok_or(Error::NetworkEndpointUnavailable(backend_id))?; + true + } + None => false, + }; + let root_filesystem_modes = if image_capabilities.resolve.is_available() { + capabilities + .root_filesystems + .intersection(&image_capabilities.resolve.root_filesystem_modes) + } else { + crate::RootFilesystemModeSet::default() + }; + Ok(SandboxCapabilities::new( + capabilities.features, + capabilities.mounts, + root_filesystem_modes, + image_capabilities.prepared_image_export, + image_capabilities.prepared_image_import, + network_available, + )) + } + + async fn backend_capabilities(&self, platform: &Platform) -> Result { + self.backend() + .capabilities(platform) + .await + .map_err(|error| Error::component("discover Sandbox capabilities", error)) + } + + async fn image_backend_capabilities(&self, platform: &Platform) -> Result { + self.provider + .image_backend() + .capabilities(platform) + .await + .map_err(|error| Error::component("discover Image capabilities", error)) + } + + /// Attaches every newly materialized Sandbox to this Network Backend. + /// + /// The selected Backend identity becomes immutable when the Sandbox is + /// created. The Network Backend remains independently replaceable when + /// composing a service for a different Sandbox. + #[must_use] + pub fn with_network_backend(mut self, network_backend: Rc) -> Self { + self.network_backend = Some(network_backend); + self + } + + /// Creates or re-adopts a named Sandbox and returns an operation that + /// yields progress until the Sandbox is ready or provisioning fails. + /// + /// Awaiting the returned value discards progress and returns the terminal + /// result. Polling it as a stream exposes every provisioning event. + #[must_use] + pub fn ensure<'a>(&'a self, request: &'a EnsureSandboxRequest) -> PendingSandbox<'a> { + PendingOperation::with_events(|events| Box::pin(async move { self.ensure_inner(request, &events).await })) + } + + /// Exports a prepared Image into an opaque Provider-owned artifact. + #[must_use] + pub fn export_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::with_events(|events| { + Box::pin(async move { + request.validate()?; + self.require_image_operation(image::ImageOperation::PreparedImageExport, request) + .await?; + let span = events.start_phase(SandboxPhase::ImagePrepare).await; + let prepared = self + .provider + .image_backend() + .export_prepared_image(request, destination) + .forward(&events) + .await?; + prepared.validate_for(request)?; + span.complete().await; + Ok(prepared) + }) + }) + } + + /// Imports a prepared Image into this Provider's materialization domain. + #[must_use] + pub fn import_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::with_events(|events| { + Box::pin(async move { + request.validate()?; + self.require_image_operation(image::ImageOperation::PreparedImageImport, request) + .await?; + let span = events.start_phase(SandboxPhase::ImagePrepare).await; + let prepared = self + .provider + .image_backend() + .import_prepared_image(request, source) + .forward(&events) + .await?; + prepared.validate_for(request)?; + span.complete().await; + Ok(prepared) + }) + }) + } + + async fn ensure_inner( + &self, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let retention_policy = request.spec.retention_policy; + let sandbox = self.ensure_sandbox(request, events).await?; + Ok(SandboxHandle { + service: self.clone(), + sandbox, + retention_policy, + }) + } + + async fn ensure_sandbox( + &self, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let span = events.start_phase(SandboxPhase::Validate).await; + request.spec.validate()?; + validate_environment(&request.environment)?; + span.end(Outcome::Completed).await; + + let span = events.start_phase(SandboxPhase::Lookup).await; + match self.backend().find(&request.name).await { + Ok(sandbox) => { + if !sandbox.image.platform.satisfies(&request.spec.platform) { + return Err(Error::Immutable("platform")); + } + if sandbox.image.source != request.spec.image { + return Err(Error::Immutable("image")); + } + if sandbox.resources.root_filesystem().mode() != request.spec.resources.root_filesystem().mode() { + return Err(Error::Immutable("resources.rootFilesystem.mode")); + } + if sandbox.init_system != request.spec.init_system { + return Err(Error::Immutable("initSystem")); + } + if sandbox.mounts != request.mounts { + return Err(Error::Immutable("mounts")); + } + span.end(Outcome::Reused).await; + let network = self + .require_backend_features_observed(&sandbox.image.platform, request, events) + .await?; + if sandbox.network != network { + return Err(Error::Immutable("network")); + } + let sandbox = self.ensure_updates(sandbox, request, events).await?; + self.ensure_running(sandbox, events).await + } + Err(error) if error.is_not_found() => { + span.end(Outcome::Completed).await; + self.require_backend_features_observed(&request.spec.platform, request, events) + .await?; + let image = self.resolve_image(request, events).await?; + let network = self + .require_backend_features_observed(&image.platform, request, events) + .await?; + let span = events.start_phase(SandboxPhase::SandboxCreate).await; + let id = crate::SandboxId::generate(); + let sandbox = self + .backend() + .create(crate::backend::CreateSandboxRequest { + id: id.clone(), + name: request.name.clone(), + hostname: request.hostname(), + image, + resources: request.spec.resources, + init_system: request.spec.init_system, + mounts: request.mounts.clone(), + environment: request.environment.clone(), + network, + }) + .forward(events) + .await + .map_err(|error| Error::component("create Sandbox", error))?; + if sandbox.id != id { + return Err(Error::SandboxIdMismatch { + expected: id, + actual: sandbox.id, + }); + } + span.end(Outcome::Completed).await; + self.ensure_running(sandbox, events).await + } + Err(error) => Err(Error::component("find Sandbox", error)), + } + } + + async fn resolve_image( + &self, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let span = events.start_phase(SandboxPhase::ImageResolve).await; + let image = self + .provider + .image_backend() + .resolve(&image::ResolveRequest { + source: request.spec.image.clone(), + platform: request.spec.platform.clone(), + root_filesystem_mode: request.spec.resources.root_filesystem().mode(), + }) + .forward(events) + .await + .map_err(|error| Error::component("resolve Sandbox Image", error))?; + image.validate()?; + if !image.platform.satisfies(&request.spec.platform) { + return Err(Error::ImagePlatformMismatch { + requested: Box::new(request.spec.platform.clone()), + actual: Box::new(image.platform), + }); + } + span.end(Outcome::Completed).await; + Ok(image) + } + + /// Converges mutable Sandbox settings in one `SandboxUpdate` phase. + async fn ensure_updates( + &self, + sandbox: Sandbox, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let span = events.start_phase(SandboxPhase::SandboxUpdate).await; + let progress = events.steps(); + let (sandbox, environment) = self + .ensure_environment(sandbox, &request.environment, events, &progress) + .await?; + let (sandbox, resources) = self + .ensure_resources(sandbox, request.spec.resources, events, &progress) + .await?; + let outcome = if environment == Outcome::Reused && resources == Outcome::Reused { + Outcome::Reused + } else { + Outcome::Completed + }; + span.end(outcome).await; + Ok(sandbox) + } + + async fn ensure_resources( + &self, + sandbox: Sandbox, + resources: SandboxResources, + events: &ProgressReporter, + progress: &SandboxProgress, + ) -> Result<(Sandbox, Outcome), Error> { + if sandbox.resources == resources { + return Ok((sandbox, Outcome::Reused)); + } + let step = progress.start_step("Update Sandbox resources").await; + let sandbox = self + .backend() + .update_resources(&sandbox.id, resources) + .forward(events) + .await + .map_err(|error| Error::component("update Sandbox resources", error))?; + step.complete().await; + Ok((sandbox, Outcome::Completed)) + } + + async fn ensure_environment( + &self, + sandbox: Sandbox, + environment: &std::collections::BTreeMap, + events: &ProgressReporter, + progress: &SandboxProgress, + ) -> Result<(Sandbox, Outcome), Error> { + if &sandbox.environment == environment { + return Ok((sandbox, Outcome::Reused)); + } + let step = progress.start_step("Update Sandbox environment").await; + if sandbox.state != SandboxState::Stopped { + self.backend() + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox for environment update", error))?; + if let Some(network_backend) = self.network_backend_for(&sandbox)? { + network_backend + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox Network for environment update", error))?; + } + } + let sandbox = self + .backend() + .update_environment(&sandbox.id, environment.clone()) + .forward(events) + .await + .map_err(|error| Error::component("update Sandbox environment", error))?; + step.complete().await; + Ok((sandbox, Outcome::Completed)) + } + + async fn require_backend_features_observed( + &self, + platform: &Platform, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result, Error> { + let span = events.start_phase(SandboxPhase::FeatureDiscovery).await; + let network = self.require_backend_features(platform, request).await?; + span.end(Outcome::Completed).await; + Ok(network) + } + + async fn require_backend_features( + &self, + platform: &Platform, + request: &EnsureSandboxRequest, + ) -> Result, Error> { + let capabilities = self.backend_capabilities(platform).await?; + for feature in request.effective_required_features().iter() { + if !capabilities.features.contains(feature) { + return Err(Error::UnsupportedFeature(feature)); + } + } + for mount in &request.mounts { + if !capabilities.mounts.contains(mount.kind()) { + return Err(Error::UnsupportedMountKind(mount.kind())); + } + } + let root_filesystem_mode = request.spec.resources.root_filesystem().mode(); + if !capabilities.root_filesystems.contains(root_filesystem_mode) { + return Err(Error::UnsupportedRootFilesystemMode(root_filesystem_mode)); + } + self.require_image_operation( + image::ImageOperation::Resolve, + &image::ResolveRequest { + source: request.spec.image.clone(), + platform: platform.clone(), + root_filesystem_mode, + }, + ) + .await?; + let Some(network_backend) = &self.network_backend else { + return Ok(None); + }; + let backend_id = network_backend.id(); + let selection = network_backend + .select_endpoint(&capabilities.network) + .ok_or_else(|| Error::NetworkEndpointUnavailable(backend_id.clone()))?; + if !capabilities.network.supports(&selection) { + return Err(Error::UnsupportedNetworkEndpoint(selection)); + } + Ok(Some(network::NetworkAttachment { + backend: backend_id, + endpoint: selection, + })) + } + + async fn require_image_operation( + &self, + operation: image::ImageOperation, + request: &image::ResolveRequest, + ) -> Result<(), Error> { + let capabilities = self.image_backend_capabilities(&request.platform).await?; + let operation_capabilities = match operation { + image::ImageOperation::Resolve => &capabilities.resolve, + image::ImageOperation::PreparedImageExport => &capabilities.prepared_image_export, + image::ImageOperation::PreparedImageImport => &capabilities.prepared_image_import, + }; + if !operation_capabilities.is_available() { + return Err(Error::UnsupportedImageOperation(operation)); + } + let source = request.source.kind(); + if !operation_capabilities.sources.contains(source) { + return Err(Error::UnsupportedImageSourceKind { + operation, + source_kind: source, + }); + } + if !operation_capabilities + .root_filesystem_modes + .contains(request.root_filesystem_mode) + { + return Err(Error::UnsupportedImageRootFilesystemMode { + operation, + mode: request.root_filesystem_mode, + }); + } + Ok(()) + } + + /// Returns the materialized Sandbox for a stable name. + /// + /// # Errors + /// + /// Returns an error when the Sandbox does not exist or the Backend fails. + pub async fn inspect(&self, name: &SandboxName) -> Result { + self.backend() + .find(name) + .await + .map_err(|error| Error::component("find Sandbox", error)) + } + + /// Opens an effect-free Handle for an already materialized Sandbox. + /// + /// This never creates, starts, updates, or reconnects the Sandbox. Callers + /// that own only in-Sandbox effects use it after a lifecycle owner has + /// persisted the exact Sandbox identity. + /// + /// # Errors + /// + /// Returns an error when the Sandbox does not exist or cannot be inspected. + pub async fn open(&self, id: &crate::SandboxId, retention_policy: RetentionPolicy) -> Result { + let sandbox = self + .backend() + .inspect(id) + .await + .map_err(|error| Error::component("inspect Sandbox", error))?; + Ok(SandboxHandle { + service: self.clone(), + sandbox, + retention_policy, + }) + } + + /// Stops and deletes a named Sandbox if it exists. + /// + /// # Errors + /// + /// Returns an error when a lifecycle component fails. + pub async fn delete(&self, name: &SandboxName) -> Result<(), Error> { + self.release(name, RetentionPolicy::Delete).await + } + + /// Stops a named Sandbox and its Network if it exists, keeping its identity, + /// root filesystem, Volumes and Network attachment for a later [`Self::ensure`]. + /// + /// # Errors + /// + /// Returns an error when a lifecycle component fails. + pub async fn stop(&self, name: &SandboxName) -> Result<(), Error> { + self.release(name, RetentionPolicy::Retain).await + } + + /// Stops a Sandbox and retains or deletes its materialized resources. + /// + /// # Errors + /// + /// Returns an error when a lifecycle component fails. + pub async fn release(&self, name: &SandboxName, retention: RetentionPolicy) -> Result<(), Error> { + let sandbox = match self.backend().find(name).await { + Ok(sandbox) => sandbox, + Err(error) if error.is_not_found() => return Ok(()), + Err(error) => return Err(Error::component("find Sandbox", error)), + }; + self.release_sandbox(sandbox, retention).await + } + + async fn release_by_id(&self, id: &crate::SandboxId, retention: RetentionPolicy) -> Result<(), Error> { + let sandbox = match self.backend().inspect(id).await { + Ok(sandbox) => sandbox, + Err(error) if error.is_not_found() => return Ok(()), + Err(error) => return Err(Error::component("inspect Sandbox", error)), + }; + self.release_sandbox(sandbox, retention).await + } + + async fn release_sandbox(&self, sandbox: Sandbox, retention: RetentionPolicy) -> Result<(), Error> { + let network_backend = self.network_backend_for(&sandbox)?; + + if sandbox.state != SandboxState::Stopped { + self.backend() + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox", error))?; + } + if let Some(network_backend) = network_backend { + network_backend + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox Network", error))?; + } + if retention == RetentionPolicy::Delete { + if let Some(network_backend) = network_backend { + network_backend + .delete(&sandbox.id) + .await + .map_err(|error| Error::component("delete Sandbox Network", error))?; + } + self.backend() + .delete(&sandbox.id) + .await + .map_err(|error| Error::component("delete Sandbox", error))?; + } + Ok(()) + } + + async fn ensure_running(&self, sandbox: Sandbox, events: &ProgressReporter) -> Result { + if let Some(network_backend) = self.network_backend_for(&sandbox)? + && !network_backend.is_running(&sandbox.id) + { + let span = events.start_phase(SandboxPhase::NetworkStart).await; + let endpoint = self + .backend() + .open_network_endpoint(&sandbox.id) + .await + .map_err(|error| Error::component("open Sandbox Network endpoint", error))?; + let expected = sandbox + .network + .as_ref() + .ok_or(Error::invalid("network", "attachment is missing"))? + .endpoint + .clone(); + let actual = endpoint.selection(); + if actual != expected { + return Err(Error::NetworkEndpointMismatch { expected, actual }); + } + network_backend + .start(network::StartNetworkRequest { + sandbox_id: sandbox.id.clone(), + sandbox_name: sandbox.name.clone(), + endpoint, + }) + .await + .map_err(|error| Error::component("start Sandbox Network", error))?; + span.end(Outcome::Completed).await; + } + let span = events.start_phase(SandboxPhase::SandboxStart).await; + if sandbox.state != SandboxState::Running { + self.backend() + .start(&sandbox.id) + .forward(events) + .await + .map_err(|error| Error::component("start Sandbox", error))?; + } + let outcome = if sandbox.state == SandboxState::Running { + Outcome::Reused + } else { + Outcome::Completed + }; + span.end(outcome).await; + let span = events.start_phase(SandboxPhase::Inspect).await; + let sandbox = self + .backend() + .inspect(&sandbox.id) + .await + .map_err(|error| Error::component("inspect Sandbox", error))?; + span.end(Outcome::Completed).await; + Ok(sandbox) + } + + fn network_backend_for(&self, sandbox: &Sandbox) -> Result>, Error> { + let Some(attachment) = &sandbox.network else { + return Ok(None); + }; + let backend = self + .network_backend + .as_ref() + .filter(|backend| backend.id() == attachment.backend) + .ok_or_else(|| Error::NetworkBackendUnavailable(attachment.backend.clone()))?; + Ok(Some(backend)) + } + + /// Creates or returns a named Volume. + /// + /// # Errors + /// + /// Returns an error when the provider cannot materialize the Volume. + pub async fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> Result { + self.backend().ensure_volume(request).await + } + + /// Finds a named Volume. + /// + /// # Errors + /// + /// Returns an error when the Volume does not exist or the provider fails. + pub async fn find_volume(&self, name: &volume::VolumeName) -> Result { + self.backend().find_volume(name).await + } + + /// Deletes a Volume by identifier. + /// + /// # Errors + /// + /// Returns an error when the Volume does not exist or the provider fails. + pub async fn delete_volume(&self, id: &volume::VolumeId) -> Result<(), Error> { + self.backend().delete_volume(id).await + } +} + +fn validate_environment(environment: &std::collections::BTreeMap) -> Result<(), Error> { + for (name, value) in environment { + let valid_name = !name.is_empty() + && name.bytes().enumerate().all(|(index, byte)| { + byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit()) + }); + if !valid_name || value.contains('\0') { + return Err(Error::invalid( + "environment", + "names must be portable environment variables and values must not contain NUL", + )); + } + } + Ok(()) +} + +/// A ready Sandbox and the operations scoped to its immutable lifecycle ID. +/// +/// The Handle owns the retention policy selected during `ensure`, but dropping +/// it performs no asynchronous cleanup. Call [`Self::release`] or +/// [`Self::delete`] explicitly. Its observed Sandbox snapshot may become stale +/// and changes only when [`Self::refresh`] succeeds. +#[must_use = "release or delete the Sandbox Handle explicitly when its work is complete"] +pub struct SandboxHandle { + service: SandboxService, + sandbox: Sandbox, + retention_policy: RetentionPolicy, +} + +impl std::fmt::Debug for SandboxHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SandboxHandle") + .field("sandbox", &self.sandbox) + .field("retention_policy", &self.retention_policy) + .finish_non_exhaustive() + } +} + +impl SandboxHandle { + /// Returns the immutable lifecycle identifier targeted by this Handle. + #[must_use] + pub const fn id(&self) -> &crate::SandboxId { + &self.sandbox.id + } + + /// Returns the stable lookup name of this Sandbox. + #[must_use] + pub const fn name(&self) -> &SandboxName { + &self.sandbox.name + } + + /// Returns the latest observed Sandbox snapshot cached by this Handle. + #[must_use] + pub const fn snapshot(&self) -> &Sandbox { + &self.sandbox + } + + /// Refreshes and returns the Sandbox snapshot. + /// + /// # Errors + /// + /// Returns an error when the provider cannot inspect the Sandbox. + pub async fn refresh(&mut self) -> Result<&Sandbox, Error> { + self.sandbox = self + .service + .backend() + .inspect(&self.sandbox.id) + .await + .map_err(|error| Error::component("inspect Sandbox", error))?; + Ok(&self.sandbox) + } + + /// Starts an addressable Execution and returns its live event stream. + /// + /// # Errors + /// + /// Returns an error when the provider cannot start the Execution. + pub async fn start_execution( + &self, + request: execution::StartExecutionRequest, + ) -> Result { + self.service.backend().start_execution(&self.sandbox.id, request).await + } + + /// Starts an addressable Execution and collects its output in memory. + /// + /// Use [`Self::start_execution`] to stream unbounded output. + /// + /// # Errors + /// + /// Returns an error when execution fails or its event stream ends unexpectedly. + pub async fn run_execution(&self, spec: execution::ExecutionSpec) -> Result { + let request = execution::StartExecutionRequest::new(spec); + self.start_execution(request).await?.collect().await + } + + /// Starts a terminal Execution with programmatic input and output. + /// + /// # Errors + /// + /// Returns an error when the provider cannot start the terminal Execution. + pub async fn start_terminal_execution( + &self, + request: terminal::StartTerminalExecutionRequest, + ) -> Result { + self.service + .backend() + .start_terminal_execution(&self.sandbox.id, request) + .await + } + + /// Attaches the caller's terminal to an interactive Execution. + /// + /// # Errors + /// + /// Returns an error when the provider cannot attach the terminal. + pub async fn attach_terminal( + &self, + request: terminal::AttachTerminalRequest, + ) -> Result { + self.service.backend().attach_terminal(&self.sandbox.id, request).await + } + + /// Requests graceful termination of a live Execution. + /// + /// # Errors + /// + /// Returns an error when the Execution cannot be found or terminated. + pub async fn terminate_execution(&self, id: &execution::ExecutionId) -> Result<(), Error> { + self.service.backend().terminate_execution(&self.sandbox.id, id).await + } + + /// Forces a live Execution to stop. + /// + /// # Errors + /// + /// Returns an error when the Execution cannot be found or killed. + pub async fn kill_execution(&self, id: &execution::ExecutionId) -> Result<(), Error> { + self.service.backend().kill_execution(&self.sandbox.id, id).await + } + + /// Opens a regular Sandbox file for streamed reading. + /// + /// # Errors + /// + /// Returns an error when the provider cannot open the file. + pub async fn read_file(&self, path: &SandboxPath) -> Result { + self.service.backend().read_file(&self.sandbox.id, path).await + } + + /// Creates or replaces a regular Sandbox file from a byte stream. + /// + /// The replacement is atomic: a process in the Sandbox reading the path concurrently sees + /// either the previous file or the complete new one. A replaced regular file keeps its mode + /// and ownership. + /// + /// # Errors + /// + /// Returns an error when the provider cannot write the file. + pub async fn write_file(&self, path: &SandboxPath, contents: file_transfer::ByteReader) -> Result<(), Error> { + self.service + .backend() + .write_file(&self.sandbox.id, path, contents) + .await + } + + /// Stops this Sandbox and applies the retention policy used to ensure it. + /// + /// # Errors + /// + /// Returns an error when the Sandbox or its Network cannot be released. + pub async fn release(self) -> Result<(), Error> { + self.service + .release_by_id(&self.sandbox.id, self.retention_policy) + .await + } + + /// Stops and deletes this Sandbox regardless of its retention policy. + /// + /// # Errors + /// + /// Returns an error when the Sandbox or its Network cannot be deleted. + pub async fn delete(self) -> Result<(), Error> { + self.service + .release_by_id(&self.sandbox.id, RetentionPolicy::Delete) + .await + } +} diff --git a/sandbox/sandbox/src/terminal.rs b/sandbox/sandbox/src/terminal.rs new file mode 100644 index 0000000..acedc1f --- /dev/null +++ b/sandbox/sandbox/src/terminal.rs @@ -0,0 +1,218 @@ +//! Bidirectional terminal execution inside a running Sandbox. + +use std::{fmt, pin::Pin, rc::Rc}; + +use bytes::Bytes; +use futures_core::Stream; + +use crate::{Error, LocalFuture, execution}; + +/// Character-cell dimensions of a terminal. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalSize { + rows: u16, + columns: u16, +} + +impl TerminalSize { + /// Conventional terminal size used when the host cannot report one. + pub const DEFAULT: Self = Self { rows: 24, columns: 80 }; + + /// Creates non-zero terminal dimensions. + /// + /// # Errors + /// + /// Returns an error when either dimension is zero. + pub const fn new(rows: u16, columns: u16) -> Result { + if rows == 0 || columns == 0 { + return Err(InvalidTerminalSize); + } + Ok(Self { rows, columns }) + } + + /// Returns the terminal height in character cells. + #[must_use] + pub const fn rows(self) -> u16 { + self.rows + } + + /// Returns the terminal width in character cells. + #[must_use] + pub const fn columns(self) -> u16 { + self.columns + } +} + +impl Default for TerminalSize { + fn default() -> Self { + Self::DEFAULT + } +} + +/// A terminal size contained a zero dimension. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InvalidTerminalSize; + +impl fmt::Display for InvalidTerminalSize { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("terminal rows and columns must both be non-zero") + } +} + +impl std::error::Error for InvalidTerminalSize {} + +/// Starts a terminal-backed Execution in a running Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StartTerminalExecutionRequest { + /// Backend-neutral identity assigned before dispatch. + id: execution::ExecutionId, + /// Desired command and process environment. + spec: execution::ExecutionSpec, + /// Dimensions assigned before the process starts. + initial_size: TerminalSize, +} + +impl StartTerminalExecutionRequest { + /// Creates a request with a freshly assigned Execution identifier. + #[must_use] + pub fn new(spec: execution::ExecutionSpec, initial_size: TerminalSize) -> Self { + Self { + id: execution::ExecutionId::generate(), + spec, + initial_size, + } + } + + /// Returns the assigned Execution identifier. + #[must_use] + pub const fn id(&self) -> &execution::ExecutionId { + &self.id + } + + /// Returns the desired command and process environment. + #[must_use] + pub const fn spec(&self) -> &execution::ExecutionSpec { + &self.spec + } + + /// Returns the terminal dimensions assigned before process start. + #[must_use] + pub const fn initial_size(&self) -> TerminalSize { + self.initial_size + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (execution::ExecutionId, execution::ExecutionSpec, TerminalSize) { + (self.id, self.spec, self.initial_size) + } +} + +/// Attaches the caller's terminal to an interactive Execution in a running Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AttachTerminalRequest { + spec: execution::ExecutionSpec, + detach_keys: Option, +} + +impl AttachTerminalRequest { + /// Creates a terminal attachment request. + #[must_use] + pub const fn new(spec: execution::ExecutionSpec) -> Self { + Self { + spec, + detach_keys: None, + } + } + + /// Returns the desired command and process environment. + #[must_use] + pub const fn spec(&self) -> &execution::ExecutionSpec { + &self.spec + } + + /// Overrides the Provider's default local detach key sequence. + /// + /// The value uses Docker-style syntax, such as `"ctrl-]"` or `"ctrl-b,d"`. + #[must_use] + pub fn with_detach_keys(mut self, keys: impl Into) -> Self { + self.detach_keys = Some(keys.into()); + self + } + + /// Returns an explicit local detach key sequence, when configured. + #[must_use] + pub fn detach_keys(&self) -> Option<&str> { + self.detach_keys.as_deref() + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (execution::ExecutionSpec, Option) { + (self.spec, self.detach_keys) + } +} + +/// Terminal condition observed when an attachment ends. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TerminalAttachOutcome { + /// The attached Execution exited. + Exited(execution::ExitStatus), + /// The caller detached before an Execution exit was observed. + Detached, +} + +/// Input and terminal controls tied to one live addressable terminal Execution. +pub trait TerminalControl { + /// Writes raw terminal input bytes, applying backend transport backpressure. + fn write_input(&self, bytes: Bytes) -> LocalFuture<'_, Result<(), Error>>; + + /// Closes the input stream and sends end-of-file to the process. + fn close_input(&self) -> LocalFuture<'_, Result<(), Error>>; + + /// Changes the terminal's character-cell dimensions. + fn resize(&self, size: TerminalSize) -> LocalFuture<'_, Result<(), Error>>; +} + +/// One event from a live terminal Execution. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TerminalEvent { + /// The process started. + Started { + /// Backend-reported process identifier, when available. + process_id: Option, + }, + /// Raw terminal output bytes. A terminal combines standard output and error. + Output(Bytes), + /// The process exited. + Exited(execution::ExitStatus), + /// The process could not be started. + Failed { + /// Backend-neutral failure description. + message: String, + }, +} + +/// A non-`Send` stream of events from one live terminal Execution. +pub type TerminalEventStream = Pin>>>; + +/// A newly started, addressable terminal Execution. +pub struct StartedTerminalExecution { + /// Identifier accepted by the common Execution termination operations. + pub id: execution::ExecutionId, + /// Bidirectional controls for the transient terminal connection. + pub control: Rc, + /// Events emitted until the Execution exits or fails. + pub events: TerminalEventStream, +} + +impl fmt::Debug for StartedTerminalExecution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StartedTerminalExecution") + .field("id", &self.id) + .finish_non_exhaustive() + } +} diff --git a/sandbox/sandbox/src/volume.rs b/sandbox/sandbox/src/volume.rs new file mode 100644 index 0000000..9e84681 --- /dev/null +++ b/sandbox/sandbox/src/volume.rs @@ -0,0 +1,135 @@ +//! Mutable storage with a lifecycle independent of a Sandbox image. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{InvalidSandboxName, SandboxName}; + +/// Stable, portable caller-provided Volume name. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct VolumeName(SandboxName); + +impl VolumeName { + /// Creates a name accepted by local and Kubernetes-oriented providers. + /// + /// # Errors + /// + /// Returns an error unless the value is a lowercase DNS label. + pub fn new(value: impl Into) -> Result { + SandboxName::new(value).map(Self).map_err(InvalidVolumeName) + } + + /// Returns the name as text. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl AsRef for VolumeName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for VolumeName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl std::str::FromStr for VolumeName { + type Err = InvalidVolumeName; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +/// A Volume name was not a portable lowercase DNS label. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvalidVolumeName(InvalidSandboxName); + +impl fmt::Display for InvalidVolumeName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "invalid Volume name: {}", self.0) + } +} + +impl std::error::Error for InvalidVolumeName {} + +/// Identifies a Volume independently of backend-specific identifiers. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct VolumeId(Uuid); + +impl VolumeId { + fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the UUID representation. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for VolumeId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for VolumeId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Backend-neutral view of a materialized Volume. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Volume { + /// Stable backend-neutral identifier. + pub id: VolumeId, + /// Stable caller-provided name. + pub name: VolumeName, +} + +/// Inputs used to ensure a Volume exists. +#[derive(Debug, Eq, PartialEq)] +pub struct EnsureVolumeRequest { + /// Backend-neutral identity used when a new Volume is materialized. + id: VolumeId, + /// Stable caller-provided name. + pub name: VolumeName, +} + +impl EnsureVolumeRequest { + /// Creates a request with a freshly assigned Volume identifier. + #[must_use] + pub fn new(name: VolumeName) -> Self { + Self { + id: VolumeId::generate(), + name, + } + } + + /// Returns the identifier to use if the Volume is newly materialized. + #[must_use] + pub const fn id(&self) -> &VolumeId { + &self.id + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (VolumeId, VolumeName) { + (self.id, self.name) + } +} diff --git a/sandbox/sandbox/tests/architecture.rs b/sandbox/sandbox/tests/architecture.rs new file mode 100644 index 0000000..7acfa80 --- /dev/null +++ b/sandbox/sandbox/tests/architecture.rs @@ -0,0 +1,13 @@ +#![allow(clippy::expect_used)] + +use std::{fs, path::Path}; + +#[test] +fn sandbox_crate_has_no_higher_layer_or_concrete_backend_dependencies() { + let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("sandbox Cargo.toml should be readable"); + + assert!(!manifest.contains("agent =")); + assert!(!manifest.contains("agent-runtime =")); + assert!(!manifest.contains("sandbox-microsandbox =")); +} diff --git a/sandbox/sandbox/tests/image.rs b/sandbox/sandbox/tests/image.rs new file mode 100644 index 0000000..a4eae8b --- /dev/null +++ b/sandbox/sandbox/tests/image.rs @@ -0,0 +1,137 @@ +#![allow(clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +use sandbox::{ + Platform, RootFilesystemMode, RootFilesystemModeSet, + image::{ + ImageBackend as _, ImageOperationCapabilities, ImageSource, ImageSourceKind, ImageSourceKindSet, ResolveRequest, + }, + memory::MemoryImageBackend, +}; + +#[test] +fn build_source_resolves_only_its_context_from_the_manifest_directory() { + let source = ImageSource::Build { + context: PathBuf::from("image"), + dockerfile: PathBuf::from("containers/Agent.Dockerfile"), + target: Some("runtime".into()), + }; + + assert_eq!( + source.resolve_from(Path::new("/manifests/worker")), + ImageSource::Build { + context: PathBuf::from("/manifests/worker/image"), + dockerfile: PathBuf::from("containers/Agent.Dockerfile"), + target: Some("runtime".into()), + } + ); +} + +#[test] +fn reference_source_is_independent_of_the_manifest_directory() { + let source = ImageSource::Reference { + reference: "ghcr.io/example/agent@sha256:1234".to_string(), + }; + + assert_eq!(source.resolve_from(Path::new("/manifests/worker")), source); +} + +#[test] +fn image_source_has_an_explicit_serialized_variant() { + let build: ImageSource = serde_json::from_value(serde_json::json!({ + "type": "build", + "context": ".", + "dockerfile": "Dockerfile", + "target": "minimal" + })) + .expect("build source should decode"); + let reference: ImageSource = serde_json::from_value(serde_json::json!({ + "type": "reference", + "reference": "docker.io/library/alpine:3.22" + })) + .expect("reference source should decode"); + + assert!(matches!( + build, + ImageSource::Build { + target: Some(target), + .. + } if target == "minimal" + )); + assert!(matches!(reference, ImageSource::Reference { .. })); +} + +#[test] +fn build_source_rejects_an_empty_target() { + let source = ImageSource::Build { + context: PathBuf::from("."), + dockerfile: PathBuf::from("Dockerfile"), + target: Some(" ".into()), + }; + + assert!(source.validate().is_err()); +} + +#[test] +fn image_operation_requires_at_least_one_source_and_mode() { + assert!( + ImageOperationCapabilities::new([ImageSourceKind::Reference].into(), [RootFilesystemMode::Direct].into(),) + .is_available() + ); + assert!( + !ImageOperationCapabilities::new([ImageSourceKind::Reference].into(), RootFilesystemModeSet::default(),) + .is_available() + ); + assert!( + !ImageOperationCapabilities::new(ImageSourceKindSet::default(), [RootFilesystemMode::Direct].into(),) + .is_available() + ); +} + +#[tokio::test(flavor = "local")] +async fn memory_images_have_deterministic_sha256_manifest_digests() { + let backend = MemoryImageBackend; + let request = ResolveRequest { + source: ImageSource::Reference { + reference: "registry.example/worker:latest".to_string(), + }, + platform: Platform::new("linux", "amd64"), + root_filesystem_mode: RootFilesystemMode::Layered, + }; + + let first = backend.resolve(&request).await.expect("image should resolve"); + let second = backend.resolve(&request).await.expect("image should resolve again"); + let digest = first + .manifest_digest + .strip_prefix("sha256:") + .expect("manifest digest should use SHA-256"); + + assert_eq!(first.manifest_digest, second.manifest_digest); + assert_eq!(digest.len(), 64); + assert!( + digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); + + let different_source = backend + .resolve(&ResolveRequest { + source: ImageSource::Reference { + reference: "registry.example/other:latest".to_string(), + }, + platform: request.platform.clone(), + root_filesystem_mode: request.root_filesystem_mode, + }) + .await + .expect("another image should resolve"); + let different_platform = backend + .resolve(&ResolveRequest { + platform: Platform::new("linux", "arm64"), + ..request + }) + .await + .expect("image should resolve for another platform"); + assert_ne!(first.manifest_digest, different_source.manifest_digest); + assert_ne!(first.manifest_digest, different_platform.manifest_digest); +} diff --git a/sandbox/sandbox/tests/name.rs b/sandbox/sandbox/tests/name.rs new file mode 100644 index 0000000..434bd5a --- /dev/null +++ b/sandbox/sandbox/tests/name.rs @@ -0,0 +1,70 @@ +#![allow(clippy::expect_used)] + +use sandbox::{Hostname, InvalidSandboxName, MAX_SANDBOX_NAME_BYTES, SandboxName}; + +#[test] +fn accepts_portable_dns_labels() { + for value in ["a", "0", "worker-1", "a1-b2"] { + let name = SandboxName::new(value).expect("portable name should be accepted"); + assert_eq!(name.as_str(), value); + } + + let maximum = "a".repeat(MAX_SANDBOX_NAME_BYTES); + assert_eq!( + SandboxName::new(&maximum) + .expect("maximum-length name should be accepted") + .as_str(), + maximum + ); +} + +#[test] +fn rejects_names_outside_the_portable_subset() { + assert_eq!(SandboxName::new(""), Err(InvalidSandboxName::Empty)); + assert_eq!( + SandboxName::new("a".repeat(MAX_SANDBOX_NAME_BYTES + 1)), + Err(InvalidSandboxName::TooLong { + length: MAX_SANDBOX_NAME_BYTES + 1 + }) + ); + + for value in ["Worker", "worker_name", "worker.name", "-worker", "worker-", "wørker"] { + assert_eq!( + SandboxName::new(value), + Err(InvalidSandboxName::InvalidSyntax), + "{value:?} should be rejected" + ); + } +} + +#[test] +fn deserialization_cannot_bypass_validation() { + let name: SandboxName = serde_json::from_str(r#""worker-1""#).expect("valid name should deserialize"); + assert_eq!(name.as_str(), "worker-1"); + assert!(serde_json::from_str::(r#""Worker_1""#).is_err()); + assert_eq!( + serde_json::to_string(&name).expect("name should serialize"), + r#""worker-1""# + ); +} + +#[test] +fn hostnames_share_the_portable_label_rules() { + let hostname = Hostname::new("agent-test").expect("portable hostname should be accepted"); + assert_eq!(hostname.as_str(), "agent-test"); + assert_eq!(hostname.to_string(), "agent-test"); + assert_eq!( + Hostname::from(SandboxName::new("worker").expect("valid name")).as_str(), + "worker" + ); + + for value in ["", "Worker", "worker_name", "worker.example.com"] { + let error = Hostname::new(value).expect_err("non-label hostname should be rejected"); + assert!( + error.to_string().starts_with("hostname is not a portable DNS label: "), + "{value:?}: {error}" + ); + } + assert!(Hostname::new("a".repeat(MAX_SANDBOX_NAME_BYTES + 1)).is_err()); + assert!(serde_json::from_str::(r#""Worker""#).is_err()); +} diff --git a/sandbox/sandbox/tests/network.rs b/sandbox/sandbox/tests/network.rs new file mode 100644 index 0000000..0cefdd1 --- /dev/null +++ b/sandbox/sandbox/tests/network.rs @@ -0,0 +1,269 @@ +#![allow(clippy::expect_used)] + +use std::{ + future::poll_fn, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + num::{NonZeroU32, NonZeroUsize}, +}; + +use bytes::Bytes; +use sandbox::{ + memory, + network::{ + InterfaceAddress, MacAddress, NetworkControlMessage, NetworkEndpoint, NetworkEndpointError, + NetworkInterfaceConfiguration, NetworkPacket, NetworkPacketBatch, NetworkTransferProgress, PacketEndpoint, + PacketEndpointProperties, PacketMedium, + }, +}; + +const fn nonzero(value: usize) -> NonZeroUsize { + NonZeroUsize::new(value).expect("test value should be nonzero") +} + +fn properties() -> PacketEndpointProperties { + PacketEndpointProperties::new( + PacketMedium::Ethernet, + interface_configuration(), + NonZeroU32::new(1_514).expect("test frame length should be nonzero"), + ) +} + +fn interface_configuration() -> NetworkInterfaceConfiguration { + NetworkInterfaceConfiguration::new( + MacAddress::new([0x02, 0, 0, 0, 0, 2]), + NonZeroU32::new(1_500).expect("test MTU should be nonzero"), + vec![ + InterfaceAddress::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), 24).expect("valid test IPv4 prefix"), + InterfaceAddress::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 128).expect("valid test IPv6 prefix"), + ], + vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))], + vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 53))], + ) +} + +fn packet(payload: &'static [u8]) -> NetworkPacket { + let mut bytes = vec![0; 14]; + bytes.extend_from_slice(payload); + NetworkPacket::new(Bytes::from(bytes)) +} + +#[tokio::test(flavor = "local")] +async fn packet_endpoint_moves_bounded_batches_in_both_directions() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(4), properties()); + assert_eq!(endpoint.properties(), &properties()); + let mut parts = endpoint.into_parts(); + + peer.emit_from_sandbox(packet(b"sandbox-one")) + .await + .expect("emit first Sandbox packet"); + peer.emit_from_sandbox(packet(b"sandbox-two")) + .await + .expect("emit second Sandbox packet"); + peer.emit_from_sandbox(packet(b"sandbox-three")) + .await + .expect("emit third Sandbox packet"); + + let mut received = NetworkPacketBatch::new(nonzero(2)); + let progress = poll_fn(|context| parts.from_sandbox.as_mut().poll_receive(context, &mut received)) + .await + .expect("receive Sandbox packets"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(2))); + assert_eq!(received.pop_front(), Some(packet(b"sandbox-one"))); + assert_eq!(received.pop_front(), Some(packet(b"sandbox-two"))); + let progress = poll_fn(|context| parts.from_sandbox.as_mut().poll_receive(context, &mut received)) + .await + .expect("receive remaining Sandbox packet"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(1))); + assert_eq!(received.pop_front(), Some(packet(b"sandbox-three"))); + + let mut pending = NetworkPacketBatch::new(nonzero(4)); + pending.push_back(packet(b"network-one")).expect("queue first packet"); + pending.push_back(packet(b"network-two")).expect("queue second packet"); + let progress = poll_fn(|context| parts.to_sandbox.as_mut().poll_send(context, &mut pending)) + .await + .expect("send packets to Sandbox"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(2))); + assert!(pending.is_empty()); + poll_fn(|context| parts.to_sandbox.as_mut().poll_flush(context)) + .await + .expect("flush packets to Sandbox"); + assert_eq!(peer.receive_for_sandbox().await, Some(packet(b"network-one"))); + assert_eq!(peer.receive_for_sandbox().await, Some(packet(b"network-two"))); +} + +#[tokio::test(flavor = "local")] +async fn receive_wakes_when_the_sandbox_emits_a_packet() { + let (endpoint, peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + let mut receiver = endpoint.into_parts().from_sandbox; + let mut batch = NetworkPacketBatch::new(nonzero(1)); + + let receive = poll_fn(|context| receiver.as_mut().poll_receive(context, &mut batch)); + let emit = peer.emit_from_sandbox(packet(b"wake")); + let (progress, emitted) = tokio::join!(receive, emit); + + emitted.expect("emit packet after receiver registered its waker"); + assert_eq!( + progress.expect("receiver should wake after a packet arrives"), + NetworkTransferProgress::Items(nonzero(1)) + ); + assert_eq!(batch.pop_front(), Some(packet(b"wake"))); +} + +#[tokio::test(flavor = "local")] +async fn send_reports_partial_progress_and_wakes_after_backpressure() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + let mut sender = endpoint.into_parts().to_sandbox; + let mut pending = NetworkPacketBatch::new(nonzero(2)); + pending.push_back(packet(b"first")).expect("queue first packet"); + pending.push_back(packet(b"second")).expect("queue second packet"); + + let first_progress = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect("send until bounded channel becomes full"); + assert_eq!(first_progress, NetworkTransferProgress::Items(nonzero(1))); + assert_eq!(pending.len(), 1); + + let send_after_capacity = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)); + let receive_first = peer.receive_for_sandbox(); + let (second_progress, first) = tokio::join!(send_after_capacity, receive_first); + + assert_eq!(first, Some(packet(b"first"))); + assert_eq!( + second_progress.expect("sender should wake after capacity becomes available"), + NetworkTransferProgress::Items(nonzero(1)) + ); + assert!(pending.is_empty()); + assert_eq!(peer.receive_for_sandbox().await, Some(packet(b"second"))); +} + +#[tokio::test(flavor = "local")] +async fn receive_drains_queued_packets_before_reporting_closure() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(2), properties()); + peer.emit_from_sandbox(packet(b"last")) + .await + .expect("emit final packet"); + peer.close_from_sandbox(); + let mut receiver = endpoint.into_parts().from_sandbox; + let mut batch = NetworkPacketBatch::new(nonzero(2)); + + let progress = poll_fn(|context| receiver.as_mut().poll_receive(context, &mut batch)) + .await + .expect("drain final packet"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(1))); + assert_eq!(batch.pop_front(), Some(packet(b"last"))); + + let progress = poll_fn(|context| receiver.as_mut().poll_receive(context, &mut batch)) + .await + .expect("observe closure after draining"); + assert_eq!(progress, NetworkTransferProgress::Closed); +} + +#[tokio::test(flavor = "local")] +async fn closed_destination_retains_unaccepted_packets() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + peer.close_to_sandbox(); + let mut sender = endpoint.into_parts().to_sandbox; + let mut pending = NetworkPacketBatch::new(nonzero(1)); + pending.push_back(packet(b"retained")).expect("queue packet"); + + let progress = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect("closed destination is a normal endpoint state"); + + assert_eq!(progress, NetworkTransferProgress::Closed); + assert_eq!(pending.pop_front(), Some(packet(b"retained"))); +} + +#[tokio::test(flavor = "local")] +async fn endpoint_enforces_its_maximum_packet_length() { + let short_properties = PacketEndpointProperties::new( + PacketMedium::Ethernet, + interface_configuration(), + NonZeroU32::new(14).expect("test frame length should be nonzero"), + ); + let (endpoint, peer) = memory::packet_endpoint_pair(nonzero(1), short_properties); + let oversized = packet(b"x"); + + let error = peer + .emit_from_sandbox(oversized.clone()) + .await + .expect_err("Sandbox packet should respect the endpoint maximum"); + assert!(matches!( + error, + NetworkEndpointError::PacketTooLarge { + actual: 15, + maximum: 14 + } + )); + + let mut sender = endpoint.into_parts().to_sandbox; + let mut pending = NetworkPacketBatch::new(nonzero(1)); + pending.push_back(oversized.clone()).expect("queue oversized packet"); + let error = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect_err("Network Backend packet should respect the endpoint maximum"); + assert!(matches!(error, NetworkEndpointError::PacketTooLarge { .. })); + assert_eq!(pending.pop_front(), Some(oversized)); +} + +#[tokio::test(flavor = "local")] +async fn sender_shutdown_is_idempotent_and_prevents_new_packets() { + let (endpoint, _peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + let mut sender = endpoint.into_parts().to_sandbox; + + poll_fn(|context| sender.as_mut().poll_shutdown(context)) + .await + .expect("first shutdown"); + poll_fn(|context| sender.as_mut().poll_shutdown(context)) + .await + .expect("idempotent shutdown"); + + let mut pending = NetworkPacketBatch::new(nonzero(1)); + let unsent = packet(b"after-shutdown"); + pending.push_back(unsent.clone()).expect("queue packet after shutdown"); + let progress = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect("closed sender should report its state"); + assert_eq!(progress, NetworkTransferProgress::Closed); + assert_eq!(pending.pop_front(), Some(unsent)); +} + +#[test] +fn live_endpoint_can_move_to_a_dedicated_thread() { + fn assert_send() {} + + assert_send::(); + assert_send::(); +} + +#[test] +fn packet_properties_expose_complete_immutable_interface_configuration() { + let properties = properties(); + let interface = properties.interface(); + + assert_eq!(properties.medium(), PacketMedium::Ethernet); + assert_eq!(properties.maximum_frame_length().get(), 1_514); + assert_eq!(interface.mac_address().to_string(), "02:00:00:00:00:02"); + assert_eq!(interface.mtu().get(), 1_500); + assert_eq!(interface.addresses().len(), 2); + assert_eq!(interface.default_gateways(), [IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))]); + assert_eq!(interface.dns_servers(), [IpAddr::V4(Ipv4Addr::new(192, 0, 2, 53))]); +} + +#[test] +fn interface_address_deserialization_rejects_invalid_prefixes() { + let error = serde_json::from_str::(r#"{"address":"192.0.2.2","prefixLength":33}"#) + .expect_err("IPv4 prefix above 32 should be rejected"); + + assert!(error.to_string().contains("prefix length 33 is invalid")); +} + +#[test] +fn opaque_control_message_debug_output_does_not_expose_payload() { + let message = NetworkControlMessage::new(b"must-not-appear"); + + let debug = format!("{message:?}"); + + assert!(!debug.contains("must-not-appear")); + assert!(debug.contains("15")); +} diff --git a/sandbox/sandbox/tests/platform.rs b/sandbox/sandbox/tests/platform.rs new file mode 100644 index 0000000..886e928 --- /dev/null +++ b/sandbox/sandbox/tests/platform.rs @@ -0,0 +1,28 @@ +use std::collections::BTreeSet; + +use sandbox::Platform; + +#[test] +fn resolved_platform_may_refine_unspecified_oci_constraints() { + let requested = Platform::new("windows", "amd64"); + let mut resolved = requested.clone(); + resolved.os_version = Some("10.0.26100.0".into()); + resolved.os_features = BTreeSet::from(["win32k".into()]); + + assert!(resolved.satisfies(&requested)); + assert!(!requested.satisfies(&resolved)); +} + +#[test] +fn resolved_platform_must_preserve_requested_constraints() { + let mut requested = Platform::new("linux", "arm64"); + requested.variant = Some("v8".into()); + requested.os_features = BTreeSet::from(["feature-a".into()]); + let mut wrong_variant = requested.clone(); + wrong_variant.variant = Some("v9".into()); + let mut missing_feature = requested.clone(); + missing_feature.os_features.clear(); + + assert!(!wrong_variant.satisfies(&requested)); + assert!(!missing_feature.satisfies(&requested)); +} diff --git a/sandbox/sandbox/tests/secret_store.rs b/sandbox/sandbox/tests/secret_store.rs new file mode 100644 index 0000000..c81a436 --- /dev/null +++ b/sandbox/sandbox/tests/secret_store.rs @@ -0,0 +1,54 @@ +#![allow(clippy::expect_used)] + +use sandbox::{ + Error, + memory::MemorySecretStore, + secret_store::{SecretMaterial, SecretStore as _}, +}; + +#[tokio::test(flavor = "local")] +async fn rotating_a_secret_preserves_its_reference() { + let store = MemorySecretStore::default(); + let reference = store.set("provider-token", b"first").await.expect("store secret"); + + assert_eq!( + store + .resolve(&reference) + .await + .expect("resolve initial secret") + .expose(), + b"first" + ); + + let rotated = store.set("provider-token", b"second").await.expect("rotate secret"); + + assert_eq!(rotated, reference); + assert_eq!( + store + .resolve(&reference) + .await + .expect("resolve rotated secret") + .expose(), + b"second" + ); +} + +#[tokio::test(flavor = "local")] +async fn resolving_an_unknown_reference_fails_closed() { + let store = MemorySecretStore::default(); + let reference = store.set("known", b"value").await.expect("store secret"); + let unknown = sandbox::secret_store::SecretReference::from_opaque("unknown"); + + assert!(matches!(store.resolve(&unknown).await, Err(Error::NotFound { .. }))); + assert_ne!(reference, unknown); +} + +#[test] +fn secret_material_debug_output_is_redacted() { + let material = SecretMaterial::new(b"must-not-appear".to_vec()); + + let debug = format!("{material:?}"); + + assert_eq!(debug, "SecretMaterial([REDACTED])"); + assert!(!debug.contains("must-not-appear")); +} diff --git a/sandbox/sandbox/tests/service.rs b/sandbox/sandbox/tests/service.rs new file mode 100644 index 0000000..3f27221 --- /dev/null +++ b/sandbox/sandbox/tests/service.rs @@ -0,0 +1,925 @@ +#![allow(clippy::expect_used)] + +use std::{future::poll_fn, io::Cursor, path::PathBuf, pin::Pin, rc::Rc}; + +use bytes::Bytes; +use futures_core::Stream as _; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Error, Hostname, OperationEvent, PendingOperation, Platform, + ProgressEvent, RetentionPolicy, RootFilesystem, RootFilesystemMode, SandboxFeature, SandboxName, SandboxPath, + SandboxPhase, SandboxResources, SandboxService, SandboxSpec, + execution::{ExecutionEvent, ExecutionSpec, ExitStatus, StartExecutionRequest}, + image::{self, ImageSource}, + memory, + network::{NetworkAttachment, NetworkBackend as _, NetworkBackendId, NetworkEndpointSelection, PacketMedium}, + terminal::{StartTerminalExecutionRequest, TerminalEvent, TerminalSize}, + volume::{EnsureVolumeRequest, VolumeName}, +}; +use tokio::io::AsyncReadExt as _; + +fn spec() -> SandboxSpec { + SandboxSpec { + image: ImageSource::Build { + context: PathBuf::from("."), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: Platform::native("linux"), + resources: resources("2", "1Gi", "4Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Retain, + } +} + +fn request() -> EnsureSandboxRequest { + EnsureSandboxRequest::new(sandbox_name(), spec()) +} + +fn sandbox_name() -> SandboxName { + SandboxName::new("worker").expect("test Sandbox name should be valid") +} + +fn resources(cpu: &str, memory: &str, root_filesystem: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + memory.parse::().expect("test memory should be valid"), + RootFilesystem::layered( + root_filesystem + .parse::() + .expect("test root filesystem should be valid"), + ), + ) +} + +#[tokio::test(flavor = "local")] +async fn ensure_defaults_the_hostname_to_the_sandbox_name() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let request = request(); + assert_eq!(request.hostname().as_str(), "worker"); + + let sandbox = service.ensure(&request).await.expect("ensure"); + assert_eq!(sandbox.snapshot().hostname, Hostname::from(sandbox_name())); +} + +#[tokio::test(flavor = "local")] +async fn ensure_creates_the_sandbox_with_an_explicit_hostname() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let hostname = Hostname::new("agent-test").expect("test hostname should be valid"); + let request = request().with_hostname(hostname.clone()); + assert_eq!(request.hostname(), hostname); + + let sandbox = service.ensure(&request).await.expect("ensure"); + assert_eq!(sandbox.name(), &sandbox_name()); + assert_eq!(sandbox.snapshot().hostname, hostname); + assert_eq!( + service.inspect(request.name()).await.expect("inspect").hostname, + hostname + ); +} + +#[tokio::test(flavor = "local")] +async fn ensure_is_idempotent_and_runs_one_sandbox() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let request = request(); + + let first = service.ensure(&request).await.expect("first ensure"); + let second = service.ensure(&request).await.expect("second ensure"); + + assert_eq!(first.snapshot(), second.snapshot()); + assert!(first.snapshot().network.is_none()); + assert_eq!(backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn component_errors_preserve_stable_kind_and_resource_identity() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let name = SandboxName::new("missing").expect("test Sandbox name should be valid"); + + let error = service.inspect(&name).await.expect_err("Sandbox should be absent"); + + assert_eq!(error.kind(), sandbox::ErrorKind::NotFound); + assert_eq!( + error.not_found_target(), + Some((sandbox::ResourceKind::Sandbox, "missing")) + ); +} + +#[tokio::test(flavor = "local")] +async fn capabilities_describe_the_configured_consumer_surface() { + let platform = Platform::native("linux"); + let without_network = SandboxService::new(Rc::new(memory::Provider::new())) + .capabilities(&platform) + .await + .expect("Provider capabilities should be available"); + assert!(without_network.features().contains(SandboxFeature::Execution)); + assert!(without_network.prepared_image_export().sources.iter().next().is_none()); + assert!(without_network.prepared_image_import().sources.iter().next().is_none()); + assert!(!without_network.network_available()); + + let with_network = SandboxService::new(Rc::new(memory::Provider::new())).with_network_backend(Rc::new( + memory::NetworkBackend::for_endpoint("network-a", NetworkEndpointSelection::Packet(PacketMedium::Ethernet)), + )); + assert!( + with_network + .capabilities(&platform) + .await + .expect("compatible Network should be discoverable") + .network_available() + ); +} + +#[tokio::test(flavor = "local")] +async fn memory_provider_rejects_prepared_image_transport_with_typed_errors() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let spec = spec(); + let request = image::ResolveRequest { + source: spec.image, + platform: spec.platform, + root_filesystem_mode: spec.resources.root_filesystem().mode(), + }; + + let export_error = service + .export_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect_err("memory Image Backend should not export prepared images"); + assert!(matches!( + export_error, + Error::UnsupportedImageOperation(image::ImageOperation::PreparedImageExport) + )); + + let import_error = service + .import_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect_err("memory Image Backend should not import prepared images"); + assert!(matches!( + import_error, + Error::UnsupportedImageOperation(image::ImageOperation::PreparedImageImport) + )); +} + +struct PreparedImageBackend { + prepared: image::PreparedImage, +} + +impl image::ImageBackend for PreparedImageBackend { + fn capabilities<'a>( + &'a self, + _platform: &'a Platform, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async { + let operation = image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build].into(), + [RootFilesystemMode::Layered].into(), + ); + Ok(image::ImageBackendCapabilities::new( + operation.clone(), + operation.clone(), + operation, + )) + }) + } + + fn resolve<'a>(&'a self, _request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + let image = self.prepared.image.clone(); + PendingOperation::run(move |_progress| Box::pin(async move { Ok(image) })) + } + + fn export_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + completed_prepared_image(self.prepared.clone()) + } + + fn import_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + completed_prepared_image(self.prepared.clone()) + } +} + +fn completed_prepared_image(prepared: image::PreparedImage) -> PendingOperation<'static, image::PreparedImage> { + PendingOperation::run(move |_progress| Box::pin(async move { Ok(prepared) })) +} + +struct PreparedImageProvider { + sandbox_backend: memory::Provider, + image_backend: PreparedImageBackend, +} + +impl sandbox::provider::SandboxProvider for PreparedImageProvider { + fn backend(&self) -> &dyn sandbox::backend::SandboxBackend { + &self.sandbox_backend + } + + fn image_backend(&self) -> &dyn image::ImageBackend { + &self.image_backend + } +} + +#[tokio::test(flavor = "local")] +async fn service_accepts_prepared_image_metadata_coherent_with_the_request() { + let spec = spec(); + let request = image::ResolveRequest { + source: spec.image, + platform: spec.platform, + root_filesystem_mode: spec.resources.root_filesystem().mode(), + }; + let prepared = image::PreparedImage { + image: image::ResolvedImage { + source: request.source.clone(), + platform: request.platform.clone(), + manifest_digest: "sha256:resolved-oci-manifest".into(), + }, + root_filesystem_mode: request.root_filesystem_mode, + artifact_digest: "sha256:opaque-artifact".into(), + virtual_size_bytes: 4096, + }; + let service = SandboxService::new(Rc::new(PreparedImageProvider { + sandbox_backend: memory::Provider::new(), + image_backend: PreparedImageBackend { + prepared: prepared.clone(), + }, + })); + + let exported = service + .export_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect("coherent exported metadata should be accepted"); + let imported = service + .import_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect("coherent imported metadata should be accepted"); + + assert_eq!(exported, prepared); + assert_eq!(imported, prepared); +} + +#[tokio::test(flavor = "local")] +async fn ensure_stream_yields_progress_then_exactly_one_ready_sandbox() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let request = request(); + let mut pending = service.ensure(&request); + let mut events = Vec::new(); + + loop { + let event = poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .expect("provisioning should produce a terminal event") + .expect("provisioning should succeed"); + let ready = matches!(event, OperationEvent::Ready(_)); + events.push(event); + if ready { + break; + } + } + + assert!(matches!( + events.first(), + Some(OperationEvent::Progress(ProgressEvent::PhaseStarted { phase })) + if *phase == SandboxPhase::Validate.phase() + )); + assert!(events.iter().any(|event| { + matches!( + event, + OperationEvent::Progress(ProgressEvent::PhaseStarted { phase }) + if *phase == SandboxPhase::ImageResolve.phase() + ) + })); + let started = events + .iter() + .filter(|event| matches!(event, OperationEvent::Progress(ProgressEvent::PhaseStarted { .. }))) + .count(); + let ended = events + .iter() + .filter(|event| matches!(event, OperationEvent::Progress(ProgressEvent::PhaseEnded { .. }))) + .count(); + assert_eq!(started, ended, "every started phase ends"); + assert!(matches!(events.last(), Some(OperationEvent::Ready(_)))); + assert!( + poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .is_none() + ); +} + +#[tokio::test(flavor = "local")] +async fn ensure_stream_yields_exactly_one_terminal_error() { + let backend = Rc::new(memory::Provider::with_platforms(Platform::new("linux", "amd64"), [])); + let service = SandboxService::new(backend); + let mut request = request(); + request.spec_mut().platform = Platform::new("windows", "amd64"); + let mut pending = service.ensure(&request); + + let error = loop { + let event = poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .expect("provisioning should produce a terminal event"); + if let Err(error) = event { + break error; + } + }; + + assert!(matches!( + error, + Error::Component { source, .. } if matches!(*source, Error::UnsupportedPlatform(_)) + )); + assert!( + poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .is_none() + ); +} + +#[tokio::test(flavor = "local")] +async fn independent_network_backend_follows_the_sandbox_lifecycle() { + let sandbox_backend = Rc::new(memory::Provider::new()); + let network_backend = Rc::new(memory::NetworkBackend::for_endpoint( + "network-a", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + )); + let service = SandboxService::new(sandbox_backend.clone()).with_network_backend(network_backend.clone()); + let request = request(); + service + .stop(request.name()) + .await + .expect("stopping a missing Sandbox has no effect"); + + let sandbox = service.ensure(&request).await.expect("create attached Sandbox"); + assert_eq!( + sandbox.snapshot().network, + Some(NetworkAttachment { + backend: NetworkBackendId::new("network-a"), + endpoint: NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + }) + ); + assert!(network_backend.is_attached(sandbox.id())); + assert!(network_backend.is_running(sandbox.id())); + + service.stop(request.name()).await.expect("stop attached Sandbox"); + service.stop(request.name()).await.expect("repeated stop"); + let stopped = service.inspect(request.name()).await.expect("inspect stopped Sandbox"); + assert_eq!(stopped.state, sandbox::SandboxState::Stopped); + assert!(network_backend.is_attached(sandbox.id())); + assert!(!network_backend.is_running(sandbox.id())); + + let adopted = service.ensure(&request).await.expect("reconnect retained Network"); + assert_eq!(adopted.id(), sandbox.id()); + assert!(network_backend.is_running(sandbox.id())); + + let other_network = Rc::new(memory::NetworkBackend::for_endpoint( + "network-b", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + )); + let incompatible_service = SandboxService::new(sandbox_backend).with_network_backend(other_network); + let error = incompatible_service + .ensure(&request) + .await + .expect_err("Network Backend attachment should be immutable"); + assert!(matches!(error, Error::Immutable("network"))); + + service.delete(request.name()).await.expect("delete attached Sandbox"); + assert!(!network_backend.is_attached(sandbox.id())); + assert!(!network_backend.is_running(sandbox.id())); +} + +#[tokio::test(flavor = "local")] +async fn endpoint_negotiation_rejects_an_incompatible_network_backend() { + let sandbox_backend = Rc::new(memory::Provider::new()); + let network_backend = Rc::new(memory::NetworkBackend::for_endpoint( + "intercepted", + NetworkEndpointSelection::Intercepted, + )); + let service = SandboxService::new(sandbox_backend).with_network_backend(network_backend); + + let error = service + .ensure(&request()) + .await + .expect_err("memory Sandbox Backend offers only packet endpoints"); + + assert!(matches!(error, Error::NetworkEndpointUnavailable(_))); +} + +#[tokio::test(flavor = "local")] +async fn endpoint_negotiation_is_not_coupled_to_ethernet() { + let sandbox_backend = Rc::new(memory::Provider::new()); + let network_backend = Rc::new(memory::NetworkBackend::for_endpoint( + "ip-network", + NetworkEndpointSelection::Packet(PacketMedium::Ip), + )); + let service = SandboxService::new(sandbox_backend).with_network_backend(network_backend); + + let sandbox = service + .ensure(&request()) + .await + .expect("IP endpoint should be compatible"); + + assert_eq!( + sandbox.snapshot().network, + Some(NetworkAttachment { + backend: NetworkBackendId::new("ip-network"), + endpoint: NetworkEndpointSelection::Packet(PacketMedium::Ip), + }) + ); +} + +#[tokio::test(flavor = "local")] +async fn image_is_immutable_after_materialization() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let mut request = request(); + let _ = service.ensure(&request).await.expect("first ensure"); + request.spec_mut().image = ImageSource::Reference { + reference: "example.test/other:latest".to_string(), + }; + + let error = service.ensure(&request).await.expect_err("image change should fail"); + assert!(matches!(error, Error::Immutable("image"))); +} + +#[tokio::test(flavor = "local")] +async fn ensure_reconciles_mutable_resources() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let mut request = request(); + let first = service.ensure(&request).await.expect("first ensure"); + request.spec_mut().resources = resources("4", "2Gi", "8Gi"); + + let updated = service.ensure(&request).await.expect("resource update"); + + assert_eq!(updated.id(), first.id()); + assert_eq!(updated.snapshot().resources, request.spec().resources); +} + +#[tokio::test(flavor = "local")] +async fn ensure_reconciles_environment_by_restarting_the_sandbox_and_network() { + let backend = Rc::new(memory::Provider::new()); + let network = Rc::new(memory::NetworkBackend::for_endpoint( + "network-a", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + )); + let service = SandboxService::new(backend).with_network_backend(network.clone()); + let first_request = request().with_environment([("API_TOKEN".into(), "placeholder-one".into())]); + let first = service.ensure(&first_request).await.expect("first ensure"); + + let second_request = request().with_environment([("API_TOKEN".into(), "placeholder-two".into())]); + let updated = service.ensure(&second_request).await.expect("environment update"); + + assert_eq!(updated.id(), first.id()); + assert_eq!(updated.snapshot().state, sandbox::SandboxState::Running); + assert_eq!(updated.snapshot().environment, second_request.environment().clone()); + assert!(network.is_running(updated.id())); +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_invalid_environment_before_materialization() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let request = request().with_environment([("NOT-AN-ENV".into(), "placeholder".into())]); + + let error = service + .ensure(&request) + .await + .expect_err("invalid environment should fail at the SDK boundary"); + + assert!(matches!( + error, + Error::Invalid { + field: "environment", + .. + } + )); + assert_eq!(backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn root_filesystem_mode_is_immutable() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let mut request = request(); + let _ = service.ensure(&request).await.expect("first ensure"); + request.spec_mut().resources = SandboxResources::new( + request.spec().resources.cpu(), + request.spec().resources.memory(), + sandbox::RootFilesystem::direct(request.spec().resources.root_filesystem().capacity()), + ); + + let error = service + .ensure(&request) + .await + .expect_err("root filesystem mode change should fail"); + + assert!(matches!(error, Error::Immutable("resources.rootFilesystem.mode"))); +} + +#[tokio::test(flavor = "local")] +async fn release_retains_by_default_and_delete_is_idempotent() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let request = request(); + let first = service.ensure(&request).await.expect("ensure"); + + service + .release(request.name(), RetentionPolicy::Retain) + .await + .expect("retain"); + let adopted = service.ensure(&request).await.expect("re-adopt"); + assert_eq!(first.id(), adopted.id()); + + service.delete(request.name()).await.expect("delete"); + service.delete(request.name()).await.expect("idempotent delete"); + assert_eq!(backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn recreating_a_name_assigns_a_new_backend_neutral_id() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let request = request(); + let first = service.ensure(&request).await.expect("first materialization"); + + service + .delete(request.name()) + .await + .expect("delete first materialization"); + let second = service.ensure(&request).await.expect("second materialization"); + + assert_ne!(first.id(), second.id()); + assert_eq!(first.name(), second.name()); + assert_eq!(first.id().as_uuid().get_version_num(), 4); + assert_eq!(second.id().as_uuid().get_version_num(), 4); +} + +#[tokio::test(flavor = "local")] +async fn a_stale_handle_cannot_delete_a_new_materialization_with_the_same_name() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let request = request(); + let stale = service.ensure(&request).await.expect("first materialization"); + + service + .delete(request.name()) + .await + .expect("delete first materialization"); + let current = service.ensure(&request).await.expect("second materialization"); + stale.delete().await.expect("stale deletion should be idempotent"); + + assert_eq!( + service + .inspect(request.name()) + .await + .expect("current Sandbox should remain") + .id, + current.id().clone() + ); +} + +#[tokio::test(flavor = "local")] +async fn service_and_handle_expose_execution_and_volume_operations() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let sandbox = service.ensure(&request()).await.expect("ensure"); + + let volume_name = VolumeName::new("home").expect("valid Volume name"); + let volume_request = EnsureVolumeRequest::new(volume_name.clone()); + let expected_volume_id = volume_request.id().clone(); + let volume = service.ensure_volume(volume_request).await.expect("create Volume"); + assert_eq!(volume.id, expected_volume_id); + assert_eq!(volume.id.as_uuid().get_version_num(), 4); + assert_eq!(service.find_volume(&volume_name).await.expect("find Volume"), volume); + + backend.queue_execution_events(vec![ + ExecutionEvent::Started { process_id: Some(42) }, + ExecutionEvent::Stdout(Bytes::from_static(b"output")), + ExecutionEvent::Stderr(Bytes::from_static(b"warning")), + ExecutionEvent::Exited(ExitStatus { code: 7 }), + ]); + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/example"), + ["--check".into()], + )) + .await + .expect("run Execution"); + + assert_eq!(output.status.code, 7); + assert!(!output.status.success()); + assert_eq!(output.stdout, Bytes::from_static(b"output")); + assert_eq!(output.stderr, Bytes::from_static(b"warning")); + assert_eq!(backend.execution_specs().len(), 1); + assert_eq!( + backend.execution_specs()[0].program(), + &sandbox::execution::Program::Command { + executable: SandboxPath::new("/usr/bin/example"), + args: vec!["--check".into()], + } + ); + + let execution = sandbox + .start_execution(StartExecutionRequest::new(ExecutionSpec::image_entrypoint())) + .await + .expect("start addressable Execution"); + assert_eq!(execution.id.as_uuid().get_version_num(), 4); + sandbox + .terminate_execution(&execution.id) + .await + .expect("terminate Execution"); + + backend.queue_terminal_events(vec![ + TerminalEvent::Started { process_id: Some(43) }, + TerminalEvent::Output(Bytes::from_static(b"terminal output")), + TerminalEvent::Exited(ExitStatus { code: 0 }), + ]); + let mut terminal = sandbox + .start_terminal_execution(StartTerminalExecutionRequest::new( + ExecutionSpec::image_entrypoint(), + TerminalSize::new(40, 120).expect("valid terminal size"), + )) + .await + .expect("start terminal Execution"); + assert_eq!(terminal.id.as_uuid().get_version_num(), 4); + terminal + .control + .write_input(Bytes::from_static(b"input")) + .await + .expect("write terminal input"); + terminal + .control + .resize(TerminalSize::new(50, 140).expect("valid terminal size")) + .await + .expect("resize terminal"); + assert!(matches!( + poll_fn(|context| terminal.events.as_mut().poll_next(context)).await, + Some(Ok(TerminalEvent::Started { process_id: Some(43) })) + )); +} + +#[tokio::test(flavor = "local")] +async fn memory_provider_matches_execution_responses_without_fifo_coupling() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let sandbox = service.ensure(&request()).await.expect("ensure"); + backend.queue_execution_events_matching( + |spec| { + matches!( + spec.program(), + sandbox::execution::Program::Command { executable, .. } + if executable.as_str() == "/usr/bin/matched" + ) + }, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code: 23 }), + ], + ); + + let unrelated = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/unrelated"), + Vec::::new(), + )) + .await + .expect("unrelated Execution"); + let matched = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/matched"), + Vec::::new(), + )) + .await + .expect("matched Execution"); + + assert!(unrelated.status.success()); + assert_eq!(matched.status.code, 23); + assert_eq!(backend.execution_specs().len(), 2); +} + +#[tokio::test(flavor = "local")] +async fn sandbox_backend_streams_files_in_both_directions() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let sandbox = service.ensure(&request()).await.expect("ensure"); + let path = SandboxPath::new("/home/sandbox/code/input.bin"); + + sandbox + .write_file(&path, Box::pin(Cursor::new(vec![0, 1, 2, 0xff]))) + .await + .expect("write Sandbox file"); + let mut reader = sandbox.read_file(&path).await.expect("read Sandbox file"); + let mut contents = Vec::new(); + reader + .read_to_end(&mut contents) + .await + .expect("consume Sandbox file stream"); + + assert_eq!(contents, vec![0, 1, 2, 0xff]); + assert!( + service + .capabilities(&sandbox.snapshot().image.platform) + .await + .expect("Platform capabilities") + .features() + .contains(SandboxFeature::FileTransfer) + ); +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_an_unsupported_platform() { + let backend = Rc::new(memory::Provider::with_platforms(Platform::new("linux", "amd64"), [])); + let service = SandboxService::new(backend.clone()); + let mut request = request(); + request.spec_mut().platform = Platform::new("windows", "amd64"); + + let error = service + .ensure(&request) + .await + .expect_err("Platform should be unsupported"); + + assert!(matches!( + error, + Error::Component { source, .. } if matches!(*source, Error::UnsupportedPlatform(_)) + )); + assert_eq!(backend.count(), 0); +} + +struct IncompatibleImageBackend; + +impl image::ImageBackend for IncompatibleImageBackend { + fn capabilities<'a>( + &'a self, + _platform: &'a Platform, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async { + Ok(image::ImageBackendCapabilities::new( + image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build, image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Layered].into(), + ), + image::ImageOperationCapabilities::default(), + image::ImageOperationCapabilities::default(), + )) + }) + } + + fn resolve<'a>(&'a self, request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + Ok(image::ResolvedImage { + source: request.source.clone(), + platform: Platform::new("windows", "amd64"), + manifest_digest: "sha256:incompatible".into(), + }) + }) + }) + } + + fn export_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageExport) + } + + fn import_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageImport) + } +} + +fn unsupported_prepared_image<'a>(operation: image::ImageOperation) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |_progress| Box::pin(async move { Err(Error::UnsupportedImageOperation(operation)) })) +} + +struct IncompatibleProvider { + backend: memory::Provider, + image_backend: IncompatibleImageBackend, +} + +impl sandbox::provider::SandboxProvider for IncompatibleProvider { + fn backend(&self) -> &dyn sandbox::backend::SandboxBackend { + &self.backend + } + + fn image_backend(&self) -> &dyn image::ImageBackend { + &self.image_backend + } +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_an_image_that_does_not_satisfy_the_requested_platform() { + let provider = Rc::new(IncompatibleProvider { + backend: memory::Provider::with_platforms(Platform::native("linux"), [Platform::new("windows", "amd64")]), + image_backend: IncompatibleImageBackend, + }); + let service = SandboxService::new(provider.clone()); + + let error = service + .ensure(&request()) + .await + .expect_err("Image Platform should be incompatible"); + + assert!(matches!(error, Error::ImagePlatformMismatch { .. })); + assert_eq!(provider.backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_a_root_mode_the_image_backend_cannot_materialize() { + let provider = Rc::new(IncompatibleProvider { + backend: memory::Provider::new(), + image_backend: IncompatibleImageBackend, + }); + let service = SandboxService::new(provider.clone()); + let mut request = request(); + request.spec_mut().resources = SandboxResources::new( + "2".parse::().expect("test CPU should be valid"), + "1Gi".parse::().expect("test memory should be valid"), + RootFilesystem::direct( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ); + + let capabilities = service + .capabilities(&request.spec().platform) + .await + .expect("Provider capabilities should be available"); + assert!( + capabilities + .root_filesystem_modes() + .contains(RootFilesystemMode::Layered) + ); + assert!( + !capabilities + .root_filesystem_modes() + .contains(RootFilesystemMode::Direct) + ); + + let error = service + .ensure(&request) + .await + .expect_err("Image Backend should reject direct materialization"); + + assert!(matches!( + error, + Error::UnsupportedImageRootFilesystemMode { + operation: image::ImageOperation::Resolve, + mode: RootFilesystemMode::Direct, + } + )); + assert_eq!(provider.backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn platform_is_immutable_after_materialization() { + let linux = Platform::native("linux"); + let windows = Platform::native("windows"); + let backend = Rc::new(memory::Provider::with_platforms(linux.clone(), [windows.clone()])); + let service = SandboxService::new(backend); + let linux_request = request(); + let _ = service.ensure(&linux_request).await.expect("first ensure"); + let windows_request = EnsureSandboxRequest::new( + linux_request.name().clone(), + SandboxSpec { + platform: windows, + ..linux_request.spec().clone() + }, + ); + + let error = service + .ensure(&windows_request) + .await + .expect_err("Platform change should fail"); + + assert!(matches!(error, Error::Immutable("platform"))); +} + +#[tokio::test(flavor = "local")] +async fn init_system_is_immutable_after_materialization() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let backend_init = request(); + let _ = service.ensure(&backend_init).await.expect("first ensure"); + let image_init = EnsureSandboxRequest::new( + backend_init.name().clone(), + SandboxSpec { + init_system: sandbox::init::InitSystem::Image, + ..backend_init.spec().clone() + }, + ); + + let error = service + .ensure(&image_init) + .await + .expect_err("init system change should fail"); + + assert!(matches!(error, Error::Immutable("initSystem"))); +} From 48ad920c02f5ac47439de300cf4f6b3c169fe2b2 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 4 Oct 2026 22:01:52 +0200 Subject: [PATCH 2/8] build: build the workspace from the repository root Point the workspace members and path dependencies at agentctl/ and sandbox/, and drop the GitHub runner coordinator, which stays in altinn-studio. Pruning Cargo.lock removes only the coordinator and the crates nothing else uses; no dependency is upgraded. The Sandbox crates move to sandbox/core, sandbox/authorization and sandbox/microsandbox, so their folders no longer repeat the parent's name. Their package names are unchanged. The Makefile, make-user-install.ps1 and installation-test.sh now run from the repository root. The .editorconfig rules apply to the Rust workspace paths only, and target/ and build/ are ignored. Files under agentctl/ and sandbox/ check out with LF on every platform, as they did in altinn-studio, because tests and scripts read them byte for byte. --- .editorconfig | 3 +- .gitignore | 4 ++ Cargo.lock | 56 +------------------ Cargo.toml | 16 +++--- Makefile | 50 ++++++++--------- agentctl/.gitattributes | 2 + agentctl/Cargo.toml | 6 +- agentctl/installation-test.sh | 16 +++--- agentctl/make-user-install.ps1 | 8 +-- sandbox/.gitattributes | 2 + .../Cargo.toml | 0 .../src/lib.rs | 0 .../tests/architecture.rs | 0 .../tests/policy.rs | 0 sandbox/{sandbox => core}/Cargo.toml | 0 .../examples/worktree/Cargo.toml | 2 +- .../examples/worktree/Dockerfile | 0 .../examples/worktree/README.md | 0 .../examples/worktree/src/main.rs | 0 .../examples/worktree/src/progress.rs | 0 .../examples/worktree/tmpfiles.conf | 0 sandbox/{sandbox => core}/src/backend.rs | 0 sandbox/{sandbox => core}/src/execution.rs | 0 sandbox/{sandbox => core}/src/feature.rs | 0 .../{sandbox => core}/src/file_transfer.rs | 0 sandbox/{sandbox => core}/src/image.rs | 0 sandbox/{sandbox => core}/src/init.rs | 0 sandbox/{sandbox => core}/src/lib.rs | 0 sandbox/{sandbox => core}/src/memory.rs | 0 sandbox/{sandbox => core}/src/mount.rs | 0 sandbox/{sandbox => core}/src/name.rs | 0 sandbox/{sandbox => core}/src/network.rs | 0 sandbox/{sandbox => core}/src/path.rs | 0 sandbox/{sandbox => core}/src/platform.rs | 0 .../{sandbox => core}/src/progress/fold.rs | 0 sandbox/{sandbox => core}/src/progress/mod.rs | 0 sandbox/{sandbox => core}/src/provider.rs | 0 sandbox/{sandbox => core}/src/resource.rs | 0 .../{sandbox => core}/src/root_filesystem.rs | 0 sandbox/{sandbox => core}/src/secret_store.rs | 0 sandbox/{sandbox => core}/src/service.rs | 0 sandbox/{sandbox => core}/src/terminal.rs | 0 sandbox/{sandbox => core}/src/volume.rs | 0 .../{sandbox => core}/tests/architecture.rs | 0 sandbox/{sandbox => core}/tests/image.rs | 0 sandbox/{sandbox => core}/tests/name.rs | 0 sandbox/{sandbox => core}/tests/network.rs | 0 sandbox/{sandbox => core}/tests/platform.rs | 0 .../{sandbox => core}/tests/secret_store.rs | 0 sandbox/{sandbox => core}/tests/service.rs | 0 .../Cargo.toml | 4 +- .../src/backend.rs | 0 .../src/client.rs | 0 .../src/encoding.rs | 0 .../src/error.rs | 0 .../src/execution.rs | 0 .../src/files.rs | 0 .../src/guest_tcp.rs | 0 .../src/heartbeat.rs | 0 .../src/image.rs | 0 .../src/image_cache.rs | 0 .../src/lib.rs | 0 .../src/network_backend.rs | 0 .../src/network_endpoint.rs | 0 .../src/platform.rs | 0 .../src/state.rs | 0 .../src/volumes.rs | 0 .../tests/architecture.rs | 0 .../tests/backend.rs | 0 .../tests/fixtures/runtime-image/Dockerfile | 0 .../tests/network_runtime.rs | 0 .../tests/runtime.rs | 0 72 files changed, 62 insertions(+), 107 deletions(-) create mode 100644 agentctl/.gitattributes create mode 100644 sandbox/.gitattributes rename sandbox/{sandbox-authorization => authorization}/Cargo.toml (100%) rename sandbox/{sandbox-authorization => authorization}/src/lib.rs (100%) rename sandbox/{sandbox-authorization => authorization}/tests/architecture.rs (100%) rename sandbox/{sandbox-authorization => authorization}/tests/policy.rs (100%) rename sandbox/{sandbox => core}/Cargo.toml (100%) rename sandbox/{sandbox => core}/examples/worktree/Cargo.toml (86%) rename sandbox/{sandbox => core}/examples/worktree/Dockerfile (100%) rename sandbox/{sandbox => core}/examples/worktree/README.md (100%) rename sandbox/{sandbox => core}/examples/worktree/src/main.rs (100%) rename sandbox/{sandbox => core}/examples/worktree/src/progress.rs (100%) rename sandbox/{sandbox => core}/examples/worktree/tmpfiles.conf (100%) rename sandbox/{sandbox => core}/src/backend.rs (100%) rename sandbox/{sandbox => core}/src/execution.rs (100%) rename sandbox/{sandbox => core}/src/feature.rs (100%) rename sandbox/{sandbox => core}/src/file_transfer.rs (100%) rename sandbox/{sandbox => core}/src/image.rs (100%) rename sandbox/{sandbox => core}/src/init.rs (100%) rename sandbox/{sandbox => core}/src/lib.rs (100%) rename sandbox/{sandbox => core}/src/memory.rs (100%) rename sandbox/{sandbox => core}/src/mount.rs (100%) rename sandbox/{sandbox => core}/src/name.rs (100%) rename sandbox/{sandbox => core}/src/network.rs (100%) rename sandbox/{sandbox => core}/src/path.rs (100%) rename sandbox/{sandbox => core}/src/platform.rs (100%) rename sandbox/{sandbox => core}/src/progress/fold.rs (100%) rename sandbox/{sandbox => core}/src/progress/mod.rs (100%) rename sandbox/{sandbox => core}/src/provider.rs (100%) rename sandbox/{sandbox => core}/src/resource.rs (100%) rename sandbox/{sandbox => core}/src/root_filesystem.rs (100%) rename sandbox/{sandbox => core}/src/secret_store.rs (100%) rename sandbox/{sandbox => core}/src/service.rs (100%) rename sandbox/{sandbox => core}/src/terminal.rs (100%) rename sandbox/{sandbox => core}/src/volume.rs (100%) rename sandbox/{sandbox => core}/tests/architecture.rs (100%) rename sandbox/{sandbox => core}/tests/image.rs (100%) rename sandbox/{sandbox => core}/tests/name.rs (100%) rename sandbox/{sandbox => core}/tests/network.rs (100%) rename sandbox/{sandbox => core}/tests/platform.rs (100%) rename sandbox/{sandbox => core}/tests/secret_store.rs (100%) rename sandbox/{sandbox => core}/tests/service.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/Cargo.toml (88%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/backend.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/client.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/encoding.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/error.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/execution.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/files.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/guest_tcp.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/heartbeat.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/image.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/image_cache.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/lib.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/network_backend.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/network_endpoint.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/platform.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/state.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/src/volumes.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/tests/architecture.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/tests/backend.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/tests/fixtures/runtime-image/Dockerfile (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/tests/network_runtime.rs (100%) rename sandbox/{sandbox-microsandbox => microsandbox}/tests/runtime.rs (100%) diff --git a/.editorconfig b/.editorconfig index d9e5871..67f7590 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,8 @@ # EditorConfig is awesome: https://editorconfig.org root = true -[*] +# The Rust workspace: agentctl, the Sandbox crates and the root build files. +[{agentctl/**,sandbox/**,Makefile,*.toml,.cargo/**}] charset = utf-8 end_of_line = lf insert_final_newline = true diff --git a/.gitignore b/.gitignore index df629fe..f3be51f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ Thumbs.db # Claude Code scratch tmpclaude-* + +# Rust +/target/ +/build/ diff --git a/Cargo.lock b/Cargo.lock index 410173d..24ebc7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -497,7 +497,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", - "untrusted 0.7.1", "zeroize", ] @@ -2171,21 +2170,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "github-runner-coordinator" -version = "0.1.0" -dependencies = [ - "clap", - "futures-util", - "jsonwebtoken", - "reqwest", - "sandbox", - "sandbox-microsandbox", - "serde", - "tempfile", - "tokio", -] - [[package]] name = "glob" version = "0.3.4" @@ -2933,24 +2917,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "11.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "getrandom 0.2.17", - "js-sys", - "pem", - "serde", - "serde_json", - "signature", - "simple_asn1", - "zeroize", -] - [[package]] name = "kasuari" version = "0.4.12" @@ -5057,7 +5023,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -5274,7 +5240,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -5807,18 +5773,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.20", - "time", -] - [[package]] name = "siphasher" version = "1.0.3" @@ -6842,12 +6796,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 3ee0fac..cf4b2b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,10 @@ [workspace] members = [ - "src/experimental/agent", - "src/experimental/sandbox-authorization", - "src/experimental/sandbox", - "src/experimental/sandbox/examples/worktree", - "src/experimental/sandbox-microsandbox", - "src/ci/github-runner/coordinator", + "agentctl", + "sandbox/authorization", + "sandbox/core", + "sandbox/core/examples/worktree", + "sandbox/microsandbox", ] resolver = "3" @@ -25,7 +24,6 @@ futures-core = "0.3.34" futures-util = "0.3.34" flate2 = "1.1.9" ignore = "0.4.33" -jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } # Microsandbox fork commit from `main-digdir`, tagged `digdir-v0.7.4-digdir.2`. # Update all three revisions together with the runtime bundle digests. microsandbox = { git = "https://github.com/martinothamar/microsandbox.git", rev = "c3a16753edba0ad4b21844ccd50ce7b615e43f84", default-features = false, features = ["local", "net"] } @@ -36,8 +34,8 @@ ratatui = "0.30.2" reqwest = { version = "0.13.4", features = ["json"] } rand_core = { version = "0.6.4", features = ["getrandom"] } rusqlite = "0.39.0" -sandbox = { path = "src/experimental/sandbox" } -sandbox-microsandbox = { path = "src/experimental/sandbox-microsandbox" } +sandbox = { path = "sandbox/core" } +sandbox-microsandbox = { path = "sandbox/microsandbox" } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" serde_yaml_ng = "0.10.0" diff --git a/Makefile b/Makefile index 38b2824..d1d58d6 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,9 @@ USER_INSTALL_VERSION := v0.0.1-dev.$(shell date -u +%Y%m%d%H%M%S) USER_INSTALL_ARCHIVE := $(abspath build/user-install/agent-$(USER_INSTALL_VERSION).tar.gz) -RELEASE_BIN_DIR := $(abspath $(or $(CARGO_TARGET_DIR),../../target)/release) +RELEASE_BIN_DIR := $(abspath $(or $(CARGO_TARGET_DIR),target)/release) -EXPERIMENTAL_PACKAGES := \ +PACKAGES := \ -p agent \ -p sandbox-authorization \ -p sandbox \ @@ -24,59 +24,59 @@ help: ## Show this help message @echo 'Available targets:' @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' -build: ## Build all experimental Rust crates - @echo "Building experimental Rust crates..." - @cargo build $(EXPERIMENTAL_PACKAGES) --all-targets --locked +build: ## Build all Rust crates + @echo "Building Rust crates..." + @cargo build $(PACKAGES) --all-targets --locked @echo "✓ Build successful" user-install: ## Build, package and install agentctl and agentd for the current user ifeq ($(OS),Windows_NT) - @powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(abspath make-user-install.ps1)" + @powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(abspath agentctl/make-user-install.ps1)" else - @echo "Building experimental Agent $(USER_INSTALL_VERSION)..." + @echo "Building agentctl $(USER_INSTALL_VERSION)..." @AGENT_VERSION=$(USER_INSTALL_VERSION) cargo build --release --locked -p agent --bins - @./agent/package.sh "$(USER_INSTALL_ARCHIVE)" "$(RELEASE_BIN_DIR)" - @AGENT_VERSION=$(USER_INSTALL_VERSION) AGENT_LOCAL_ARCHIVE="$(USER_INSTALL_ARCHIVE)" ./agent/install.sh + @./agentctl/package.sh "$(USER_INSTALL_ARCHIVE)" "$(RELEASE_BIN_DIR)" + @AGENT_VERSION=$(USER_INSTALL_VERSION) AGENT_LOCAL_ARCHIVE="$(USER_INSTALL_ARCHIVE)" ./agentctl/install.sh endif -clean: ## Clean experimental build artifacts - @echo "Cleaning experimental build artifacts..." - @cargo clean $(EXPERIMENTAL_PACKAGES) +clean: ## Clean Rust build artifacts + @echo "Cleaning Rust build artifacts..." + @cargo clean $(PACKAGES) @echo "✓ Cleaned" fmt: ## Format Rust code - @echo "Formatting experimental Rust code..." - @cargo fmt $(EXPERIMENTAL_PACKAGES) + @echo "Formatting Rust code..." + @cargo fmt $(PACKAGES) @echo "✓ Code formatted" lint: ## Run strict Clippy analysis - @echo "Linting experimental Rust crates..." - @cargo clippy $(EXPERIMENTAL_PACKAGES) --all-targets --all-features --locked + @echo "Linting Rust crates..." + @cargo clippy $(PACKAGES) --all-targets --all-features --locked @echo "✓ Lint passed" lint-fix: ## Apply safe Clippy fixes - @echo "Applying Clippy fixes to experimental Rust crates..." - @cargo clippy $(EXPERIMENTAL_PACKAGES) --all-targets --all-features --fix --allow-dirty --locked + @echo "Applying Clippy fixes to Rust crates..." + @cargo clippy $(PACKAGES) --all-targets --all-features --fix --allow-dirty --locked @echo "✓ Lint fixes applied" test: changelog-test ## Run all tests - @echo "Testing experimental Rust crates..." - @cargo test $(EXPERIMENTAL_PACKAGES) --all-targets --locked + @echo "Testing Rust crates..." + @cargo test $(PACKAGES) --all-targets --locked @echo "✓ Tests passed" -changelog-validate: ## Check that CHANGELOG.md has the expected structure - @./changelog.sh validate +changelog-validate: ## Check that agentctl/CHANGELOG.md has the expected structure + @./agentctl/changelog.sh validate changelog-test: ## Run the changelog.sh tests - @./changelog_test.sh + @./agentctl/changelog_test.sh test-e2e: ## Run integration tests that require Docker, Internet access, and KVM - @echo "Running experimental end-to-end tests..." + @echo "Running end-to-end tests..." @cargo test -p sandbox-microsandbox --tests --locked -- --ignored @echo "✓ End-to-end tests passed" deps: ## Print the workspace dependency graph - @cargo tree $(EXPERIMENTAL_PACKAGES) --locked + @cargo tree $(PACKAGES) --locked deps-check: ## Check for unused direct dependencies @cargo machete --with-metadata . diff --git a/agentctl/.gitattributes b/agentctl/.gitattributes new file mode 100644 index 0000000..5922768 --- /dev/null +++ b/agentctl/.gitattributes @@ -0,0 +1,2 @@ +# Check out with LF on every platform: tests and scripts read these files byte for byte. +* text=auto eol=lf diff --git a/agentctl/Cargo.toml b/agentctl/Cargo.toml index e457a58..0be9c4e 100644 --- a/agentctl/Cargo.toml +++ b/agentctl/Cargo.toml @@ -8,9 +8,9 @@ license.workspace = true [dependencies] base64.workspace = true -sandbox = { path = "../sandbox" } -sandbox-authorization = { path = "../sandbox-authorization" } -sandbox-microsandbox = { path = "../sandbox-microsandbox" } +sandbox = { path = "../sandbox/core" } +sandbox-authorization = { path = "../sandbox/authorization" } +sandbox-microsandbox = { path = "../sandbox/microsandbox" } clap.workspace = true crossterm.workspace = true ignore.workspace = true diff --git a/agentctl/installation-test.sh b/agentctl/installation-test.sh index a04b509..0f89544 100755 --- a/agentctl/installation-test.sh +++ b/agentctl/installation-test.sh @@ -48,7 +48,7 @@ mkdir -p "${smoke_root}" CARGO_TARGET_DIR="${smoke_target}" CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 \ AGENT_VERSION="${old_version}" cargo build --locked -p agent --bins -./agent/package.sh "${old_archive}" "${binary_directory}" +./agentctl/package.sh "${old_archive}" "${binary_directory}" CARGO_TARGET_DIR="${smoke_target}" CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 \ AGENT_VERSION="${target_version}" cargo build --locked -p agent --bins suffix="" @@ -58,7 +58,7 @@ fi mkdir "${target_binaries}" mv "${binary_directory}/agentctl${suffix}" "${binary_directory}/agentd${suffix}" "${target_binaries}/" rm -rf -- "${smoke_target}" -./agent/package.sh "${target_archive}" "${target_binaries}" +./agentctl/package.sh "${target_archive}" "${target_binaries}" if [ "${RUNNER_OS:-}" = "Windows" ]; then # Standalone installation @@ -72,7 +72,7 @@ if [ "${RUNNER_OS:-}" = "Windows" ]; then export AGENT_HOME="$(cygpath -w "${standalone_root}/home")" export AGENT_VERSION="${target_version}" export AGENT_LOCAL_ARCHIVE="$(cygpath -w "${target_archive}")" - pwsh -NoProfile -File "$(cygpath -w agent/install.ps1)" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" # shellcheck disable=SC2016 # PowerShell expands its own environment variables. pwsh -NoProfile -Command ' $agentctl = Join-Path $env:AGENT_INSTALL_DIR "agentctl.exe" @@ -100,7 +100,7 @@ if [ "${RUNNER_OS:-}" = "Windows" ]; then export AGENT_VERSION="${old_version}" AGENT_LOCAL_ARCHIVE="$(cygpath -w "${old_archive}")" export AGENT_INSTALL_ROOT AGENT_INSTALL_DIR AGENT_HOME AGENT_LOCAL_ARCHIVE - pwsh -NoProfile -File "$(cygpath -w agent/install.ps1)" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" export AGENT_SMOKE_BIN="${AGENT_INSTALL_DIR}" AGENT_LOCAL_ARCHIVE="$(cygpath -w "${target_archive}")" export AGENT_LOCAL_ARCHIVE @@ -123,7 +123,7 @@ if [ "${RUNNER_OS:-}" = "Windows" ]; then AGENT_LOCAL_ARCHIVE="$(cygpath -w "${old_archive}")" export AGENT_VERSION="${old_version}" export AGENT_INSTALL_ROOT AGENT_LOCAL_ARCHIVE - pwsh -NoProfile -File "$(cygpath -w agent/install.ps1)" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" # shellcheck disable=SC2016 # PowerShell expands its own environment variables. pwsh -NoProfile -Command ' $agentctl = Join-Path $env:AGENT_SMOKE_BIN "agentctl.cmd" @@ -144,7 +144,7 @@ else AGENT_HOME="${standalone_root}/home" \ AGENT_VERSION="${target_version}" \ AGENT_LOCAL_ARCHIVE="${target_archive}" \ - ./agent/install.sh + ./agentctl/install.sh test -x "${standalone_root}/bin/agentctl" test -x "${standalone_root}/bin/agentd" test "$("${standalone_root}/bin/agentctl" --version)" = "agentctl ${target_version}" @@ -160,7 +160,7 @@ else export AGENT_HOME="${smoke_root}/home" export AGENT_VERSION="${old_version}" export AGENT_LOCAL_ARCHIVE="${old_archive}" - ./agent/install.sh + ./agentctl/install.sh agentctl="${AGENT_INSTALL_DIR}/agentctl" export AGENT_LOCAL_ARCHIVE="${target_archive}" unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR @@ -171,6 +171,6 @@ else export AGENT_INSTALL_DIR="${smoke_root}/bin" export AGENT_LOCAL_ARCHIVE="${old_archive}" export AGENT_VERSION="${old_version}" - ./agent/install.sh + ./agentctl/install.sh test "$("${agentctl}" --version)" = "agentctl ${old_version}" fi diff --git a/agentctl/make-user-install.ps1 b/agentctl/make-user-install.ps1 index b0d608c..0591daf 100644 --- a/agentctl/make-user-install.ps1 +++ b/agentctl/make-user-install.ps1 @@ -21,7 +21,7 @@ function Restore-ProcessEnvironment([string]$Name, [AllowNull()][string]$Value) } } -$Root = [IO.Path]::GetFullPath($PSScriptRoot) +$Root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) if (-not $Version) { $Version = "v0.0.1-dev.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))" } elseif (-not $Version.StartsWith("v")) { @@ -31,20 +31,20 @@ if (-not $Version) { $TargetRoot = if ($env:CARGO_TARGET_DIR) { Resolve-PathFromRoot $env:CARGO_TARGET_DIR $Root } else { - Resolve-PathFromRoot "../../target" $Root + Resolve-PathFromRoot "target" $Root } $ReleaseBinDirectory = Join-Path $TargetRoot "release" $ArchiveDirectory = Join-Path $Root "build/user-install" $ArchiveName = "agent-$Version.tar.gz" $Archive = Join-Path $ArchiveDirectory $ArchiveName -$Installer = Join-Path $Root "agent/install.ps1" +$Installer = Join-Path $Root "agentctl/install.ps1" $PreviousVersion = [Environment]::GetEnvironmentVariable("AGENT_VERSION", "Process") $PreviousArchive = [Environment]::GetEnvironmentVariable("AGENT_LOCAL_ARCHIVE", "Process") $PreviousChecksum = [Environment]::GetEnvironmentVariable("AGENT_LOCAL_ARCHIVE_SHA256", "Process") Push-Location $Root try { - Write-Host "Building experimental Agent $Version..." + Write-Host "Building agentctl $Version..." $env:AGENT_VERSION = $Version & cargo build --release --locked -p agent --bins if ($LASTEXITCODE -ne 0) { diff --git a/sandbox/.gitattributes b/sandbox/.gitattributes new file mode 100644 index 0000000..5922768 --- /dev/null +++ b/sandbox/.gitattributes @@ -0,0 +1,2 @@ +# Check out with LF on every platform: tests and scripts read these files byte for byte. +* text=auto eol=lf diff --git a/sandbox/sandbox-authorization/Cargo.toml b/sandbox/authorization/Cargo.toml similarity index 100% rename from sandbox/sandbox-authorization/Cargo.toml rename to sandbox/authorization/Cargo.toml diff --git a/sandbox/sandbox-authorization/src/lib.rs b/sandbox/authorization/src/lib.rs similarity index 100% rename from sandbox/sandbox-authorization/src/lib.rs rename to sandbox/authorization/src/lib.rs diff --git a/sandbox/sandbox-authorization/tests/architecture.rs b/sandbox/authorization/tests/architecture.rs similarity index 100% rename from sandbox/sandbox-authorization/tests/architecture.rs rename to sandbox/authorization/tests/architecture.rs diff --git a/sandbox/sandbox-authorization/tests/policy.rs b/sandbox/authorization/tests/policy.rs similarity index 100% rename from sandbox/sandbox-authorization/tests/policy.rs rename to sandbox/authorization/tests/policy.rs diff --git a/sandbox/sandbox/Cargo.toml b/sandbox/core/Cargo.toml similarity index 100% rename from sandbox/sandbox/Cargo.toml rename to sandbox/core/Cargo.toml diff --git a/sandbox/sandbox/examples/worktree/Cargo.toml b/sandbox/core/examples/worktree/Cargo.toml similarity index 86% rename from sandbox/sandbox/examples/worktree/Cargo.toml rename to sandbox/core/examples/worktree/Cargo.toml index 211775e..9f1a6ac 100644 --- a/sandbox/sandbox/examples/worktree/Cargo.toml +++ b/sandbox/core/examples/worktree/Cargo.toml @@ -11,7 +11,7 @@ version.workspace = true clap.workspace = true futures-util.workspace = true sandbox = { path = "../.." } -sandbox-microsandbox = { path = "../../../sandbox-microsandbox" } +sandbox-microsandbox = { path = "../../../microsandbox" } sha2.workspace = true tokio.workspace = true diff --git a/sandbox/sandbox/examples/worktree/Dockerfile b/sandbox/core/examples/worktree/Dockerfile similarity index 100% rename from sandbox/sandbox/examples/worktree/Dockerfile rename to sandbox/core/examples/worktree/Dockerfile diff --git a/sandbox/sandbox/examples/worktree/README.md b/sandbox/core/examples/worktree/README.md similarity index 100% rename from sandbox/sandbox/examples/worktree/README.md rename to sandbox/core/examples/worktree/README.md diff --git a/sandbox/sandbox/examples/worktree/src/main.rs b/sandbox/core/examples/worktree/src/main.rs similarity index 100% rename from sandbox/sandbox/examples/worktree/src/main.rs rename to sandbox/core/examples/worktree/src/main.rs diff --git a/sandbox/sandbox/examples/worktree/src/progress.rs b/sandbox/core/examples/worktree/src/progress.rs similarity index 100% rename from sandbox/sandbox/examples/worktree/src/progress.rs rename to sandbox/core/examples/worktree/src/progress.rs diff --git a/sandbox/sandbox/examples/worktree/tmpfiles.conf b/sandbox/core/examples/worktree/tmpfiles.conf similarity index 100% rename from sandbox/sandbox/examples/worktree/tmpfiles.conf rename to sandbox/core/examples/worktree/tmpfiles.conf diff --git a/sandbox/sandbox/src/backend.rs b/sandbox/core/src/backend.rs similarity index 100% rename from sandbox/sandbox/src/backend.rs rename to sandbox/core/src/backend.rs diff --git a/sandbox/sandbox/src/execution.rs b/sandbox/core/src/execution.rs similarity index 100% rename from sandbox/sandbox/src/execution.rs rename to sandbox/core/src/execution.rs diff --git a/sandbox/sandbox/src/feature.rs b/sandbox/core/src/feature.rs similarity index 100% rename from sandbox/sandbox/src/feature.rs rename to sandbox/core/src/feature.rs diff --git a/sandbox/sandbox/src/file_transfer.rs b/sandbox/core/src/file_transfer.rs similarity index 100% rename from sandbox/sandbox/src/file_transfer.rs rename to sandbox/core/src/file_transfer.rs diff --git a/sandbox/sandbox/src/image.rs b/sandbox/core/src/image.rs similarity index 100% rename from sandbox/sandbox/src/image.rs rename to sandbox/core/src/image.rs diff --git a/sandbox/sandbox/src/init.rs b/sandbox/core/src/init.rs similarity index 100% rename from sandbox/sandbox/src/init.rs rename to sandbox/core/src/init.rs diff --git a/sandbox/sandbox/src/lib.rs b/sandbox/core/src/lib.rs similarity index 100% rename from sandbox/sandbox/src/lib.rs rename to sandbox/core/src/lib.rs diff --git a/sandbox/sandbox/src/memory.rs b/sandbox/core/src/memory.rs similarity index 100% rename from sandbox/sandbox/src/memory.rs rename to sandbox/core/src/memory.rs diff --git a/sandbox/sandbox/src/mount.rs b/sandbox/core/src/mount.rs similarity index 100% rename from sandbox/sandbox/src/mount.rs rename to sandbox/core/src/mount.rs diff --git a/sandbox/sandbox/src/name.rs b/sandbox/core/src/name.rs similarity index 100% rename from sandbox/sandbox/src/name.rs rename to sandbox/core/src/name.rs diff --git a/sandbox/sandbox/src/network.rs b/sandbox/core/src/network.rs similarity index 100% rename from sandbox/sandbox/src/network.rs rename to sandbox/core/src/network.rs diff --git a/sandbox/sandbox/src/path.rs b/sandbox/core/src/path.rs similarity index 100% rename from sandbox/sandbox/src/path.rs rename to sandbox/core/src/path.rs diff --git a/sandbox/sandbox/src/platform.rs b/sandbox/core/src/platform.rs similarity index 100% rename from sandbox/sandbox/src/platform.rs rename to sandbox/core/src/platform.rs diff --git a/sandbox/sandbox/src/progress/fold.rs b/sandbox/core/src/progress/fold.rs similarity index 100% rename from sandbox/sandbox/src/progress/fold.rs rename to sandbox/core/src/progress/fold.rs diff --git a/sandbox/sandbox/src/progress/mod.rs b/sandbox/core/src/progress/mod.rs similarity index 100% rename from sandbox/sandbox/src/progress/mod.rs rename to sandbox/core/src/progress/mod.rs diff --git a/sandbox/sandbox/src/provider.rs b/sandbox/core/src/provider.rs similarity index 100% rename from sandbox/sandbox/src/provider.rs rename to sandbox/core/src/provider.rs diff --git a/sandbox/sandbox/src/resource.rs b/sandbox/core/src/resource.rs similarity index 100% rename from sandbox/sandbox/src/resource.rs rename to sandbox/core/src/resource.rs diff --git a/sandbox/sandbox/src/root_filesystem.rs b/sandbox/core/src/root_filesystem.rs similarity index 100% rename from sandbox/sandbox/src/root_filesystem.rs rename to sandbox/core/src/root_filesystem.rs diff --git a/sandbox/sandbox/src/secret_store.rs b/sandbox/core/src/secret_store.rs similarity index 100% rename from sandbox/sandbox/src/secret_store.rs rename to sandbox/core/src/secret_store.rs diff --git a/sandbox/sandbox/src/service.rs b/sandbox/core/src/service.rs similarity index 100% rename from sandbox/sandbox/src/service.rs rename to sandbox/core/src/service.rs diff --git a/sandbox/sandbox/src/terminal.rs b/sandbox/core/src/terminal.rs similarity index 100% rename from sandbox/sandbox/src/terminal.rs rename to sandbox/core/src/terminal.rs diff --git a/sandbox/sandbox/src/volume.rs b/sandbox/core/src/volume.rs similarity index 100% rename from sandbox/sandbox/src/volume.rs rename to sandbox/core/src/volume.rs diff --git a/sandbox/sandbox/tests/architecture.rs b/sandbox/core/tests/architecture.rs similarity index 100% rename from sandbox/sandbox/tests/architecture.rs rename to sandbox/core/tests/architecture.rs diff --git a/sandbox/sandbox/tests/image.rs b/sandbox/core/tests/image.rs similarity index 100% rename from sandbox/sandbox/tests/image.rs rename to sandbox/core/tests/image.rs diff --git a/sandbox/sandbox/tests/name.rs b/sandbox/core/tests/name.rs similarity index 100% rename from sandbox/sandbox/tests/name.rs rename to sandbox/core/tests/name.rs diff --git a/sandbox/sandbox/tests/network.rs b/sandbox/core/tests/network.rs similarity index 100% rename from sandbox/sandbox/tests/network.rs rename to sandbox/core/tests/network.rs diff --git a/sandbox/sandbox/tests/platform.rs b/sandbox/core/tests/platform.rs similarity index 100% rename from sandbox/sandbox/tests/platform.rs rename to sandbox/core/tests/platform.rs diff --git a/sandbox/sandbox/tests/secret_store.rs b/sandbox/core/tests/secret_store.rs similarity index 100% rename from sandbox/sandbox/tests/secret_store.rs rename to sandbox/core/tests/secret_store.rs diff --git a/sandbox/sandbox/tests/service.rs b/sandbox/core/tests/service.rs similarity index 100% rename from sandbox/sandbox/tests/service.rs rename to sandbox/core/tests/service.rs diff --git a/sandbox/sandbox-microsandbox/Cargo.toml b/sandbox/microsandbox/Cargo.toml similarity index 88% rename from sandbox/sandbox-microsandbox/Cargo.toml rename to sandbox/microsandbox/Cargo.toml index 6376566..7d0579a 100644 --- a/sandbox/sandbox-microsandbox/Cargo.toml +++ b/sandbox/microsandbox/Cargo.toml @@ -14,8 +14,8 @@ ignore.workspace = true microsandbox.workspace = true microsandbox-image.workspace = true microsandbox-network.workspace = true -sandbox = { path = "../sandbox" } -sandbox-authorization = { path = "../sandbox-authorization" } +sandbox = { path = "../core" } +sandbox-authorization = { path = "../authorization" } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/sandbox/sandbox-microsandbox/src/backend.rs b/sandbox/microsandbox/src/backend.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/backend.rs rename to sandbox/microsandbox/src/backend.rs diff --git a/sandbox/sandbox-microsandbox/src/client.rs b/sandbox/microsandbox/src/client.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/client.rs rename to sandbox/microsandbox/src/client.rs diff --git a/sandbox/sandbox-microsandbox/src/encoding.rs b/sandbox/microsandbox/src/encoding.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/encoding.rs rename to sandbox/microsandbox/src/encoding.rs diff --git a/sandbox/sandbox-microsandbox/src/error.rs b/sandbox/microsandbox/src/error.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/error.rs rename to sandbox/microsandbox/src/error.rs diff --git a/sandbox/sandbox-microsandbox/src/execution.rs b/sandbox/microsandbox/src/execution.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/execution.rs rename to sandbox/microsandbox/src/execution.rs diff --git a/sandbox/sandbox-microsandbox/src/files.rs b/sandbox/microsandbox/src/files.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/files.rs rename to sandbox/microsandbox/src/files.rs diff --git a/sandbox/sandbox-microsandbox/src/guest_tcp.rs b/sandbox/microsandbox/src/guest_tcp.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/guest_tcp.rs rename to sandbox/microsandbox/src/guest_tcp.rs diff --git a/sandbox/sandbox-microsandbox/src/heartbeat.rs b/sandbox/microsandbox/src/heartbeat.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/heartbeat.rs rename to sandbox/microsandbox/src/heartbeat.rs diff --git a/sandbox/sandbox-microsandbox/src/image.rs b/sandbox/microsandbox/src/image.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/image.rs rename to sandbox/microsandbox/src/image.rs diff --git a/sandbox/sandbox-microsandbox/src/image_cache.rs b/sandbox/microsandbox/src/image_cache.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/image_cache.rs rename to sandbox/microsandbox/src/image_cache.rs diff --git a/sandbox/sandbox-microsandbox/src/lib.rs b/sandbox/microsandbox/src/lib.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/lib.rs rename to sandbox/microsandbox/src/lib.rs diff --git a/sandbox/sandbox-microsandbox/src/network_backend.rs b/sandbox/microsandbox/src/network_backend.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/network_backend.rs rename to sandbox/microsandbox/src/network_backend.rs diff --git a/sandbox/sandbox-microsandbox/src/network_endpoint.rs b/sandbox/microsandbox/src/network_endpoint.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/network_endpoint.rs rename to sandbox/microsandbox/src/network_endpoint.rs diff --git a/sandbox/sandbox-microsandbox/src/platform.rs b/sandbox/microsandbox/src/platform.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/platform.rs rename to sandbox/microsandbox/src/platform.rs diff --git a/sandbox/sandbox-microsandbox/src/state.rs b/sandbox/microsandbox/src/state.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/state.rs rename to sandbox/microsandbox/src/state.rs diff --git a/sandbox/sandbox-microsandbox/src/volumes.rs b/sandbox/microsandbox/src/volumes.rs similarity index 100% rename from sandbox/sandbox-microsandbox/src/volumes.rs rename to sandbox/microsandbox/src/volumes.rs diff --git a/sandbox/sandbox-microsandbox/tests/architecture.rs b/sandbox/microsandbox/tests/architecture.rs similarity index 100% rename from sandbox/sandbox-microsandbox/tests/architecture.rs rename to sandbox/microsandbox/tests/architecture.rs diff --git a/sandbox/sandbox-microsandbox/tests/backend.rs b/sandbox/microsandbox/tests/backend.rs similarity index 100% rename from sandbox/sandbox-microsandbox/tests/backend.rs rename to sandbox/microsandbox/tests/backend.rs diff --git a/sandbox/sandbox-microsandbox/tests/fixtures/runtime-image/Dockerfile b/sandbox/microsandbox/tests/fixtures/runtime-image/Dockerfile similarity index 100% rename from sandbox/sandbox-microsandbox/tests/fixtures/runtime-image/Dockerfile rename to sandbox/microsandbox/tests/fixtures/runtime-image/Dockerfile diff --git a/sandbox/sandbox-microsandbox/tests/network_runtime.rs b/sandbox/microsandbox/tests/network_runtime.rs similarity index 100% rename from sandbox/sandbox-microsandbox/tests/network_runtime.rs rename to sandbox/microsandbox/tests/network_runtime.rs diff --git a/sandbox/sandbox-microsandbox/tests/runtime.rs b/sandbox/microsandbox/tests/runtime.rs similarity index 100% rename from sandbox/sandbox-microsandbox/tests/runtime.rs rename to sandbox/microsandbox/tests/runtime.rs From cef3d00b0c34bbcaa2e1602ff157ffab13b5975c Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 4 Oct 2026 22:01:52 +0200 Subject: [PATCH 3/8] test: drop tests over repository files These tests asserted what files declare rather than how agentctl behaves: altinn-studio's agents/ manifests, Dockerfile and skill list, which stay in altinn-studio, and the example manifests and Dockerfiles here. They broke on every legitimate edit of those files and exercised no agentctl code. Tests that use an example only as input stay. --- agentctl/tests/agent_manifests.rs | 269 ------------------------------ agentctl/tests/manifest.rs | 189 --------------------- agentctl/tests/ssh_access.rs | 50 ------ 3 files changed, 508 deletions(-) delete mode 100644 agentctl/tests/agent_manifests.rs diff --git a/agentctl/tests/agent_manifests.rs b/agentctl/tests/agent_manifests.rs deleted file mode 100644 index 57d7030..0000000 --- a/agentctl/tests/agent_manifests.rs +++ /dev/null @@ -1,269 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::path::{Path, PathBuf}; - -use agent::{Agent, Harness, MountSpec, manifest}; -use sandbox::image::ImageSource; - -fn repository_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") -} - -fn resolved(path: &Path) -> Agent { - manifest::resolve(path).expect("manifest should resolve").agent -} - -fn assert_inputs_exist(agent: &Agent, directory: &Path) { - // Naming the path matters: CI checks these out sparsely, so a source outside the - // checkout fails here and nowhere else, and the path is the whole diagnosis. - let home = directory.join(&agent.spec.home.source); - assert!(home.is_dir(), "home source {} is not a directory", home.display()); - for instruction in &agent.spec.instructions { - let source = directory.join(&instruction.source); - assert!( - source.is_file(), - "instruction source {} is not a file", - source.display() - ); - } - for skill in &agent.spec.skills { - let source = directory.join(&skill.source); - assert!( - source.join("SKILL.md").is_file(), - "skill source {} has no SKILL.md", - source.display() - ); - } - if let ImageSource::Build { - context, dockerfile, .. - } = &agent.spec.sandbox.image - { - let context = directory.join(context); - assert!(context.is_dir(), "image build context exists"); - assert!( - context.join(dockerfile).is_file(), - "Dockerfile is inside its build context" - ); - } -} - -#[test] -fn self_development_variants_are_local_independent_builds() { - let directory = repository_root().join("src/experimental/agent/examples/self-dev"); - let default = resolved(&directory.join("agent.yaml")); - let nested = resolved(&directory.join("agent.nested.yaml")); - let worktree = resolved(&directory.join("agent.worktree.yaml")); - - for agent in [&default, &nested, &worktree] { - assert_eq!( - agent.spec.sandbox.image, - ImageSource::Build { - context: PathBuf::from("."), - dockerfile: PathBuf::from("Dockerfile"), - target: None, - } - ); - assert_inputs_exist(agent, &directory); - } - assert!(nested.spec.sandbox.resources.cpu() < default.spec.sandbox.resources.cpu()); - assert!(nested.spec.sandbox.resources.memory() < default.spec.sandbox.resources.memory()); - assert!( - nested.spec.sandbox.resources.root_filesystem().capacity() - < default.spec.sandbox.resources.root_filesystem().capacity() - ); - assert!(matches!( - &worktree.spec.sandbox.mounts[..], - [MountSpec::Bind { source, target, read_only: false }, MountSpec::Tmpfs { .. }] - if source == Path::new("../../../../..") - && target.as_str() == "/home/agent/code/altinn-studio" - )); - let dockerfile = std::fs::read_to_string(directory.join("Dockerfile")).expect("self-dev Dockerfile"); - assert!(!dockerfile.contains("ghcr.io/altinn/altinn-studio/agent")); - for manifest in ["agent.yaml", "agent.nested.yaml", "agent.worktree.yaml"] { - let text = std::fs::read_to_string(directory.join(manifest)).expect(manifest); - assert!(!text.contains("agents/"), "{manifest} does not consume the Altinn tree"); - } -} - -#[test] -fn altinn_variants_inherit_agent_policy_and_select_expected_images() { - for (agent, target) in [("full", "full"), ("minimal", "minimal"), ("desktop", "desktop")] { - let directory = repository_root().join("agents").join(agent); - let default = resolved(&directory.join("agent.yaml")); - let nested = resolved(&directory.join("agent.nested.yaml")); - let nested_build = resolved(&directory.join("agent.nested-build.yaml")); - let worktree = resolved(&directory.join("agent.worktree.yaml")); - - let published = ImageSource::Reference { - reference: format!("ghcr.io/altinn/altinn-studio/agent-{agent}:latest"), - }; - assert_eq!(default.spec.sandbox.image, published); - assert_eq!(nested.spec.sandbox.image, published); - assert_eq!(worktree.spec.sandbox.image, published); - assert_eq!( - nested_build.spec.sandbox.image, - ImageSource::Build { - context: PathBuf::from(".."), - dockerfile: PathBuf::from("Dockerfile"), - target: Some(target.into()), - } - ); - assert_inputs_exist(&nested_build, &directory); - assert_published_skills(&default, agent); - // The image owns the harness version, here as much as in the examples: a published - // manifest that named one would have to be edited for every image bump. - for variant in [&default, &nested, &worktree, &nested_build] { - for harness in &variant.spec.harnesses { - assert_eq!( - harness.version, None, - "{} pins a version for {:?}; the image owns it", - variant.metadata.name, harness.kind - ); - } - } - - assert_published_harnesses(&default); - - assert_eq!(default.spec.secrets.len(), 5); - let azure_devops_pat = default - .spec - .secrets - .iter() - .find(|secret| secret.environment == "AZURE_DEVOPS_PAT") - .expect("Azure DevOps PAT is declared as a mediated secret"); - assert_eq!(azure_devops_pat.source(), "AZURE_DEVOPS_PAT"); - assert!(azure_devops_pat.optional); - assert_eq!(azure_devops_pat.allowed_hosts, ["dev.azure.com"]); - assert_eq!(azure_devops_pat.inert_value(), "$AGENT_SECRET_AZURE_DEVOPS_PAT"); - for (environment, host) in [ - ("STUDIO_PROD_API_KEY", "altinn.studio"), - ("STUDIO_STAGING_API_KEY", "staging.altinn.studio"), - ("STUDIO_DEV_API_KEY", "dev.altinn.studio"), - ] { - let secret = default - .spec - .secrets - .iter() - .find(|secret| secret.environment == environment) - .expect("Studio API key is declared as a mediated secret"); - assert_eq!(secret.source(), environment); - assert!(secret.optional); - assert_eq!(secret.allowed_hosts, [host]); - assert_eq!(secret.inert_value(), format!("$AGENT_SECRET_{environment}")); - } - - let mut comparable_build = nested_build.clone(); - comparable_build.metadata.name = nested.metadata.name.clone(); - comparable_build.spec.sandbox.image = nested.spec.sandbox.image.clone(); - assert_eq!(comparable_build, nested, "nested-build changes only name and image"); - - assert!(nested.spec.sandbox.resources.cpu() < default.spec.sandbox.resources.cpu()); - assert!(nested.spec.sandbox.resources.memory() < default.spec.sandbox.resources.memory()); - assert!( - nested.spec.sandbox.resources.root_filesystem().capacity() - < default.spec.sandbox.resources.root_filesystem().capacity() - ); - assert!(matches!( - &worktree.spec.sandbox.mounts[..], - [MountSpec::Bind { source, target, read_only: false }, MountSpec::Tmpfs { .. }] - if source == Path::new("../..") && target.as_str() == "/home/agent/code/altinn-studio" - )); - for inherited in [&nested, &nested_build, &worktree] { - assert_eq!(inherited.spec.harnesses, default.spec.harnesses); - assert_eq!(inherited.spec.instructions, default.spec.instructions); - assert_eq!(inherited.spec.skills, default.spec.skills); - assert_eq!(inherited.spec.access, default.spec.access); - assert_eq!(inherited.spec.secrets, default.spec.secrets); - assert_eq!(inherited.spec.network, default.spec.network); - } - } - let dockerfile = std::fs::read_to_string(repository_root().join("agents/Dockerfile")).expect("Altinn Dockerfile"); - assert!(!dockerfile.contains("AGENT_VERSION")); - assert!(!dockerfile.contains("src/experimental/agent/install.sh")); - assert!(!dockerfile.contains("agentctl --version")); - assert!(dockerfile.contains("FROM base AS minimal")); - assert!(dockerfile.contains("FROM base AS full")); - assert!(dockerfile.contains("FROM full AS desktop")); - assert!(!dockerfile.contains("cargo build")); -} - -#[test] -fn agent_images_install_the_pinned_gh_stack_extension() { - let root = repository_root(); - for dockerfile in [ - root.join("agents/Dockerfile"), - root.join("src/experimental/agent/examples/minimal/Dockerfile"), - root.join("src/experimental/agent/examples/self-dev/Dockerfile"), - ] { - let text = std::fs::read_to_string(&dockerfile).expect("Agent Dockerfile"); - assert!( - text.contains("ARG GH_STACK_VERSION="), - "{} pins gh-stack", - dockerfile.display() - ); - assert!( - text.contains("github/gh-stack/releases/download/v${GH_STACK_VERSION}"), - "{} downloads gh-stack from its official releases", - dockerfile.display() - ); - assert!( - text.contains("/home/agent/.local/share/gh/extensions/gh-stack/gh-stack"), - "{} installs gh-stack for the agent user", - dockerfile.display() - ); - } -} - -#[test] -fn every_agent_ignores_local_variants() { - let root = repository_root(); - for directory in [ - root.join("agents/full"), - root.join("agents/minimal"), - root.join("agents/desktop"), - root.join("src/experimental/agent/examples/self-dev"), - ] { - let ignore = std::fs::read_to_string(directory.join(".gitignore")).expect("Agent .gitignore"); - assert!(ignore.lines().any(|line| line == "agent.*.yaml")); - } -} - -/// Claude Code is required and the default; Codex is optional, so an Agent is created without it -/// on a host that has no Codex login rather than refusing to be created at all. -fn assert_published_harnesses(agent: &Agent) { - let claude = agent - .spec - .harness(Harness::ClaudeCode) - .expect("published manifests install Claude Code"); - assert!(!claude.optional); - assert!(claude.default); - let codex = agent - .spec - .harness(Harness::Codex) - .expect("published manifests install Codex"); - assert!(codex.optional); - assert!(!codex.default); -} - -/// Every repository-wide Skill is installed for every published Agent, and for every harness. -/// -/// They live in `.claude/skills/`, where Claude Code discovers them in a plain checkout, and the -/// manifests reach across so an Agent installs them for every harness as well. A Skill added there -/// and not added here reaches a local checkout only, which is the failure this guards. The desktop -/// Agent adds the Skill for driving its screen. -fn assert_published_skills(agent: &Agent, directory: &str) { - let mut expected = vec!["altinn-studio-app-development", "pr-evidence"]; - if directory == "desktop" { - expected.push("computer-use"); - } - expected.extend(["changelog", "tekstforfatter-docs", "text-content-review"]); - assert_eq!( - agent - .spec - .skills - .iter() - .filter_map(|skill| skill.name()) - .collect::>(), - expected - ); -} diff --git a/agentctl/tests/manifest.rs b/agentctl/tests/manifest.rs index afff1e3..a0df23a 100644 --- a/agentctl/tests/manifest.rs +++ b/agentctl/tests/manifest.rs @@ -131,119 +131,6 @@ fn a_manifest_may_set_the_run_state_and_omits_it_by_default() { manifest::decode(paused.as_bytes()).expect_err("only Running and Stopped exist"); } -/// The image owns the harness version, so a manifest that repeats it only creates a second place -/// to forget. The examples are what people copy, so none of them may pin one. -#[test] -fn no_example_manifest_pins_a_harness_version() { - let examples = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"); - let mut checked = 0; - for example in std::fs::read_dir(&examples).expect("examples directory") { - let directory = example.expect("examples entry").path(); - if !directory.is_dir() { - continue; - } - for manifest in std::fs::read_dir(&directory).expect("example directory") { - let path = manifest.expect("example entry").path(); - if !path.is_file() { - continue; - } - let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default(); - let is_manifest = name.starts_with("agent") - && path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("yaml")); - if !is_manifest { - continue; - } - let agent = manifest::resolve(&path) - .unwrap_or_else(|error| panic!("{} should resolve: {error}", path.display())) - .agent; - for harness in &agent.spec.harnesses { - assert_eq!( - harness.version, - None, - "{} pins a version for {:?}; the image owns it", - path.display(), - harness.kind - ); - } - checked += 1; - } - } - assert!(checked > 0, "no example manifests were checked"); -} - -#[test] -fn decodes_the_self_development_manifest() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/self-dev/agent.worktree.yaml"); - let agent = manifest::resolve(&path) - .expect("self-development manifest should resolve") - .agent; - - assert_eq!(agent.metadata.name, "agent-dev-worktree"); - assert_eq!(agent.spec.sandbox.platform.architecture, None); - assert_eq!(agent.spec.secrets.len(), 1); - assert_eq!(agent.spec.secrets[0].environment, "GITHUB_TOKEN"); - assert_eq!(agent.spec.secrets[0].source(), "GITHUB_TOKEN"); - assert_eq!(agent.spec.environment.len(), 2); - assert_eq!(agent.spec.environment[0].name, "GIT_USER_NAME"); - assert_eq!(agent.spec.environment[0].source(), "GIT_USER_NAME"); - assert_eq!( - agent.spec.secrets[0].placeholder.as_deref(), - Some("github_pat_AGENT_MEDIATED_GITHUB_TOKEN") - ); - assert!( - agent.spec.secrets[0] - .allowed_hosts - .iter() - .any(|host| host == "uploads.github.com") - ); - assert_eq!(agent.spec.skills.len(), 1); - assert_eq!(agent.spec.skills[0].name(), Some("pr-evidence")); - assert_eq!(agent.spec.harnesses.len(), 2); - assert!(agent.spec.harnesses[0].default); - assert_eq!(agent.spec.harnesses[0].kind, Harness::ClaudeCode); - assert_eq!(agent.spec.harnesses[0].version, None); - assert_eq!(agent.spec.harnesses[1].kind, Harness::Codex); - assert!(!agent.spec.harnesses[1].default); - assert_eq!( - agent.spec.sandbox.resources.root_filesystem().mode(), - RootFilesystemMode::Direct - ); -} - -#[test] -fn published_manifests_explicitly_select_git_identity() { - let manifests = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../agents"); - if !manifests.is_dir() { - eprintln!("skipping published manifests omitted from this sparse checkout"); - return; - } - - for path in [ - manifests.join("minimal/agent.yaml"), - manifests.join("full/agent.yaml"), - manifests.join("full/agent.nested.yaml"), - manifests.join("full/agent.nested-build.yaml"), - manifests.join("full/agent.worktree.yaml"), - manifests.join("desktop/agent.yaml"), - manifests.join("desktop/agent.nested.yaml"), - manifests.join("desktop/agent.nested-build.yaml"), - manifests.join("desktop/agent.worktree.yaml"), - ] { - let agent = manifest::resolve(&path) - .expect("published Agent manifest should resolve") - .agent; - let names = agent - .spec - .environment - .iter() - .map(|variable| variable.name.as_str()) - .collect::>(); - assert_eq!(names, ["GIT_USER_NAME", "GIT_USER_EMAIL"]); - } -} - #[test] fn decodes_explicit_non_secret_environment_with_an_optional_source() { let mut agent = support::agent("worker"); @@ -356,60 +243,6 @@ fn rejects_environment_collisions_with_secrets_and_harness_owned_values() { )); } -#[test] -fn self_development_mounts_the_host_checkout_instead_of_cloning() { - let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/self-dev"); - let agent = manifest::resolve(&directory.join("agent.worktree.yaml")) - .expect("self-development worktree variant should resolve") - .agent; - let dockerfile = include_str!("../examples/self-dev/Dockerfile"); - - let mounts = &agent.spec.sandbox.mounts; - assert_eq!(mounts.len(), 2); - assert!(matches!( - &mounts[0], - manifest::MountSpec::Bind { source, target, read_only } - if source == std::path::Path::new("../../../../..") - && target.as_str() == "/home/agent/code/altinn-studio" - && !read_only - )); - assert!(!dockerfile.contains("gh repo clone")); - - let default = manifest::resolve(&directory.join("agent.yaml")) - .expect("default manifest should resolve") - .agent; - assert_eq!(default.metadata.name, "agent-dev"); - assert_eq!(default.spec.sandbox.mounts.len(), 1); - assert_eq!(default.spec.environment, agent.spec.environment); - let nested = manifest::resolve(&directory.join("agent.nested.yaml")) - .expect("nested manifest should resolve") - .agent; - assert_eq!(nested.metadata.name, "agent-dev-nested"); - assert_eq!(nested.spec.sandbox.mounts, default.spec.sandbox.mounts); - assert_eq!(nested.spec.environment, agent.spec.environment); - assert!(nested.spec.sandbox.resources.memory() < default.spec.sandbox.resources.memory()); - for resolved in [&default, &nested, &agent] { - assert!(matches!( - &resolved.spec.sandbox.image, - sandbox::image::ImageSource::Build { .. } - )); - } -} - -#[test] -fn self_development_image_leaves_harness_startup_to_sessions() { - let dockerfile = include_str!("../examples/self-dev/Dockerfile"); - - assert!(!dockerfile.lines().any(|line| line.trim_start().starts_with("CMD "))); - assert!(dockerfile.contains("podman")); - assert!(dockerfile.contains("podman-docker")); - assert!(dockerfile.contains("nftables")); - assert!(dockerfile.contains("rustup")); - assert!(dockerfile.contains("cargo-machete")); - assert!(dockerfile.contains("ENV DOCKER_HOST=unix:///run/podman/podman.sock")); - assert!(dockerfile.contains("ENV CARGO_TARGET_DIR=")); -} - #[test] fn rejects_removed_repository_bootstrap_configuration() { let bytes = br" @@ -709,28 +542,6 @@ spec: } } -/// The `agents/` manifests declare the same default but live outside this crate, -/// which the portable hosts build from a sparse checkout, so only the examples are -/// guarded here. -#[test] -fn example_manifests_keep_claude_code_sessions_on_fable() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - for path in [ - root.join("examples/minimal/agent.yaml"), - root.join("examples/self-dev/agent.yaml"), - root.join("examples/self-dev/agent.nested.yaml"), - root.join("examples/self-dev/agent.worktree.yaml"), - ] { - let agent = manifest::resolve(&path).expect("manifest should resolve").agent; - let claude = agent - .spec - .harness(Harness::ClaudeCode) - .expect("every example manifest installs Claude Code"); - assert_eq!(claude.defaults.model_str(), Some("fable")); - assert_eq!(claude.defaults.effort_str(), None); - } -} - const ACCESS_MANIFEST_HEAD: &str = r#" apiVersion: agents.platform/v1alpha1 kind: Agent diff --git a/agentctl/tests/ssh_access.rs b/agentctl/tests/ssh_access.rs index a1c71d3..540814c 100644 --- a/agentctl/tests/ssh_access.rs +++ b/agentctl/tests/ssh_access.rs @@ -665,53 +665,3 @@ async fn descriptor_json_is_the_documented_shape() { let decoded: ssh::AccessInfo = serde_json::from_value(value).expect("round trip"); assert_eq!(decoded, info); } - -fn repository_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") -} - -#[test] -fn images_supply_ssh_prerequisites_without_owning_platform_policy() { - let root = repository_root(); - let published = root.join("agents/common"); - let self_dev = root.join("src/experimental/agent/examples/self-dev"); - - let dockerfile = std::fs::read_to_string(root.join("agents/Dockerfile")).expect("Dockerfile"); - let base_stage = dockerfile.split("FROM base AS minimal").next().expect("base stage"); - assert!(base_stage.contains("openssh-server")); - assert!(base_stage.contains("passwd --delete agent")); - assert!(base_stage.contains("systemctl mask ssh.service ssh.socket")); - assert!(!base_stage.contains("sshd_config")); - assert!(!published.join("sshd_config").exists()); - assert!(!published.join("ssh.service").exists()); - assert!(!published.join("ssh-tmpfiles.conf").exists()); - - let self_dev_dockerfile = std::fs::read_to_string(self_dev.join("Dockerfile")).expect("self-dev Dockerfile"); - assert!(self_dev_dockerfile.contains("openssh-server")); - assert!(self_dev_dockerfile.contains("passwd --delete agent")); - assert!(self_dev_dockerfile.contains("systemctl mask ssh.service ssh.socket")); - assert!(!self_dev_dockerfile.contains("sshd_config")); - assert!(!self_dev.join("sshd_config").exists()); - assert!(!self_dev.join("ssh.service").exists()); - assert!(!self_dev.join("ssh-tmpfiles.conf").exists()); - - for manifest in [ - "agents/full/agent.yaml", - "agents/full/agent.nested.yaml", - "agents/full/agent.nested-build.yaml", - "agents/full/agent.worktree.yaml", - "agents/minimal/agent.yaml", - "agents/desktop/agent.yaml", - "agents/desktop/agent.nested.yaml", - "agents/desktop/agent.nested-build.yaml", - "agents/desktop/agent.worktree.yaml", - "src/experimental/agent/examples/self-dev/agent.yaml", - "src/experimental/agent/examples/self-dev/agent.nested.yaml", - "src/experimental/agent/examples/self-dev/agent.worktree.yaml", - ] { - let decoded = agent::manifest::resolve(&root.join(manifest)) - .unwrap_or_else(|error| panic!("{manifest}: {error}")) - .agent; - assert!(decoded.spec.ssh_access(), "{manifest} declares SSH access"); - } -} From 2ed11cb805d78b5b7b6c1dc59c263a5355b6a5ba Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 4 Oct 2026 22:01:52 +0200 Subject: [PATCH 4/8] feat!: release agentctl from digdir/digdir-agents Installers and `agentctl self update` now look for `agentctl/v*` releases in digdir/digdir-agents, and nothing is called experimental any more. The self-development and worktree examples develop this repository. MICROSANDBOX.md keeps only the consumer side of the Microsandbox pin; synchronizing the forks and releasing runtimes is documented in digdir/microsandbox. altinn-studio issues are referenced as Altinn/altinn-studio#N until they are recreated here. --- README.md | 7 +- agentctl/AGENTS.md | 18 +- agentctl/CHANGELOG.md | 10 +- agentctl/HARNESSES.md | 16 +- agentctl/README.md | 33 +- agentctl/changelog.sh | 6 +- agentctl/changelog_test.sh | 6 +- agentctl/examples/minimal/README.md | 8 +- agentctl/examples/minimal/agent.yaml | 2 +- agentctl/examples/self-dev/Dockerfile | 5 - agentctl/examples/self-dev/README.md | 8 +- .../examples/self-dev/agent.worktree.yaml | 4 +- agentctl/examples/self-dev/instructions.md | 16 +- .../self-dev/skills/pr-evidence/SKILL.md | 2 +- agentctl/examples/self-dev/workspace-init.sh | 4 +- agentctl/install.ps1 | 10 +- agentctl/install.sh | 8 +- agentctl/src/bin/agentctl/self_update.rs | 2 +- agentctl/src/sandbox/platform/linux_ssh.rs | 2 +- agentctl/src/upgrade.rs | 6 +- agentctl/tests/manifest.rs | 2 +- sandbox/AGENTS.md | 5 + sandbox/MICROSANDBOX.md | 420 ++---------------- sandbox/core/examples/worktree/Dockerfile | 2 +- sandbox/core/examples/worktree/README.md | 5 +- sandbox/core/examples/worktree/src/main.rs | 6 +- 26 files changed, 142 insertions(+), 471 deletions(-) create mode 100644 sandbox/AGENTS.md diff --git a/README.md b/README.md index e43b722..b168072 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ agentens `triggers/`-katalog**. Formatet er beskrevet i [`integrations/README.md`](integrations/README.md) (resultatkontrakt med `intent`/`reply`). -### Planlagt innflytting: `agentctl/` og `sandbox/` +### `agentctl/` og `sandbox/` -Agent-laget fra `altinn-studio/src/experimental` flytter inn som to nye -rotkataloger (ki-lab-beslutning, september 2026): +Agent-laget fra `altinn-studio/src/experimental` har flyttet inn som to +rotkataloger (ki-lab-beslutning, september 2026), med Rust-arbeidsområdet på +rot: | Katalog | Rolle | |---|---| diff --git a/agentctl/AGENTS.md b/agentctl/AGENTS.md index f2b953d..e3931d5 100644 --- a/agentctl/AGENTS.md +++ b/agentctl/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md -This area contains the experimental agent platform described in `README.md`. +This area contains the agent platform described in `README.md`: the `agent` crate here, with `agentctl` and +`agentd`, and the Sandbox crates under `../sandbox/`. ## Architecture @@ -53,18 +54,17 @@ Use Tokio's `LocalRuntime` for asynchronous work. Keep control-plane state singl ## Development -Run `make help` in this directory to list the available development targets. +Run `make help` at the repository root to list the available development targets. -Follow [MICROSANDBOX.md](MICROSANDBOX.md) when synchronizing the Microsandbox or libkrunfw forks, -publishing a downstream runtime or updating this workspace's source and artifact pins. +Follow [MICROSANDBOX.md](../sandbox/MICROSANDBOX.md) when updating the Microsandbox source and runtime pins. The +forks are synchronized and their runtimes released in digdir/microsandbox and digdir/libkrunfw. -When adding or updating a harness installation or adapter, follow [HARNESSES.md](agent/HARNESSES.md). +When adding or updating a harness installation or adapter, follow [HARNESSES.md](HARNESSES.md). ## Changelog and releases -[CHANGELOG.md](CHANGELOG.md) is the release notes for the whole experimental Agent stack: `agentctl`, `agentd` and -the Agent images under `agents/`. There is one changelog because there is one release unit, the `agentctl` and -`agentd` binaries published by the `experimental-agent/v*` tag. The version in the changelog is that release +[CHANGELOG.md](CHANGELOG.md) is the release notes for the Agent platform. There is one changelog because there is +one release unit, the `agentctl` and `agentd` binaries published by the `agentctl/v*` tag. The version in the changelog is that release version; the Rust workspace version is a build detail and is not tracked there. Run `make changelog-validate` to check the file's structure, and `make changelog-test` for the tests covering @@ -73,6 +73,6 @@ Run `make changelog-validate` to check the file's structure, and `make changelog Releasing is a promotion pull request that renames `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and adds a fresh empty `## [Unreleased]` above it. That rename is itself a change to the Unreleased section, so the pull request needs no `skip-changelog` label. Once it is merged, push the tag -`experimental-agent/v`; the release workflow extracts that section with `changelog.sh extract` and +`agentctl/v`; the release workflow extracts that section with `changelog.sh extract` and publishes it as the GitHub release body, and fails before creating the release when the section is missing or has no date. diff --git a/agentctl/CHANGELOG.md b/agentctl/CHANGELOG.md index e07ad39..3aff735 100644 --- a/agentctl/CHANGELOG.md +++ b/agentctl/CHANGELOG.md @@ -1,17 +1,21 @@ # Changelog -All notable changes to the experimental Agent platform will be documented in this file. +All notable changes to the Agent platform will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Entries should describe only user-facing functionality in clear, user-friendly language; omit implementation details that do not affect how people use the product. Section ordering: Added, Changed, Fixed, Removed, Security, Deprecated. -The version is the Agent release published by the `experimental-agent/v*` tag, covering `agentctl`, `agentd` and the -Agent images they work with. The Rust workspace version is a build detail and is not tracked here. +The version is the Agent release published by the `agentctl/v*` tag, covering `agentctl`, `agentd` and the example +Agents. The Rust workspace version is a build detail and is not tracked here. ## [Unreleased] +### Changed + +- Breaking: `agentctl` is now developed and released in [digdir/digdir-agents](https://github.com/digdir/digdir-agents). Rerun the [installer](https://github.com/digdir/digdir-agents/tree/main/agentctl#install) once; Agents and Sessions carry over, and `agentctl self update` then follows the new releases. ([#136](https://github.com/digdir/digdir-agents/pull/136)) + ## [0.1.0-preview.9] - 2026-10-02 ### Added diff --git a/agentctl/HARNESSES.md b/agentctl/HARNESSES.md index a251725..11169ab 100644 --- a/agentctl/HARNESSES.md +++ b/agentctl/HARNESSES.md @@ -9,14 +9,14 @@ Adapter changes that accompany a bump must therefore also work with the previous Implementation: [adapters](src/harness), [terminal runtime](src/sessions/runtime/tmux.rs), [Session service](src/sessions/service.rs). Harness pins: [self-dev](examples/self-dev/Dockerfile), -[minimal](examples/minimal/Dockerfile) (Claude Code only) and [worktree](../sandbox/examples/worktree/Dockerfile); +[minimal](examples/minimal/Dockerfile) (Claude Code only) and [worktree](../sandbox/core/examples/worktree/Dockerfile); update them together. ## Upgrade test plan -Install the current platform with `make user-install` from `src/experimental`; it replaces and restarts `agentd`, -and refuses while any Session reports `Working` (#20871, #20872), so archive or delete earlier test Sessions first. -Test the self-dev image built from the branch: from `agent/examples/self-dev`, `agentctl apply --variant nested +Install the current platform with `make user-install` from the repository root; it replaces and restarts `agentd`, +and refuses while any Session reports `Working` (Altinn/altinn-studio#20871, Altinn/altinn-studio#20872), so archive or delete earlier test Sessions first. +Test the self-dev image built from the branch: from `agentctl/examples/self-dev`, `agentctl apply --variant nested --env-file --wait` builds it with both harnesses and fits inside another Agent. Keep the env file outside the checkout. Use fresh Session names and confirm `claude --version` and `codex --version` in the Sandbox; testing an existing Sandbox does not prove the rebuilt image works. @@ -42,15 +42,15 @@ on an Agent built with the previous pin. | -------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Create with an initial prompt | Both | One submission and answer; a daemon restart does not replay the prompt. A failed launch may lose the initial prompt and recover with an empty conversation. | | Create without a prompt, then immediately `prompt --wait` | Both | Input submits without manual Enter. Repeat several times to expose startup races. Codex readiness depends on its `› ` composer and the truncated session-ID pane title. | -| Short, long, multiline, XML-shaped and literal request-heading input | Both | `turns` preserves the complete operator input and shows no harness-injected text as operator input (#20870). | +| Short, long, multiline, XML-shaped and literal request-heading input | Both | `turns` preserves the complete operator input and shows no harness-injected text as operator input (Altinn/altinn-studio#20870). | | Prompt again after completion, including identical text | Both | Waits for one more completed turn, ignoring previous completions. | | Prompt during an active tool call, including identical text | Both | Input appears in `turns`; waiting follows work observed during settling, but does not demand an extra turn when input is absorbed into the current one. | | Prompt while a background command runs, until it finishes | Claude Code | The wait ends with the operator's turn; the completion notification's own turn does not end a later wait early. | | Tool success and tool failure | Both | STATE reflects activity; tool completion alone does not complete a turn; `turns` marks only the failed call. | -| Permission prompt and `AskUserQuestion`, approved and denied | Claude Code | While blocked, STATE is `WaitingForInput` and `activity.turns` does not advance; answering or approving completes the turn. A denial is an interruption (#20869). Not tested for Codex, which launches without approvals. | -| Interruption during a tool call, then another prompt | Both | Codex reports `Interrupt`, which releases the wait. Known gap (#20869): Claude Code has no interrupt hook, so STATE stays `Working` and a wait times out. The next prompt works in both. | +| Permission prompt and `AskUserQuestion`, approved and denied | Claude Code | While blocked, STATE is `WaitingForInput` and `activity.turns` does not advance; answering or approving completes the turn. A denial is an interruption (Altinn/altinn-studio#20869). Not tested for Codex, which launches without approvals. | +| Interruption during a tool call, then another prompt | Both | Codex reports `Interrupt`, which releases the wait. Known gap (Altinn/altinn-studio#20869): Claude Code has no interrupt hook, so STATE stays `Working` and a wait times out. The next prompt works in both. | | Model error reported through `StopFailure` | Claude Code | Create with an unknown `--model`. The completion report ends the wait, but does not imply a successful model response; inspect `turns`. | -| Model error without a completion report | Codex | Create with an unknown `--model`. Codex shows the API error without a turn ending, so the wait times out and STATE stays `Working` (#20872); inspect the Session and recover manually. | +| Model error without a completion report | Codex | Create with an unknown `--model`. Codex shows the API error without a turn ending, so the wait times out and STATE stays `Working` (Altinn/altinn-studio#20872); inspect the Session and recover manually. | | Short completion timeout | Both | Queuing, input readiness and delivery finish before the completion timeout starts. A timeout reports that the prompt was submitted; inspect turns before retrying. The next prompt contains no leftover draft. | | Idle-stop and resume, before and after the first turn | Both | After 30 unattached, quiet minutes the Session is Idle. An untouched Session remains usable; an established conversation resumes with its history. | | Archive mid-turn, then unarchive and resume | Both | The harness stops once the turn ends; `create` or `attach` after unarchive resumes the conversation. | diff --git a/agentctl/README.md b/agentctl/README.md index cd48982..e00f8e3 100644 --- a/agentctl/README.md +++ b/agentctl/README.md @@ -13,20 +13,37 @@ The main goals are: - host APIs that work on Linux, macOS and Windows while initially materializing Linux Sandboxes; and - a Sandbox layer reusable by CI runners and other isolated workloads that do not depend on Agent concepts. +## Install + +Install the released `agentctl` and `agentd` on Linux or macOS, then log in to Claude Code: + +```sh +curl -fsSL https://raw.githubusercontent.com/digdir/digdir-agents/main/agentctl/install.sh | sh +agentctl claude login +``` + +Windows additionally requires the `HypervisorPlatform` optional feature. Install from PowerShell, then open a new +PowerShell window so the updated user `PATH` takes effect: + +```powershell +irm https://raw.githubusercontent.com/digdir/digdir-agents/main/agentctl/install.ps1 | iex +``` + +Update an installation with `agentctl self update`. + ## Development -Run `make help` from this directory for the supported development commands. `make user-install` builds, packages and -installs `agentctl` and `agentd` for the current user. On Windows without Make, run `.\make-user-install.ps1` for the +Run `make help` at the repository root for the supported development commands. `make user-install` builds, packages and +installs `agentctl` and `agentd` for the current user. On Windows without Make, run `.\agentctl\make-user-install.ps1` for the same build, package and installation flow. -Maintainers updating the Microsandbox or libkrunfw forks should follow the -[downstream maintenance runbook](MICROSANDBOX.md). +Maintainers updating the Microsandbox pin should follow [MICROSANDBOX.md](../sandbox/MICROSANDBOX.md). User-visible changes are recorded in [CHANGELOG.md](CHANGELOG.md), which covers the whole stack and provides the -release notes for each `experimental-agent/v*` release. +release notes for each `agentctl/v*` release. -The Agent database and local protocol are intentionally clean-slate while this code is experimental. Breaking schema -changes require stopping `agentd` and removing the configured Agent home rather than migrating old state. +Upgrading from a released version migrates the Agent database, so Agents and Sessions carry over. State created by +unreleased development builds is not migrated. ## Architecture @@ -120,7 +137,7 @@ runtime must establish the common interface before one is introduced. ## Images, home and harnesses -See the [harness compatibility test plan](agent/HARNESSES.md) when updating harness installations. +See the [harness compatibility test plan](HARNESSES.md) when updating harness installations. Agent images own installed tools and optional workspace initialization. Repository checkouts are persistent runtime data beneath `/home/agent/code`; they are not declared, updated or deleted by the Agent controller. Sessions may clone diff --git a/agentctl/changelog.sh b/agentctl/changelog.sh index 42d7a14..725119b 100755 --- a/agentctl/changelog.sh +++ b/agentctl/changelog.sh @@ -1,8 +1,8 @@ #!/bin/sh -# Changelog tooling for the experimental Agent platform. +# Changelog tooling for the Agent platform. # -# One changelog, src/experimental/CHANGELOG.md, covers the whole stack: the `agentctl` and `agentd` -# binaries published by the `experimental-agent/v*` tag, and the Agent images they work with. It +# One changelog, agentctl/CHANGELOG.md, covers the whole stack: the `agentctl` and `agentd` +# binaries published by the `agentctl/v*` tag, and the Agent images they work with. It # follows Keep a Changelog 1.1.0 and Semantic Versioning 2.0.0. The script has no dependencies # beyond a POSIX shell, coreutils, awk and git. # diff --git a/agentctl/changelog_test.sh b/agentctl/changelog_test.sh index 629ea0e..9e4a5f8 100755 --- a/agentctl/changelog_test.sh +++ b/agentctl/changelog_test.sh @@ -84,7 +84,7 @@ header() { cat <<'HEADER' # Changelog -All notable changes to the experimental Agent platform will be documented in this file. +All notable changes to the Agent platform will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). @@ -679,11 +679,11 @@ assert_message 'extract rejects a dated section with no content' 1 'is empty' \ # -------------------------------------------------------- check-unreleased --- REPOSITORY="${WORK}/repository" -mkdir -p "${REPOSITORY}/src/experimental" +mkdir -p "${REPOSITORY}/agentctl" git -C "${REPOSITORY}" init --quiet git -C "${REPOSITORY}" config user.email changelog-test@example.com git -C "${REPOSITORY}" config user.name 'Changelog Test' -tracked="${REPOSITORY}/src/experimental/CHANGELOG.md" +tracked="${REPOSITORY}/agentctl/CHANGELOG.md" git -C "${REPOSITORY}" commit --quiet --allow-empty -m 'empty' empty_base="$(git -C "${REPOSITORY}" rev-parse HEAD)" diff --git a/agentctl/examples/minimal/README.md b/agentctl/examples/minimal/README.md index 553e4be..2109d9b 100644 --- a/agentctl/examples/minimal/README.md +++ b/agentctl/examples/minimal/README.md @@ -1,14 +1,14 @@ # Minimal Agent This manifest-secret-free example exercises the core Agent lifecycle with mediated Claude Code authentication and one -or more persistent tmux sessions. It does not require a `.env` file or expose GitHub and -Altinn Studio secrets to the network mediator. Its small local Dockerfile contains only the tools needed for this +or more persistent tmux sessions. It does not require a `.env` file or expose secrets to +the network mediator. Its small local Dockerfile contains only the tools needed for this flow on the multi-platform Ubuntu 26.04 LTS base, and its layered root filesystem keeps the smoke-test sandbox capacity-efficient. Sessions start in the platform's stable `/home/agent/code` workspace root; this example is intentionally repository-free and uses the Sandbox Provider's backend init instead of an image entrypoint. A builder that needs a boot-time checkout should use an -image init/entrypoint like the published `agents/` images; the self-development example offers both a boot-time clone and a -bind-mounted host checkout. Sessions can instead clone repositories on demand when their +image init or entrypoint, like the self-development example, which offers both a boot-time clone and a bind-mounted +host checkout. Sessions can instead clone repositories on demand when their Agent declares a suitable mediated secret. It is not intended for running Docker inside the Agent. ```sh diff --git a/agentctl/examples/minimal/agent.yaml b/agentctl/examples/minimal/agent.yaml index 26e2d45..edc3c88 100644 --- a/agentctl/examples/minimal/agent.yaml +++ b/agentctl/examples/minimal/agent.yaml @@ -1,7 +1,7 @@ apiVersion: agents.platform/v1alpha1 kind: Agent metadata: - name: altinn-studio + name: minimal spec: sandbox: image: diff --git a/agentctl/examples/self-dev/Dockerfile b/agentctl/examples/self-dev/Dockerfile index dbae420..1d2c306 100644 --- a/agentctl/examples/self-dev/Dockerfile +++ b/agentctl/examples/self-dev/Dockerfile @@ -167,11 +167,6 @@ RUN systemctl mask ssh.service ssh.socket \ # for concurrent harnesses. The kernel pid limit remains the Sandbox boundary. # The guest also has no udev to activate hvc0's device unit, so its generated # serial login would otherwise wait 90 seconds at boot. -# -# The kernel's inotify defaults suit one interactive desktop, not a guest running -# concurrent harnesses, nested Agents and build tooling, each with its own file -# watchers. These values match the ones the self-hosted CI runners raise in -# src/ci/github-runner/run.sh. RUN install -d /etc/systemd/system/init.scope.d \ && printf '[Scope]\nTasksMax=infinity\n' >/etc/systemd/system/init.scope.d/50-agent-tasks.conf \ && install -d /usr/lib/sysctl.d \ diff --git a/agentctl/examples/self-dev/README.md b/agentctl/examples/self-dev/README.md index 1bece4c..63dbbcc 100644 --- a/agentctl/examples/self-dev/README.md +++ b/agentctl/examples/self-dev/README.md @@ -1,19 +1,19 @@ # Agent platform self-development Agent -This Agent develops the Agent platform itself, under `src/experimental`. +This Agent develops the Agent platform itself: `agentctl/`, `sandbox/` and the Rust workspace in digdir-agents. | Variant | Checkout | Resources | | --- | --- | --- | -| default (`agent.yaml`) | Fresh `Altinn/altinn-studio` clone made at boot | Normal | +| default (`agent.yaml`) | Fresh `digdir/digdir-agents` clone made at boot | Normal | | `nested` | Fresh clone | Reduced to fit inside the default Agent | | `worktree` | Current host checkout mounted read-write | Normal | Every variant builds the directory's `Dockerfile` locally. Self-development images are not published to GHCR. ```sh -make -C src/experimental user-install +make user-install agentctl claude login -cd src/experimental/agent/examples/self-dev +cd agentctl/examples/self-dev mkdir -p ~/.agent cp .env.sample ~/.agent/self-dev.env agentctl apply --env-file ~/.agent/self-dev.env --wait diff --git a/agentctl/examples/self-dev/agent.worktree.yaml b/agentctl/examples/self-dev/agent.worktree.yaml index 3850774..167282e 100644 --- a/agentctl/examples/self-dev/agent.worktree.yaml +++ b/agentctl/examples/self-dev/agent.worktree.yaml @@ -9,8 +9,8 @@ spec: sandbox: mounts: - type: bind - source: ../../../../.. - target: /home/agent/code/altinn-studio + source: ../../.. + target: /home/agent/code/digdir-agents readOnly: false - type: tmpfs target: /tmp diff --git a/agentctl/examples/self-dev/instructions.md b/agentctl/examples/self-dev/instructions.md index 82f8beb..7f324aa 100644 --- a/agentctl/examples/self-dev/instructions.md +++ b/agentctl/examples/self-dev/instructions.md @@ -1,21 +1,21 @@ # Agent platform self-development Agent -You develop the experimental agent platform under `src/experimental` in the checkout at -`/home/agent/code/altinn-studio`. Never delete, reset or reclone that directory. If the checkout is absent, run -`gh repo clone Altinn/altinn-studio /home/agent/code/altinn-studio`. +You develop the agent platform in the checkout at `/home/agent/code/digdir-agents`: `agentctl/`, `sandbox/` and the +Rust workspace at its root. Never delete, reset or reclone that directory. If the checkout is absent, run +`gh repo clone digdir/digdir-agents /home/agent/code/digdir-agents`. Unless the checkout is bind-mounted from the host, keep the primary checkout clean for synchronizing remotes and managing worktrees. Do each task in its own Git worktree under `/home/agent/code/.worktrees/`, starting new work from the current `origin/main`. Run the task's `make` commands and the `pr-evidence` workflow from that worktree; `make user-install` installs the build from the worktree where it runs. -If `mount | grep altinn-studio` shows that the checkout is bind-mounted from the host, treat it as the task's existing +If `mount | grep digdir-agents` shows that the checkout is bind-mounted from the host, treat it as the task's existing worktree and work on its current branch. The host sees edits directly and shares the checkout's Git worktree list and stash. Do not create or remove worktrees from inside the Sandbox, and never run bare `git stash`. -Read `src/experimental/AGENTS.md` first. Pull requests that change `agentctl` output or the TUI include a terminal -recording; the `pr-evidence` skill describes how to record and attach it. `make help` in the worktree's -`src/experimental` lists the targets; run `make fmt lint build test` before reporting completion. `make test-e2e` and +Read `agentctl/AGENTS.md` first. Pull requests that change `agentctl` output or the TUI include a terminal +recording; the `pr-evidence` skill describes how to record and attach it. `make help` at the worktree's root +lists the targets; run `make fmt lint build test` before reporting completion. `make test-e2e` and `make user-install` work here too: the Sandbox has `/dev/kvm` and Podman. Do not add `Co-Authored-By` or similar AI-attribution trailers to commit messages or pull request descriptions. @@ -28,7 +28,7 @@ printf '%s\n' "$AGENT_CLAUDE_ACCESS_TOKEN" | agentctl claude login --from-stdin agentctl codex login --from-stdin < ~/.codex/auth.json printf 'GITHUB_TOKEN=%s\nGIT_USER_NAME=%s\nGIT_USER_EMAIL=%s\n' \ "$GITHUB_TOKEN" "$GIT_USER_NAME" "$GIT_USER_EMAIL" > ~/nested.env -cd altinn-studio/src/experimental/agent/examples/self-dev +cd digdir-agents/agentctl/examples/self-dev agentctl apply --variant nested --env-file ~/nested.env ``` diff --git a/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md b/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md index ea1bf35..e1ca9b0 100644 --- a/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md +++ b/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md @@ -45,7 +45,7 @@ From the artifact directory, use local image references in the PR body; `gh --at those references to hosted URLs. Pass one `--attach` per file. For example, after pushing the branch: ```sh -gh pr create --repo Altinn/altinn-studio --base main --head \ +gh pr create --repo digdir/digdir-agents --base main --head \ --title 'fix: ...' --body-file pr-body.md --attach ./result.gif gh pr edit --attach ./result.gif ``` diff --git a/agentctl/examples/self-dev/workspace-init.sh b/agentctl/examples/self-dev/workspace-init.sh index 78aadc3..df712a3 100644 --- a/agentctl/examples/self-dev/workspace-init.sh +++ b/agentctl/examples/self-dev/workspace-init.sh @@ -1,13 +1,13 @@ #!/bin/sh set -eu -destination=${AGENT_WORKSPACE_DESTINATION:-/home/agent/code/altinn-studio} +destination=${AGENT_WORKSPACE_DESTINATION:-/home/agent/code/digdir-agents} if [ -d "$destination/.git" ]; then exit 0 fi -repository=${AGENT_WORKSPACE_REPOSITORY:-Altinn/altinn-studio} +repository=${AGENT_WORKSPACE_REPOSITORY:-digdir/digdir-agents} parent=${destination%/*} mkdir -p "$parent" diff --git a/agentctl/install.ps1 b/agentctl/install.ps1 index 799f35f..6bc9b01 100644 --- a/agentctl/install.ps1 +++ b/agentctl/install.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = "Stop" -$Repository = if ($env:AGENT_GITHUB_REPOSITORY) { $env:AGENT_GITHUB_REPOSITORY } else { "Altinn/altinn-studio" } +$Repository = if ($env:AGENT_GITHUB_REPOSITORY) { $env:AGENT_GITHUB_REPOSITORY } else { "digdir/digdir-agents" } $Version = $env:AGENT_VERSION $InstallMode = if ($env:AGENT_INSTALL_MODE) { $env:AGENT_INSTALL_MODE } else { "managed" } $InstallRoot = if ($env:AGENT_INSTALL_ROOT) { $env:AGENT_INSTALL_ROOT } else { Join-Path $env:LOCALAPPDATA "Agent" } @@ -51,11 +51,11 @@ if (-not $Version) { $Page = 1 do { $Releases = Invoke-RestMethod "https://api.github.com/repos/$Repository/releases?per_page=100&page=$Page" - $Release = $Releases | Where-Object { $_.tag_name -like "experimental-agent/v*" } | Select-Object -First 1 + $Release = $Releases | Where-Object { $_.tag_name -like "agentctl/v*" } | Select-Object -First 1 $Page++ } while (-not $Release -and $Releases.Count -eq 100) - if (-not $Release) { throw "Could not resolve the latest experimental Agent release" } - $Version = $Release.tag_name.Substring("experimental-agent/".Length) + if (-not $Release) { throw "Could not resolve the latest agentctl release" } + $Version = $Release.tag_name.Substring("agentctl/".Length) } if (-not $Version.StartsWith("v")) { $Version = "v$Version" } @@ -78,7 +78,7 @@ try { $Checksum = if ($env:AGENT_LOCAL_ARCHIVE_SHA256) { $env:AGENT_LOCAL_ARCHIVE_SHA256 } else { "$LocalArchive.sha256" } } else { $Archive = "agent-$Platform.tar.gz" - $Base = "https://github.com/$Repository/releases/download/experimental-agent/$Version" + $Base = "https://github.com/$Repository/releases/download/agentctl/$Version" Invoke-WebRequest "$Base/$Archive" -OutFile (Join-Path $Temporary $Archive) $Checksum = Join-Path $Temporary "$Archive.sha256" Invoke-WebRequest "$Base/$Archive.sha256" -OutFile $Checksum diff --git a/agentctl/install.sh b/agentctl/install.sh index 54e4a67..91007d6 100755 --- a/agentctl/install.sh +++ b/agentctl/install.sh @@ -2,7 +2,7 @@ set -eu umask 077 -repository="${AGENT_GITHUB_REPOSITORY:-Altinn/altinn-studio}" +repository="${AGENT_GITHUB_REPOSITORY:-digdir/digdir-agents}" version="${AGENT_VERSION:-}" install_mode="${AGENT_INSTALL_MODE:-managed}" bin_directory="${AGENT_INSTALL_DIR:-${HOME}/.local/bin}" @@ -55,14 +55,14 @@ if [ -z "${version}" ]; then while [ -z "${version}" ]; do releases="$(curl -fsSL "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}")" version="$(printf '%s' "${releases}" \ - | sed -n 's/.*"tag_name": "experimental-agent\/\(v[^"]*\)".*/\1/p' \ + | sed -n 's/.*"tag_name": "agentctl\/\(v[^"]*\)".*/\1/p' \ | head -n 1)" [ "${releases}" != "[]" ] || break page=$((page + 1)) done fi if [ -z "${version}" ]; then - echo "Could not resolve the latest experimental Agent release" >&2 + echo "Could not resolve the latest agentctl release" >&2 exit 1 fi case "${version}" in @@ -89,7 +89,7 @@ if [ "${install_mode}" = standalone ] || [ ! -d "${target}" ]; then cp "${AGENT_LOCAL_ARCHIVE_SHA256:-${local_archive}.sha256}" "${temporary}/${archive}.sha256" else archive="agent-${platform}.tar.gz" - base="https://github.com/${repository}/releases/download/experimental-agent/${version}" + base="https://github.com/${repository}/releases/download/agentctl/${version}" curl -fsSL "${base}/${archive}" -o "${temporary}/${archive}" curl -fsSL "${base}/${archive}.sha256" -o "${temporary}/${archive}.sha256" fi diff --git a/agentctl/src/bin/agentctl/self_update.rs b/agentctl/src/bin/agentctl/self_update.rs index a4a47b6..cf9acd9 100644 --- a/agentctl/src/bin/agentctl/self_update.rs +++ b/agentctl/src/bin/agentctl/self_update.rs @@ -13,7 +13,7 @@ use agent::{ use super::CommandResult; -const DEFAULT_REPOSITORY: &str = "Altinn/altinn-studio"; +const DEFAULT_REPOSITORY: &str = "digdir/digdir-agents"; const DAEMON_STOP_TIMEOUT: Duration = Duration::from_secs(65); const LIFECYCLE_REQUEST_TIMEOUT: Duration = Duration::from_secs(65); const TARGET_VERIFY_TIMEOUT: Duration = Duration::from_secs(75); diff --git a/agentctl/src/sandbox/platform/linux_ssh.rs b/agentctl/src/sandbox/platform/linux_ssh.rs index bf90325..d7e572f 100644 --- a/agentctl/src/sandbox/platform/linux_ssh.rs +++ b/agentctl/src/sandbox/platform/linux_ssh.rs @@ -219,7 +219,7 @@ fn render_unit() -> String { format!( "[Unit]\n\ Description=OpenSSH server for Agent access on the guest loopback\n\ - Documentation=https://github.com/Altinn/altinn-studio/tree/main/src/experimental\n\ + Documentation=https://github.com/digdir/digdir-agents/tree/main/agentctl\n\ ConditionPathExists={HOST_KEY}\n\ After=network.target\n\ \n\ diff --git a/agentctl/src/upgrade.rs b/agentctl/src/upgrade.rs index e5373c4..f36acb6 100644 --- a/agentctl/src/upgrade.rs +++ b/agentctl/src/upgrade.rs @@ -281,7 +281,7 @@ pub async fn stage_release(paths: &InstallPaths, release: Release) -> Result Result { .map_err(|error| Error::Daemon(format!("decode Agent releases: {error}")))?; if let Some(version) = releases .iter() - .find_map(|release| release.tag_name.strip_prefix("experimental-agent/")) + .find_map(|release| release.tag_name.strip_prefix("agentctl/")) { return normalize_version(version); } @@ -639,7 +639,7 @@ async fn latest_release(repository: &str) -> Result { break; } } - Err(Error::Invalid("GitHub has no experimental Agent release".into())) + Err(Error::Invalid("GitHub has no agentctl release".into())) } async fn download(url: &str, path: &Path) -> Result<(), Error> { diff --git a/agentctl/tests/manifest.rs b/agentctl/tests/manifest.rs index a0df23a..ca41a09 100644 --- a/agentctl/tests/manifest.rs +++ b/agentctl/tests/manifest.rs @@ -98,7 +98,7 @@ fn decodes_the_minimal_manifest() { assert_eq!(agent.api_version, API_VERSION); assert_eq!(agent.kind, KIND); - assert_eq!(agent.metadata.name, "altinn-studio"); + assert_eq!(agent.metadata.name, "minimal"); assert_eq!(agent.spec.sandbox.platform.os, "linux"); assert_eq!(agent.spec.sandbox.platform.architecture, None); assert_eq!(agent.spec.sandbox.retention_policy, None); diff --git a/sandbox/AGENTS.md b/sandbox/AGENTS.md new file mode 100644 index 0000000..6f0ee7a --- /dev/null +++ b/sandbox/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md + +The Sandbox crates here are part of the agent platform. Its architecture rules and development targets, which cover +these crates, are in [../agentctl/AGENTS.md](../agentctl/AGENTS.md). Updating the Microsandbox pin is described in +[MICROSANDBOX.md](MICROSANDBOX.md). diff --git a/sandbox/MICROSANDBOX.md b/sandbox/MICROSANDBOX.md index 5f122dc..1595035 100644 --- a/sandbox/MICROSANDBOX.md +++ b/sandbox/MICROSANDBOX.md @@ -1,402 +1,52 @@ -# Microsandbox downstream maintenance +# Microsandbox pin -This runbook describes how to maintain the Microsandbox and libkrunfw forks used by -`sandbox-microsandbox`. It covers upstream synchronization, the Digdir patch queues, downstream -runtime publication and the exact source and artifact pins in this repository. +`sandbox-microsandbox` builds on the Digdir fork of Microsandbox, +[digdir/microsandbox](https://github.com/digdir/microsandbox), and on the host runtime published with each of its +releases. How the fork is synchronized with upstream and how runtimes are released is in its +[MAINTAINING-digdir.md](https://github.com/digdir/microsandbox/blob/main-digdir/MAINTAINING-digdir.md). This document +covers the pin in this repository. -The repositories have different release units. A Microsandbox source update is not complete until -the matching host runtime and guest agent have been published and Altinn Studio pins both the source -commit and the published artifact digests. +## What is pinned -## Repository and branch roles +- The `microsandbox*` Git dependencies in the root `Cargo.toml`, all at the same revision, and their resolution in + `Cargo.lock`. +- The SHA-256 digest of every supported host runtime bundle, in the digest table in + `microsandbox/src/client.rs`. -| Repository | Upstream mirror | Downstream integration | Consumer pin | -| --------------------------------------------------------------------- | --------------- | ------------------------------ | ------------------------------------------------------- | -| `superradcompany/microsandbox` forked as `martinothamar/microsandbox` | `main` | `main-digdir` | Root `Cargo.toml` and `Cargo.lock` | -| `superradcompany/libkrunfw` forked as `martinothamar/libkrunfw` | `krunfw` | `main-digdir` | Microsandbox `vendor/libkrunfw` submodule | -| `Altinn/altinn-studio` | Not applicable | The current development branch | Microsandbox source revision and runtime SHA-256 values | +The SDK only launches a runtime of exactly its own version, so the source revision and the runtime digests always +come from the same Digdir release. Pin only tagged revisions: a `v` runtime release tag, or a +`v-source.` tag that reuses an already published runtime. The tags keep the pinned commits +reachable after the fork's `main-digdir` is rewritten. -The mirror branches contain no Digdir changes. Update them with fast-forward-only pushes from their -corresponding upstream branches. Synchronizing a mirror branch does not select the next downstream -base: `main-digdir` is based on a stable Microsandbox release tag, not an arbitrary upstream `main` -commit. The `0.6.9-digdir.*` line predates this rule and sits two upstream commits past `v0.6.9`; the -first synchronization that follows this runbook moves the base onto the exact tag. +Consumers of the sandbox crates, such as Altinn Studio's GitHub runner coordinator, pin a revision of this +repository and download the matching runtime bundle themselves. -The `main-digdir` branches are downstream patch queues. Rebuild them on temporary synchronization -branches and review each patch when moving to a new upstream base. Do not merge an upstream release -into a patch queue: the resulting conflict-resolution merge hides which downstream patches remain -necessary and makes later synchronization harder. +## Updating -## Required invariants +Update only to a runtime release that is complete and verified, or to a source tag whose reuse of the runtime is +recorded in the fork. -- Before rewriting a published downstream branch, create an immutable tag for every commit still - pinned by a consumer. This keeps older checkouts reproducible and prevents Git hosting from - garbage-collecting the pinned commit. Today the only consumer is the `src/experimental/` stack: - the current development branch plus the `Cargo.lock` of every `experimental-agent/v*` release - tag, since Cargo fetches Git dependencies by revision. Revisit this list when `src/experimental/` - moves to its own repositories. -- The Microsandbox workspace version uses `-digdir.`. -- `digdir-v` points at the exact Microsandbox commit from which the downstream - runtime assets were built. -- All internal Microsandbox crates use the same exact workspace version, and `Cargo.lock` agrees. -- The Microsandbox commit pins the intended private libkrunfw commit through - `vendor/libkrunfw`. -- The `msb` host executable, embedded `agentd` and libkrunfw artifact come from the tagged - Microsandbox runtime release revision. Every published `msb` is built with the `embed-binaries` - feature: without it `msb --version` still works, but no guest agent is embedded and every sandbox - start fails. -- The embedded SDK launches only a runtime whose embedded version equals its own workspace version - exactly, so the SDK and the published `msb` always come from the same Digdir revision. -- Altinn Studio normally pins that same release revision. A source-only descendant may instead use - an immutable `digdir-source-v-` tag after verifying that it needs no new - host runtime, guest agent, firmware or protocol behavior (see the check in step 7). A source-only - descendant keeps the runtime tag's workspace version unchanged, because of the exact version match - above. Never pin an untagged follow-up commit. -- Altinn Studio records the SHA-256 digest of every supported host runtime bundle before the new - source revision is merged. -- Existing release tags and assets are immutable. A correction gets a new Digdir revision. +1. Update every `microsandbox*` revision in the root `Cargo.toml` together, and regenerate `Cargo.lock`. Every + Git-sourced Microsandbox package must resolve to the same revision and version. +2. For a runtime release, replace the digest table in `microsandbox/src/client.rs` with the digests from the + release's `checksums.sha256`. A source tag keeps the runtime version and digests unchanged. +3. Search for every remaining reference to the previous pin, rather than relying on a list of files: -The release dependency flows in one direction: - -```text -stable Microsandbox tag - + -Digdir libkrunfw patch queue - + -Digdir Microsandbox patch queue - | - v -digdir-v source tag and runtime assets - | - v -Altinn Cargo revision, lockfile and runtime SHA-256 pins -``` - -## Review gates - -Tags and release assets are immutable and a published `main-digdir` is shared, so human review -belongs on the fork before the tag, not on the Altinn pull request. - -1. **After triage (step 3).** Review the triage record before the rebase starts. Every wrong `drop` - or misdirected `adapt` decision found later costs a rebase pass. -2. **Before the tag (step 6).** Review the `sync/digdir-X.Y.Z` pull request on the fork: the - `range-diff` against the triage record, the complete tree diff from the upstream tag and the test - results. Approval authorizes the `digdir-v*` tag. - -A synchronization pull request rewrites history onto a new base, so GitHub marks it as conflicting -and never runs its `pull_request` checks. Start `check-digdir.yml` on the branch with -`gh workflow run check-digdir.yml --ref sync/digdir-X.Y.Z` after every push. Once the pull request -is approved, merge it by moving the integration branch, never with a merge, squash or rebase -button: `git push --force-with-lease=main-digdir: origin sync/digdir-X.Y.Z:main-digdir`. -GitHub then marks the pull request as merged. Do the same for libkrunfw first, because the -Microsandbox submodule pins its rewritten commit. - -The fork pull request description is a short summary for the reviewer: which triage decisions -changed during the rebase and why, what is not covered by tests, and what was verified where. Do -not paste the triage record, process notes, or anything the reviewer can read from the diff. - -Text written to the forks, in commit messages, pull request titles and descriptions, and code or -workflow comments, must never contain `#` references or GHSA identifiers. GitHub resolves a -bare `#123` in a fork against the upstream repository and records a cross-reference in that upstream -issue's or pull request's history, which is noise for the upstream maintainers. Cite an upstream -change by its short commit SHA, and refer to triage rows as "row 3", never "#3". Before pushing or -opening the pull request, check with `git log --format=%B ..HEAD | grep -E '#[0-9]+|GHSA'` -and the same grep over the description. - -The Altinn pull request (step 7) only needs a check that the revisions, lockfile and digests agree -with the published release and that first-run installation was exercised from an empty home. - -## 1. Refresh the upstream mirrors - -Configure `origin` as the personal fork and `upstream` as the Super Rad Company repository. Fetch -branches and tags before comparing histories. - -For Microsandbox: - -```bash -git fetch origin -git fetch upstream --tags -git switch main -git merge --ff-only upstream/main -git push origin main -``` - -For libkrunfw, whose upstream default branch is `krunfw`: - -```bash -git fetch origin -git fetch upstream --tags -git switch krunfw -git merge --ff-only upstream/krunfw -git push origin krunfw -``` - -Stop if either fast-forward fails. A mirror branch with fork-only commits must be inspected and -repaired rather than merged. - -## 2. Select the stable Microsandbox base - -Use the latest non-prerelease GitHub release unless a specific version has been selected for a -documented reason. Verify the release tag and record its commit. Do not use upstream `main` merely -because the mirror has been refreshed. - -```bash -target_msb_tag=vX.Y.Z -git rev-parse "$target_msb_tag^{commit}" -``` - -Inspect the libkrunfw submodule revision selected by that release: - -```bash -git ls-tree "$target_msb_tag" vendor/libkrunfw -git show "$target_msb_tag:.gitmodules" -``` - -That submodule revision is the libkrunfw base for a strict stable-release synchronization. Newer -commits on upstream libkrunfw are a separate upgrade decision and must not be included implicitly. - -## 3. Triage the upstream changes - -Most upstream commits do not affect this stack. Altinn Studio consumes the Rust SDK, the crates it -links (`image`, `network`, `filesystem` and, transitively, the rest of `crates/`), the guest agent, -the firmware submodule and the release workflow that produces the host artifacts. Documentation, -the other language SDKs, examples and package publishing are out of scope. Reduce the upstream -range to the commits that matter before touching the patch queue: - -```bash -old_msb_base=$(git merge-base origin/main-digdir "$target_msb_tag") -git log --oneline --no-merges "$old_msb_base".."$target_msb_tag" -- \ - crates sdk/rust vendor/libkrunfw Cargo.toml Cargo.lock \ - '.github/workflows/release*.yml' scripts/ci \ - ':!**/*.md' ':!crates/*/examples' ':!crates/*/benches' -``` - -The release workflow paths are in scope because they decide how the published binaries are built. -An upstream change to the build container, linker baseline or artifact validation does not make -the downstream workflow fail; it silently makes the downstream artifacts differ from upstream's. -The 0.6.18 synchronization missed the upstream move to a glibc 2.28 baseline for exactly this -reason. - -Then narrow further to the paths the downstream patches own, which forecasts the rebase conflicts -and reveals patches that upstream may have made redundant: - -```bash -git diff --name-only "$old_msb_base" origin/main-digdir -- crates sdk/rust | - xargs git log --oneline --no-merges "$old_msb_base".."$target_msb_tag" -- -``` - -Read each remaining commit and write the result down as a triage record before starting the -rebase. The record is the handoff between triage, rebase and release, which may be done by -different people or in separate sessions. It stays in the sync notes; it is not the pull request -description (see the review gates). It contains: - -- one row per downstream commit: subject, decision (`keep`, `adapt`, `drop`), the upstream commits - that motivate the decision, and for `adapt` the new upstream API or file location to target; -- the upstream commits in the narrowed list that touch no downstream patch but change behavior the - stack relies on, such as protocol, guest agent or firmware changes, or the image catalog semantics - that `sandbox-microsandbox` image removal relies on: `Image::remove_local` removes a manifest - with its last reference and refuses while a runtime pins it, `Image::persist` refreshes a - reference's last use, and `Image::prune_local` keeps pinned images (see `image_cache.rs`); and -- upstream refactors that moved or deleted files a patch touches, since `git rebase` reports those - as delete/modify conflicts and the patch must be re-applied by hand at the new location. - -For every upstream change to the release workflows, decide whether `release-digdir-runtime.yml` -must adopt it; it copies upstream's build steps and inherits none of their later fixes. - -## 4. Rebase the libkrunfw patch queue - -First compare the libkrunfw revision selected by the new tag with the base of the current downstream -queue: - -```bash -git ls-tree "$target_msb_tag" vendor/libkrunfw -git -C vendor/libkrunfw merge-base origin/main-digdir upstream/krunfw -``` - -Compare their trees rather than their commit IDs. An upstream tag can pin a libkrunfw commit that no -upstream branch reaches, for example a pull request head that was later squash-merged; `v0.7.4` -pins `cf4c22b9`, whose tree equals the squash-merged `49862475` on `krunfw`: - -```bash -git -C vendor/libkrunfw diff --quiet && echo identical -``` - -If the pinned commit is not on `upstream/krunfw`, rebase onto the `krunfw` commit with the identical -tree instead, so that the next synchronization's merge-base stays meaningful. - -If the trees are equal to the current base, upstream has not moved the firmware and this step is a -no-op: keep the existing `main-digdir` commit of libkrunfw and continue with step 5. This was the case -for every release from `v0.6.9` through `v0.6.18`; `v0.7.4` moved it. - -Otherwise create a temporary branch from the existing downstream branch. Tag the old consumed tip -before rewriting or moving any published reference. - -```bash -git switch -c sync/libkrunfw-X.Y.Z origin/main-digdir -target_libkrunfw_commit=REPLACE_WITH_RECORDED_COMMIT -old_libkrunfw_base=$(git merge-base origin/main-digdir "$target_libkrunfw_commit") -git rebase --interactive --onto "$target_libkrunfw_commit" "$old_libkrunfw_base" -``` - -During the rebase, retain only the kernel configuration and firmware behavior still required by the -Agent platform. Resolve generated kernel configuration changes deliberately; do not accept an entire -side of a conflict without checking every required option. - -Review the rewritten patch queue: - -```bash -git range-diff \ - "$old_libkrunfw_base"..origin/main-digdir \ - "$target_libkrunfw_commit"..sync/libkrunfw-X.Y.Z -``` - -Run libkrunfw's kernel configuration checks and builds for every supported guest architecture. The -Microsandbox release workflow builds firmware from this submodule, but it is not a substitute for -checking the rewritten libkrunfw commits themselves. - -Push the temporary branch for review. Move `main-digdir` only after every still-consumed commit has -an immutable tag and the new patch queue passes its checks. Use `--force-with-lease` rather than an -unguarded force push if the integration branch must be moved to rewritten history. - -## 5. Rebase the Microsandbox patch queue - -Work on a temporary branch based on the current downstream tip: - -```bash -git switch -c sync/digdir-X.Y.Z origin/main-digdir -git rebase --interactive --onto "$target_msb_tag" "$old_msb_base" -``` - -Apply the triage list from step 3 to every downstream commit. In particular: - -- drop behavior that the selected upstream release now implements; -- adapt patches when upstream provides a new API for the same purpose; -- keep Altinn-specific network control, prepared-root, runtime isolation and runtime publication - behavior separate where possible; -- keep functional patches separate from downstream version bumps and release plumbing; -- drop the previous `chore: bump downstream runtime` commit during the rebase and add a fresh one - at the tip once every functional patch is in place; and -- preserve the ordering between protocol changes, the embedded guest agent and host runtime changes. - -Update `vendor/libkrunfw` to the reviewed private libkrunfw commit from step 4. Do not -update the submodule to the tip of either libkrunfw branch without verifying its ancestry and content. - -Finish with one downstream version such as `X.Y.Z-digdir.1`. Update every internal exact version and -regenerate `Cargo.lock`. The version suffix increments for any correction published from the same -upstream release. - -Update the triage record when a decision changes during the rebase, so that the record and the -final `range-diff` agree. - -Compare the old and new patch queues before publishing: - -```bash -git range-diff \ - "$old_msb_base"..origin/main-digdir \ - "$target_msb_tag"..sync/digdir-X.Y.Z -``` - -Also inspect the complete tree difference from the upstream tag. `range-diff` explains rewritten -commits; it does not reveal an accidentally retained generated file or submodule pointer by itself. - -## 6. Validate and publish the downstream runtime - -Run the Microsandbox repository's focused checks for every touched crate, followed by its workspace -checks. Runtime, networking, filesystem, image and protocol changes require the hardware-backed -integration tests on supported hosts. A compile-only result does not establish that the host and -guest protocol still agree. - -Before tagging, verify all of the following: - -- the workspace and internal crate versions are identical; -- `Cargo.lock` is current; -- `vendor/libkrunfw` is initialized at the committed private revision; -- the downstream release workflow accepts the version and submodule remote; -- the runtime download helpers use the downstream release repository and tag scheme; and -- no build uses an old `agentd`, `msb` or libkrunfw artifact from a local cache. - -Create `digdir-v` at the reviewed source commit. The downstream release workflow -builds and publishes the host bundles, standalone guest agents and libkrunfw artifacts for Linux -x86_64, Linux aarch64, macOS aarch64, Windows x86_64 and Windows aarch64. It only builds and -checksums; it does not start a sandbox on any of them, so the runtime tests above are the only -execution coverage a release gets. Treat a partially -published or failed release as unusable and publish a corrected Digdir revision instead of replacing -assets. - -Download the published checksum manifest and independently verify each runtime bundle. For the -Linux bundles also confirm the glibc baseline by listing the `GLIBC_*` versions the binaries -import; the release workflow's validator gate must have run, but check the artifact itself. Record the -bundle SHA-256 values for Linux x86_64, Linux aarch64, macOS aarch64, Windows x86_64 and Windows -aarch64. If the supported platform matrix changes, update both the downstream release validation and -Altinn Studio's digest table in the same change. - -## 7. Update Altinn Studio - -Only update Altinn Studio after the downstream runtime release is complete and verified, or after a -source-only descendant has been audited as compatible with the already verified runtime release. - -1. Update every `microsandbox*` Git revision together in the root `Cargo.toml`. -2. Regenerate the root `Cargo.lock` and confirm every Git-sourced Microsandbox package resolves to - the same revision and downstream version. -3. Search the repository for every remaining reference to the previous pin. For a runtime release, - replace the previous version, revision and bundle digests. For a source-only update, replace the - source revision but keep the compatible runtime version and bundle digests unchanged. Do not rely - on a list of known files; search for the identifiers themselves: - - ```bash - git grep -n -e '' -e '' + ```sh + git grep -n -e '' -e '' git grep -n -F -f <(printf '%s\n' ) ``` - Runtime-release hits include the runtime bundle digest table in - `sandbox-microsandbox/src/client.rs`, container images that download the runtime bundle (CI fails - on any skew between such a pin and `Cargo.lock`), and comments that name the pinned downstream - version. For a source-only update, verify those runtime references still match the compatible - `digdir-v*` tag. Repeat the source-revision search until it returns only this runbook and changelog - history. -4. Confirm that the Cargo revision is tagged by either the corresponding `digdir-v*` release or an - explicitly runtime-compatible `digdir-source-v*` tag. Start a source-only audit with the complete - diff from the runtime tag: - - ```bash - git diff --stat digdir-v..digdir-source-v- -- . - ``` - - Changes confined to `sdk/rust` are normally source-only. Shared host libraries under `crates/` - may also qualify when the consumer pull request records that the changed production symbols are - reached only from the embedded host SDK, the existing runtime does not execute the changed path, - and protocol and artifact behavior are unchanged. This explicit audit is required because the - repository paths alone do not identify which binary executes shared library code. - - A new Digdir runtime revision is required for changes used by the published `msb`, embedded - `agentd`, libkrunfw or firmware artifacts, or for changes to the host/guest protocol, release - workflow or artifact composition. Tests alone may change without a runtime release when their - non-test code is untouched. - -5. Run the experimental formatting, lint, build and unit-test targets. -6. Run the ignored Microsandbox end-to-end tests on hosts with Docker, Internet access and hardware +4. Run `make fmt lint build test`, and `make test-e2e` on a host with Docker, Internet access and hardware virtualization. -7. Exercise first-run runtime installation from an empty provider home so stale local artifacts - cannot mask a release or checksum error, and separately exercise an upgrade from a provider home - and database populated by the previously pinned version, since migrations only run there. -8. Run `yarn spell:quick` for the changed documentation and source files. +5. Exercise a first-run runtime installation from an empty provider home, so that stale local artifacts cannot hide + a release or checksum error, and an upgrade from a provider home and database populated by the previous pin, + where migrations run. -The Altinn change is internal maintenance unless it changes behavior visible to Agent users. Use the -`skip-changelog` label for internal-only synchronization; otherwise describe the user-visible effect -under `Unreleased` in `CHANGELOG.md`. +A pin update is internal maintenance unless it changes behavior Agent users can see. Use the `skip-changelog` label +for internal-only updates; otherwise describe the effect under `Unreleased` in `agentctl/CHANGELOG.md`. ## Rollback -Rollback is an Altinn pin change, not a mutation of an existing downstream release. Restore the -previous tagged Microsandbox revision, lockfile resolution and matching runtime digest table -together. Do not combine source from one downstream version with runtime artifacts from another. - -Keep the failed downstream source tag and release for diagnosis. Publish a new Digdir revision when -the problem is corrected. - -## Future upstream contributions - -Upstream contribution branches are separate from downstream synchronization. Start them from the -current upstream default branch and cherry-pick one coherent downstream change at a time. Do not -merge those branches back into `main-digdir`; a later stable upstream release brings accepted work -back into the downstream base, where the corresponding patch can be dropped or reduced. +Roll back by restoring the previous tagged revision, `Cargo.lock` resolution and digest table together. Never combine +source from one Digdir version with runtime artifacts from another. diff --git a/sandbox/core/examples/worktree/Dockerfile b/sandbox/core/examples/worktree/Dockerfile index c98f7f9..072aeb2 100644 --- a/sandbox/core/examples/worktree/Dockerfile +++ b/sandbox/core/examples/worktree/Dockerfile @@ -170,7 +170,7 @@ COPY tmpfiles.conf /usr/lib/tmpfiles.d/worktree.conf ENV HOME=/home/agent USER agent -WORKDIR /workspace/altinn-studio +WORKDIR /workspace/digdir-agents ENTRYPOINT ["/usr/lib/systemd/systemd"] CMD ["codex", "--yolo"] diff --git a/sandbox/core/examples/worktree/README.md b/sandbox/core/examples/worktree/README.md index 55ef266..f1804d6 100644 --- a/sandbox/core/examples/worktree/README.md +++ b/sandbox/core/examples/worktree/README.md @@ -2,7 +2,7 @@ Runs Codex or Claude Code directly through the Sandbox SDK in a microVM (Microsandbox), with the current Git worktree bind mounted along with Codex/Claude configuration from the current user's home directory. It exercises the Sandbox -layer alone, without `agentd`; the Agent-layer equivalent is `src/experimental/agent/examples/self-dev`. +layer alone, without `agentd`; the Agent-layer equivalent is `agentctl/examples/self-dev`. Installed tools: - .NET 10 @@ -11,11 +11,10 @@ Installed tools: - nodejs - container tooling: docker with Buildx, kind, kubectl, flux, helm -This should allow the agent to build and use most/all altinn-studio projects. Note that though this protects e.g. the host filesystem, it has permissive network access. Defaults: 4 CPU, 8Gi memory, a 64Gi direct root filesystem and a 4Gi `/tmp`. -Run from `src/experimental`: +Run from the repository root: ```sh cargo run -p sandbox-worktree # Start Codex diff --git a/sandbox/core/examples/worktree/src/main.rs b/sandbox/core/examples/worktree/src/main.rs index f55ea22..5111ca4 100644 --- a/sandbox/core/examples/worktree/src/main.rs +++ b/sandbox/core/examples/worktree/src/main.rs @@ -23,8 +23,8 @@ use sha2::{Digest as _, Sha256}; mod progress; const SANDBOX_HOME: &str = "/home/agent"; -const SANDBOX_REPOSITORY: &str = "/workspace/altinn-studio"; -const SANDBOX_WORKSPACE: &str = "/workspace/altinn-studio"; +const SANDBOX_REPOSITORY: &str = "/workspace/digdir-agents"; +const SANDBOX_WORKSPACE: &str = "/workspace/digdir-agents"; const WORKTREE_ID_HEX_LENGTH: usize = 12; #[derive(Debug, Parser)] @@ -220,7 +220,7 @@ fn worktree_repository() -> Result> { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!( - "{} does not contain the Altinn Studio Rust workspace", + "{} does not contain the digdir-agents Rust workspace", repository.display() ), ) From 35f5ea3b50797c5d08ac275ce8a934db57c6b39f Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 5 Oct 2026 08:46:11 +0200 Subject: [PATCH 5/8] feat(examples): clone the forks and add the changelog skill to self-dev The self-development Agent clones digdir/microsandbox and digdir/libkrunfw beside digdir/digdir-agents, so work that spans the platform and its forks starts from one Agent. gh adds an upstream remote to each fork clone. The changelog skill is adapted from altinn-studio's for agentctl/CHANGELOG.md and agentctl/changelog.sh. --- agentctl/CHANGELOG.md | 4 + agentctl/examples/self-dev/README.md | 9 +- agentctl/examples/self-dev/agent.yaml | 1 + agentctl/examples/self-dev/instructions.md | 7 +- .../self-dev/skills/changelog/SKILL.md | 145 ++++++++++++++++++ .../examples/self-dev/workspace-init.service | 4 +- agentctl/examples/self-dev/workspace-init.sh | 26 ++-- 7 files changed, 179 insertions(+), 17 deletions(-) create mode 100644 agentctl/examples/self-dev/skills/changelog/SKILL.md diff --git a/agentctl/CHANGELOG.md b/agentctl/CHANGELOG.md index 3aff735..9e7b427 100644 --- a/agentctl/CHANGELOG.md +++ b/agentctl/CHANGELOG.md @@ -12,6 +12,10 @@ Agents. The Rust workspace version is a build detail and is not tracked here. ## [Unreleased] +### Added + +- The self-development Agent clones `digdir/microsandbox` and `digdir/libkrunfw` beside `digdir/digdir-agents`, and has the `changelog` skill for writing changelog entries. ([#136](https://github.com/digdir/digdir-agents/pull/136)) + ### Changed - Breaking: `agentctl` is now developed and released in [digdir/digdir-agents](https://github.com/digdir/digdir-agents). Rerun the [installer](https://github.com/digdir/digdir-agents/tree/main/agentctl#install) once; Agents and Sessions carry over, and `agentctl self update` then follows the new releases. ([#136](https://github.com/digdir/digdir-agents/pull/136)) diff --git a/agentctl/examples/self-dev/README.md b/agentctl/examples/self-dev/README.md index 63dbbcc..532ff39 100644 --- a/agentctl/examples/self-dev/README.md +++ b/agentctl/examples/self-dev/README.md @@ -4,9 +4,9 @@ This Agent develops the Agent platform itself: `agentctl/`, `sandbox/` and the R | Variant | Checkout | Resources | | --- | --- | --- | -| default (`agent.yaml`) | Fresh `digdir/digdir-agents` clone made at boot | Normal | -| `nested` | Fresh clone | Reduced to fit inside the default Agent | -| `worktree` | Current host checkout mounted read-write | Normal | +| default (`agent.yaml`) | Fresh `digdir/digdir-agents`, `digdir/microsandbox` and `digdir/libkrunfw` clones made at boot | Normal | +| `nested` | Fresh clones | Reduced to fit inside the default Agent | +| `worktree` | Current host checkout mounted read-write, fresh fork clones | Normal | Every variant builds the directory's `Dockerfile` locally. Self-development images are not published to GHCR. @@ -37,4 +37,5 @@ Ignored local variants such as `agent.mine.yaml` may extend another sibling vari the mounted checkout. Inside a running Agent, `instructions.md` tells the harness how to build, test and run the platform nested, and the -`pr-evidence` skill how to record `agentctl` demonstrations and attach them to pull requests. +`pr-evidence` skill how to record `agentctl` demonstrations and attach them to pull requests, and the `changelog` +skill how to write changelog entries. diff --git a/agentctl/examples/self-dev/agent.yaml b/agentctl/examples/self-dev/agent.yaml index 0642fee..95f84a6 100644 --- a/agentctl/examples/self-dev/agent.yaml +++ b/agentctl/examples/self-dev/agent.yaml @@ -27,6 +27,7 @@ spec: - source: instructions.md skills: - source: skills/pr-evidence + - source: skills/changelog harnesses: - type: claudeCode auth: mediated diff --git a/agentctl/examples/self-dev/instructions.md b/agentctl/examples/self-dev/instructions.md index 7f324aa..85a9416 100644 --- a/agentctl/examples/self-dev/instructions.md +++ b/agentctl/examples/self-dev/instructions.md @@ -2,7 +2,9 @@ You develop the agent platform in the checkout at `/home/agent/code/digdir-agents`: `agentctl/`, `sandbox/` and the Rust workspace at its root. Never delete, reset or reclone that directory. If the checkout is absent, run -`gh repo clone digdir/digdir-agents /home/agent/code/digdir-agents`. +`gh repo clone digdir/digdir-agents /home/agent/code/digdir-agents`. The Microsandbox and libkrunfw forks it builds on +are cloned beside it, at `/home/agent/code/microsandbox` and `/home/agent/code/libkrunfw`, with `upstream` remotes for +their upstream repositories; each fork's `CONTRIBUTING-digdir.md` describes how to change it. Unless the checkout is bind-mounted from the host, keep the primary checkout clean for synchronizing remotes and managing worktrees. Do each task in its own Git worktree under `/home/agent/code/.worktrees/`, starting new work from @@ -14,7 +16,8 @@ worktree and work on its current branch. The host sees edits directly and shares stash. Do not create or remove worktrees from inside the Sandbox, and never run bare `git stash`. Read `agentctl/AGENTS.md` first. Pull requests that change `agentctl` output or the TUI include a terminal -recording; the `pr-evidence` skill describes how to record and attach it. `make help` at the worktree's root +recording; the `pr-evidence` skill describes how to record and attach it. The `changelog` skill describes how to write +`agentctl/CHANGELOG.md` entries. `make help` at the worktree's root lists the targets; run `make fmt lint build test` before reporting completion. `make test-e2e` and `make user-install` work here too: the Sandbox has `/dev/kvm` and Podman. diff --git a/agentctl/examples/self-dev/skills/changelog/SKILL.md b/agentctl/examples/self-dev/skills/changelog/SKILL.md new file mode 100644 index 0000000..a861d77 --- /dev/null +++ b/agentctl/examples/self-dev/skills/changelog/SKILL.md @@ -0,0 +1,145 @@ +--- +name: changelog +description: Write and edit agentctl/CHANGELOG.md entries as release notes for the people who use agentctl and its Agents. Use when adding or changing a changelog entry, when a pull request needs one, or when preparing a release's changelog. +--- + +# Changelog entries + +A changelog is release notes for the people who use the product. Pull request titles and descriptions are for the +people who review the code. Do not copy one into the other: a reviewer needs to know how and why, a reader of the +changelog needs to know what changed for them and whether they must act. + +## Rules + +- **One entry per change a reader notices**, however many pull requests it took. Do not join unrelated changes in one + sentence, even when one pull request made them: make them separate entries, or sub-bullets under what they change. + If `[Unreleased]` already has an entry for the same feature, extend or rewrite that entry instead of adding another. + No entry for refactors, tests or CI, or for a change an `[Unreleased]` entry already describes with its issue + linked: apply the `skip-changelog` label. +- **Short.** One or two sentences, 40 words or fewer as a rule, and never more than 60. +- **Lead with what changed for the reader**, then say what they can do now or what they must do. +- **Use the names readers know**, written exactly as they appear in the product, so the entry can be searched. Give a + few examples rather than a complete list. +- **Leave out** how it is implemented, why it was designed that way, internal components, and what used to happen, + unless the reader must act on it. +- **Fixed** entries describe the symptom the reader saw, not the cause. +- **Breaking changes, deprecations and removals** say what to do instead, and breaking changes start with `Breaking:`. + Step-by-step migration belongs in the documentation, such as `agentctl/README.md`; link to it. Say so when a tool, + such as `agentctl self update`, makes the change for the reader. +- **End with the references in parentheses**: the documentation link first, when there is one, then every issue whose + problem or request the entry describes, or the pull request when there is no issue: + `([install](https://github.com/digdir/digdir-agents/tree/main/agentctl#install), [#1234](https://github.com/digdir/digdir-agents/issues/1234))`. + Links do not count toward the word limit. +- **Link the issue, not its pull requests.** An issue says what readers asked for or ran into and leads to the pull + requests for it, so link it once, however many pull requests it took. When the issue is part of a larger one about the + same change, such as a feature, link the larger one, but not an issue that gathers unrelated work, such as an epic or + a list of findings. Do not link an issue the change is only related to. Write an issue or pull request in another + repository as `[digdir/microsandbox#12](https://github.com/digdir/microsandbox/pull/12)`. +- **Without an issue, link the pull request.** Do not open an issue afterwards to have one to link. +- **Sub-bullets group changes by what readers know them by**, such as a command, a manifest field or a view in + `agentctl tui`: the top line names it, and each sub-bullet is one change to it. Use one level, at most five short + sub-bullets, and put each reference on the line it belongs to, or on the top line when it covers every sub-bullet. + The word limit counts the whole entry, sub-bullets included. Changes in different categories are separate entries. +- **Do not wrap lines.** Only sub-bullets start a new line within an entry. + +Before you finish, read the entry as someone who has only the changelog: can they tell what changed for them and +whether they need to do anything? Delete every clause that does not help with that. + +## Writing the entry for a pull request + +You know the implementation too well to see it from the reader's side. Write the entry from what the reader will +notice after upgrading, not from what you did: + +1. Decide whether the change is visible to the changelog's readers at all, and whether an `[Unreleased]` entry already + describes it with its issue linked. In either case, use the `skip-changelog` label. +2. If your harness can start a subagent, give a fresh one only this skill, the pull request title and description, and + the diff of what the reader sees or uses, and have it draft the entry. Otherwise, write the entry before rereading + the implementation. +3. Merge it with any related `[Unreleased]` entry, keeping that entry's references. Link the issue for this pull + request, as the rules above describe, and reference it in the pull request description (`Closes #1234`, or + `Part of #1234`) so readers can get from the issue to the change. Without an issue, add this pull request's link + once it is open. +4. Commit, then check the changelog with `agentctl/changelog.sh validate` and + `agentctl/changelog.sh check-unreleased origin/main HEAD`. + +## Preparing a release + +Read the entries being released together. If they follow the rules above, promote them as they are, as +`agentctl/AGENTS.md` describes. Otherwise, fix them in the promotion pull request: + +- Merge entries about the same feature, keeping all their references. Drop a pull request link when the merged entry + links the issue for that pull request. +- Replace a pull request link with the issue the rules above point to, when there is one. +- Drop entries for something added and fixed within the same release: readers never saw the problem. +- Cut entries to the rules above, and move migration detail to the documentation. + +## Examples + +Each block shows entries exactly as they are written in the changelog. Long entries are cut short with `...`. + +Too long, with implementation detail: + +```markdown +- `agentctl stop` and `agentctl start` change the Agent's desired run state, which the Sandbox controller reconciles by asking the Microsandbox backend to stop the VM process while keeping the root filesystem and volumes, so that a later start reuses the same disk instead of materializing the image again. ... +``` + +Better: + +```markdown +- `agentctl stop` and `agentctl start`, or `x` in `agentctl tui`, stop an Agent's VM and start it again on the same disk, also one that stopped responding. ([#123](https://github.com/digdir/digdir-agents/issues/123)) +``` + +Explains the mechanism instead of the effect: + +```markdown +- On macOS, the network backend answers DNS queries for host-default names by calling the system resolver instead of forwarding them to the servers in `/etc/resolv.conf`. +``` + +Better: + +```markdown +- On macOS, Agents resolve names through the host's system resolver, so VPN split DNS and `/etc/resolver` domains work inside an Agent as they do on the host. ([#123](https://github.com/digdir/digdir-agents/pull/123)) +``` + +Several entries for one feature: + +```markdown +- `agentctl tui` opens an Agent's shell, VS Code, Zed or SSH with `o`. ... +- `agentctl tui` opens an Agent's desktop in the browser or a VNC client with `o`. ... +- `agentctl tui` opens a port forward with `o` from the forwards view. ... +``` + +Better, as one entry with sub-bullets: + +```markdown +- `agentctl tui` opens an Agent with `o`: + - in a shell, VS Code, Zed or SSH, offering to add the `Include` to `~/.ssh/config` first ([#123](https://github.com/digdir/digdir-agents/pull/123)) + - on its desktop, in the browser or a VNC client ([#124](https://github.com/digdir/digdir-agents/pull/124)) + - through a forward, from the forwards view ([#124](https://github.com/digdir/digdir-agents/pull/124)) +``` + +Two changes in one entry: + +```markdown +- `agentctl tui` asks before quitting when that would close port forwards, and forwards are now closed when their Agent is deleted or re-created. +``` + +Better, grouped under what they change: + +```markdown +- `agentctl tui` port forwards: ([#124](https://github.com/digdir/digdir-agents/pull/124)) + - `q` asks before quitting would close them + - they close when their Agent is deleted or re-created +``` + +A fix described by its cause: + +```markdown +- `agentd` no longer blocks its request loop while a cancelled client waits for an Agent to become ready. +``` + +Better, by its symptom: + +```markdown +- Interrupted commands that wait for an Agent, such as an editor retrying its SSH connection to an Agent that cannot start, no longer make `agentd` stop answering every other command. ([#123](https://github.com/digdir/digdir-agents/pull/123)) +``` diff --git a/agentctl/examples/self-dev/workspace-init.service b/agentctl/examples/self-dev/workspace-init.service index 5d25f32..3f8a527 100644 --- a/agentctl/examples/self-dev/workspace-init.service +++ b/agentctl/examples/self-dev/workspace-init.service @@ -4,8 +4,8 @@ Wants=network-online.target After=network-online.target [Service] -# One best-effort clone started at boot for the checkout variants; a bind-mounted checkout already -# has `.git`, so the script exits without touching it. `Type=exec` completes the start job once the +# Best-effort clones started at boot; a bind-mounted checkout already has `.git`, so the script +# leaves it alone. `Type=exec` completes the start job once the # script has been started, keeping the clone out of the boot transaction so that # `systemctl is-system-running --wait` does not wait for it. Type=exec diff --git a/agentctl/examples/self-dev/workspace-init.sh b/agentctl/examples/self-dev/workspace-init.sh index df712a3..a62cf06 100644 --- a/agentctl/examples/self-dev/workspace-init.sh +++ b/agentctl/examples/self-dev/workspace-init.sh @@ -1,18 +1,22 @@ #!/bin/sh set -eu -destination=${AGENT_WORKSPACE_DESTINATION:-/home/agent/code/digdir-agents} - -if [ -d "$destination/.git" ]; then +# The agent platform and the Microsandbox and libkrunfw forks it builds on, +# cloned beside each other. A checkout that already has `.git`, such as a +# bind-mounted host checkout, is left alone. +missing= +for repository in digdir/digdir-agents digdir/microsandbox digdir/libkrunfw; do + if [ ! -e "/home/agent/code/${repository#*/}/.git" ]; then + missing="$missing $repository" + fi +done +if [ -z "$missing" ]; then exit 0 fi - -repository=${AGENT_WORKSPACE_REPOSITORY:-digdir/digdir-agents} -parent=${destination%/*} -mkdir -p "$parent" +mkdir -p /home/agent/code # Guest boot can race the host-mediated network handshake. Wait for DNS, -# while leaving the repository operation itself as one best-effort attempt. +# while leaving each repository operation itself as one best-effort attempt. remaining=30 while ! /usr/bin/getent ahosts github.com >/dev/null 2>&1; do if [ "$remaining" -eq 0 ]; then @@ -23,4 +27,8 @@ while ! /usr/bin/getent ahosts github.com >/dev/null 2>&1; do /usr/bin/sleep 1 done -/usr/local/bin/gh repo clone "$repository" "$destination" +status=0 +for repository in $missing; do + /usr/local/bin/gh repo clone "$repository" "/home/agent/code/${repository#*/}" || status=1 +done +exit "$status" From 9453f6b9e619be71eb1eb1f64cd8bf9fefab65fa Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 4 Oct 2026 22:01:52 +0200 Subject: [PATCH 6/8] build: check licenses and generate third-party notices deny.toml allows only the licenses the released binaries contain, for the release targets, and agentctl/notices generates the notices with cargo-about. They also reproduce the license files the dependencies ship, because cargo-about falls back to canonical texts without copyright notices when it cannot identify a file. --- agentctl/notices/about.hbs | 14 ++++++ agentctl/notices/about.toml | 17 +++++++ agentctl/notices/license-files.py | 61 +++++++++++++++++++++++++ agentctl/notices/third-party-notices.sh | 58 +++++++++++++++++++++++ deny.toml | 25 +++++++++- 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 agentctl/notices/about.hbs create mode 100644 agentctl/notices/about.toml create mode 100755 agentctl/notices/license-files.py create mode 100755 agentctl/notices/third-party-notices.sh diff --git a/agentctl/notices/about.hbs b/agentctl/notices/about.hbs new file mode 100644 index 0000000..e3c47c2 --- /dev/null +++ b/agentctl/notices/about.hbs @@ -0,0 +1,14 @@ +{{#each licenses}} +### {{{name}}} + +Used by: + +{{#each used_by}} +- {{{crate.name}}} {{{crate.version}}} +{{/each}} + +```text +{{{text}}} +``` + +{{/each}} diff --git a/agentctl/notices/about.toml b/agentctl/notices/about.toml new file mode 100644 index 0000000..55b77db --- /dev/null +++ b/agentctl/notices/about.toml @@ -0,0 +1,17 @@ +accepted = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +ignore-build-dependencies = true +ignore-dev-dependencies = true diff --git a/agentctl/notices/license-files.py b/agentctl/notices/license-files.py new file mode 100755 index 0000000..3bc6ba9 --- /dev/null +++ b/agentctl/notices/license-files.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Print the license files that the dependencies of an agentctl release ship. + +Reads a `cargo about generate --format json` report and, for every crate from a +registry or Git source, reproduces the license, copying, copyright and notice +files in its package root verbatim. cargo-about identifies license texts +automatically and falls back to canonical texts without the crate's copyright +notices when it cannot, so this keeps every notice a dependency ships. +Identical files are printed once with the crates that ship them. +""" + +import hashlib +import json +import sys +from pathlib import Path + +NAME_PREFIXES = ("license", "licence", "copying", "copyright", "notice", "unlicense") + + +def license_files(package_dir): + files = [] + for entry in sorted(package_dir.iterdir()): + if entry.is_file() and entry.name.lower().startswith(NAME_PREFIXES): + files.append(entry) + return files + + +def main(paths): + crates = {} + for path in paths: + with open(path, encoding="utf-8") as report: + for crate in json.load(report)["crates"]: + package = crate["package"] + manifest = Path(package["manifest_path"]) + # Workspace crates are covered by this repository's LICENSE. + if "registry" not in manifest.parts and "git" not in manifest.parts: + continue + crates[(package["name"], package["version"])] = manifest.parent + + texts = {} + for (name, version), package_dir in sorted(crates.items()): + for file in license_files(package_dir): + text = file.read_bytes().decode("utf-8", errors="replace").strip() + digest = hashlib.sha256(text.encode()).hexdigest() + entry = texts.setdefault(digest, {"text": text, "users": []}) + entry["users"].append(f"{name} {version} ({file.name})") + + if not texts: + sys.exit("no dependency license files found; check the cargo-about report") + + for entry in sorted(texts.values(), key=lambda entry: entry["users"][0]): + print("Shipped by:\n") + for user in entry["users"]: + print(f"- {user}") + print("\n```text") + print(entry["text"].replace("```", "'''")) + print("```\n") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/agentctl/notices/third-party-notices.sh b/agentctl/notices/third-party-notices.sh new file mode 100755 index 0000000..e37ba29 --- /dev/null +++ b/agentctl/notices/third-party-notices.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# third-party-notices.sh — write the third-party notices for an agentctl release. +# +# Usage: +# agentctl/notices/third-party-notices.sh +# +# Covers the Rust dependencies of the `agentctl` and `agentd` binaries for every +# release target, followed by the license files the dependencies ship. Requires +# cargo-about and Python 3. + +set -euo pipefail + +if (( $# != 1 )); then + echo "Usage: $0 " >&2 + exit 2 +fi + +output=$(realpath -m -- "$1") +cd "$(git rev-parse --show-toplevel)" + +config=agentctl/notices/about.toml +template=agentctl/notices/about.hbs +about_args=( + --manifest-path agentctl/Cargo.toml + --target x86_64-unknown-linux-gnu --target aarch64-unknown-linux-gnu + --target aarch64-apple-darwin + --target x86_64-pc-windows-msvc --target aarch64-pc-windows-msvc +) +reports=$(mktemp -d) +trap 'rm -rf "$reports"' EXIT +cargo about generate --locked --config "$config" --format json "${about_args[@]}" > "$reports/agent.json" + +{ + cat <<'NOTICE' +# Third-party notices + +This release of agentctl contains the `agentctl` and `agentd` binaries. agentctl +is licensed under MIT; see `LICENSE`. This file lists the third-party components +the binaries contain and their licenses. + +## Rust dependencies + +NOTICE + cargo about generate --locked --config "$config" "${about_args[@]}" "$template" + cat <<'NOTICE' + +## License files shipped by the dependencies + +The license texts above are identified automatically and do not always reproduce +the copyright notices of each dependency. The license, copying and notice files +that the dependencies ship are reproduced here verbatim; identical files are +listed once. + +NOTICE + python3 agentctl/notices/license-files.py "$reports/agent.json" +} > "$output" + +echo "Wrote $output" diff --git a/deny.toml b/deny.toml index e4bf4ce..ad87614 100644 --- a/deny.toml +++ b/deny.toml @@ -3,11 +3,13 @@ [graph] all-features = true +# The targets agentctl is released for. targets = [ "x86_64-unknown-linux-gnu", - "x86_64-pc-windows-gnu", + "aarch64-unknown-linux-gnu", "aarch64-apple-darwin", - "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", ] [advisories] @@ -24,3 +26,22 @@ unknown-registry = "deny" # Do not maintain a git repository allowlist, but require an explicit revision specifier. unknown-git = "allow" required-git-spec = "rev" + +[licenses] +# The licenses the released binaries may contain. agentctl/notices/about.toml accepts the same list, so a new license +# is a reviewed change to both. +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] From b05d7bcade1cdd97901a5eb9add8a478f1c25c13 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 4 Oct 2026 22:01:52 +0200 Subject: [PATCH 7/8] ci: build, test and release agentctl rust.yml builds, lints and tests the workspace and checks dependency advisories, sources and licenses; ci.yml runs it when the workspace changes, behind ci-gate. agentctl-release.yml publishes an `agentctl/v*` tag with LICENSE and THIRD_PARTY_NOTICES.md as separate assets, because the installers and the updater accept only the two binaries in an archive. It publishes through a draft, so every asset is attached before the release becomes immutable. The changelog check runs on its own so that toggling skip-changelog does not rerun CI. The root Rust files and these workflows get their own CODEOWNERS entries. --- .github/CODEOWNERS | 16 +++ .github/workflows/agentctl-changelog.yml | 48 +++++++ .github/workflows/agentctl-release.yml | 171 +++++++++++++++++++++++ .github/workflows/ci.yml | 9 +- .github/workflows/rust.yml | 143 +++++++++++++++++++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/agentctl-changelog.yml create mode 100644 .github/workflows/agentctl-release.yml create mode 100644 .github/workflows/rust.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index edfd086..bef591a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -27,3 +27,19 @@ scripts/ @olebhansen # review-gaten der er deres, ikke orkestrator-pipelinens agentctl/ @martinothamar sandbox/ @martinothamar + +# Rust-arbeidsområdet på rot og workflowene til agentctl/sandbox. Står etter +# .github/ slik at review av disse ikke krever orkestrator-pipelinens eier, og +# omvendt. +/Cargo.toml @martinothamar +/Cargo.lock @martinothamar +/rust-toolchain.toml @martinothamar +/rustfmt.toml @martinothamar +/clippy.toml @martinothamar +/deny.toml @martinothamar +/Makefile @martinothamar +/.cargo/ @martinothamar +/.editorconfig @martinothamar +/.github/workflows/rust.yml @martinothamar +/.github/workflows/agentctl-release.yml @martinothamar +/.github/workflows/agentctl-changelog.yml @martinothamar diff --git a/.github/workflows/agentctl-changelog.yml b/.github/workflows/agentctl-changelog.yml new file mode 100644 index 0000000..c8531da --- /dev/null +++ b/.github/workflows/agentctl-changelog.yml @@ -0,0 +1,48 @@ +name: agentctl changelog entry + +# Requires pull requests that change agentctl or the Sandbox crates to add an entry to agentctl/CHANGELOG.md under +# [Unreleased], using agentctl/changelog.sh. Apply the 'skip-changelog' label to pull requests with no user-visible +# change (refactors, test-only or CI-only work) or a change an Unreleased entry already describes with its issue +# linked; the labeled/unlabeled triggers re-evaluate the check when the label is toggled. +# Markdown-only changes do not trigger the check, except the changelog itself, so that a pull request editing only the +# changelog is still validated. + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + paths: + - 'agentctl/**' + - 'sandbox/**' + - '.github/workflows/agentctl-changelog.yml' + - '!agentctl/**/*.md' + - '!sandbox/**/*.md' + - 'agentctl/CHANGELOG.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + require-changelog-entry: + name: Require agentctl changelog entry + if: ${{ !github.event.pull_request.draft && !contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate changelog structure + run: agentctl/changelog.sh validate + + - name: Require an Unreleased entry + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: agentctl/changelog.sh check-unreleased "$BASE_SHA" "$HEAD_SHA" diff --git a/.github/workflows/agentctl-release.yml b/.github/workflows/agentctl-release.yml new file mode 100644 index 0000000..c78f1b4 --- /dev/null +++ b/.github/workflows/agentctl-release.yml @@ -0,0 +1,171 @@ +# Publishes an agentctl release for an `agentctl/v` tag: the archives for every host, the installers, the +# license and third-party notices, with the version's CHANGELOG.md section as the release notes. +name: agentctl release + +on: + push: + tags: + - 'agentctl/v*' + +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.archive }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + archive: agent-linux-x86_64 + - runner: ubuntu-24.04-arm + archive: agent-linux-aarch64 + - runner: macos-15 + archive: agent-macos-aarch64 + - runner: windows-2025 + archive: agent-windows-x86_64 + - runner: windows-11-arm + archive: agent-windows-aarch64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - name: Enable long paths for git on Windows + if: runner.os == 'Windows' + run: git config --system core.longpaths true + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install native build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libcap-ng-dev + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal --component clippy,rustfmt + rustup default "${RUST_VERSION}" + + - name: Build release binaries + run: | + AGENT_VERSION="${GITHUB_REF_NAME#agentctl/}" cargo build --release --locked -p agent --bins + + - name: Package release binaries + env: + ARCHIVE: ${{ matrix.archive }} + run: | + agentctl/package.sh "dist/${ARCHIVE}.tar.gz" target/release + + - name: Smoke-test managed and standalone installation + env: + ARCHIVE: ${{ matrix.archive }} + AGENT_RELEASE_TAG: ${{ github.ref_name }} + run: | + VERSION="${AGENT_RELEASE_TAG#agentctl/}" + export VERSION AGENT_VERSION="${VERSION}" + SMOKE_ROOT="${RUNNER_TEMP}/agent-install-smoke" + ARCHIVE_PATH="${GITHUB_WORKSPACE}/dist/${ARCHIVE}.tar.gz" + if [ "${RUNNER_OS}" = "Windows" ]; then + export AGENT_INSTALL_ROOT="$(cygpath -w "${SMOKE_ROOT}/install")" + export AGENT_INSTALL_DIR="$(cygpath -w "${SMOKE_ROOT}/bin")" + export AGENT_HOME="$(cygpath -w "${SMOKE_ROOT}/home")" + export AGENT_LOCAL_ARCHIVE="$(cygpath -w "${ARCHIVE_PATH}")" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" + export AGENT_SMOKE_BIN="${AGENT_INSTALL_DIR}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + pwsh -NoProfile -Command '& (Join-Path $env:AGENT_SMOKE_BIN "agentctl.cmd") --home $env:AGENT_HOME self update --version $env:VERSION' + export AGENT_INSTALL_MODE=standalone + export AGENT_INSTALL_DIR="$(cygpath -w "${SMOKE_ROOT}/standalone")" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" + pwsh -NoProfile -Command '& (Join-Path $env:AGENT_INSTALL_DIR "agentctl.exe") --version' + else + export AGENT_INSTALL_ROOT="${SMOKE_ROOT}/install" + export AGENT_INSTALL_DIR="${SMOKE_ROOT}/bin" + export AGENT_HOME="${SMOKE_ROOT}/home" + export AGENT_LOCAL_ARCHIVE="${ARCHIVE_PATH}" + sh agentctl/install.sh + AGENT_SMOKE_BIN="${AGENT_INSTALL_DIR}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + "${AGENT_SMOKE_BIN}/agentctl" --home "${AGENT_HOME}" self update --version "${VERSION}" + AGENT_INSTALL_MODE=standalone AGENT_INSTALL_DIR="${SMOKE_ROOT}/standalone" \ + sh agentctl/install.sh + "${SMOKE_ROOT}/standalone/agentctl" --version + fi + + - name: Upload release artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: ${{ matrix.archive }} + path: dist/agent-* + if-no-files-found: error + + release: + name: Publish GitHub release + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build release notes from the changelog + env: + TAG: ${{ github.ref_name }} + run: | + agentctl/changelog.sh validate + agentctl/changelog.sh extract "${TAG#agentctl/}" > release-notes.md + cat release-notes.md + + - name: Download binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + - name: Add installers + run: | + cp agentctl/install.sh agentctl/install.ps1 dist/ + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal + rustup default "${RUST_VERSION}" + + - name: Install cargo-about + run: cargo install cargo-about --locked --features cli --version 0.9.2 + + # The archives hold only the two binaries the installers and `agentctl self update` accept, so the license and + # notices are separate assets. + - name: Add license and third-party notices + run: | + cp LICENSE dist/LICENSE + agentctl/notices/third-party-notices.sh dist/THIRD_PARTY_NOTICES.md + + # Immutable releases lock their assets on publication, so upload every asset to a draft first and publish it + # last. A draft left by a failed run is not immutable and is replaced. + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + if [ "$(gh release view "${TAG}" --json isDraft --jq .isDraft 2>/dev/null)" = true ]; then + gh release delete "${TAG}" --yes + fi + gh release create "${TAG}" --draft --prerelease --latest=false --verify-tag \ + --title "agentctl ${TAG#agentctl/}" --notes-file release-notes.md + gh release upload "${TAG}" dist/* + gh release edit "${TAG}" --draft=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02f0236..538ed5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: pull-requests: read outputs: integrations: ${{ steps.areas.outputs.integrations }} + rust: ${{ steps.areas.outputs.rust }} steps: - name: Detect changed areas id: areas @@ -40,6 +41,7 @@ jobs: # Each area's paths, as an extended regular expression over the changed file names. areas=( 'integrations=^(integrations/|\.github/workflows/(ci|integrations)\.yml$)' + 'rust=^(agentctl/|sandbox/|\.cargo/|(Cargo\.toml|Cargo\.lock|rust-toolchain\.toml|rustfmt\.toml|clippy\.toml|deny\.toml|Makefile)$|\.github/workflows/(ci|rust)\.yml$)' ) # A renamed file counts under both its old and its new name. names='.filename, (.previous_filename // empty)' @@ -65,10 +67,15 @@ jobs: if: needs.changes.outputs.integrations == 'true' uses: ./.github/workflows/integrations.yml + rust: + needs: changes + if: needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/rust.yml + ci-gate: name: ci-gate if: always() - needs: [changes, integrations] + needs: [changes, integrations, rust] runs-on: ubuntu-latest steps: - name: Check that no needed job failed diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..ac4d0d6 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,143 @@ +# Builds, lints and tests the Rust workspace: agentctl and the Sandbox crates. Called from ci.yml when the workspace +# changes. +name: rust + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: Build and test + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libcap-ng-dev + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal --component clippy,rustfmt + rustup default "${RUST_VERSION}" + + - name: Restore Rust cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + # Saves only from main, so pull requests share its cache instead of evicting it with their own. + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Install cargo-machete + run: cargo install cargo-machete --version 0.9.2 --locked + + - name: Check formatting + run: | + make fmt + git diff --exit-code -- . + + - name: Build + run: make build + + - name: Lint + run: make lint + + - name: Run tests + run: make test + + - name: Test installation and managed upgrade + run: ./agentctl/installation-test.sh + + - name: Check dependencies + run: make deps-check + + portable-hosts: + name: Portable host (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: Linux ARM64 + runner: ubuntu-24.04-arm + - name: macOS ARM64 + runner: macos-15 + - name: Windows x64 + runner: windows-2025 + - name: Windows ARM64 + runner: windows-11-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - name: Enable long paths for git on Windows + if: runner.os == 'Windows' + working-directory: ${{ runner.temp }} + run: git config --system core.longpaths true + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install native build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libcap-ng-dev + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal --component clippy,rustfmt + rustup default "${RUST_VERSION}" + + - name: Check native host + run: >- + cargo clippy + -p sandbox + -p sandbox-authorization + -p agent + -p sandbox-microsandbox + --all-targets --all-features --locked + + - name: Test native host + run: >- + cargo test + -p sandbox + -p sandbox-authorization + -p agent + -p sandbox-microsandbox + --all-targets --all-features --locked + + - name: Test installation and managed upgrade + run: ./agentctl/installation-test.sh + + dependencies: + name: Advisories, sources and licenses + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check dependency advisories, sources and licenses + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + rust-version: '1.97.1' + arguments: '--locked' + command: check + command-arguments: 'advisories sources licenses' From 561dfb6e0dfc36230be41220fc9f844a03dd59b2 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Sun, 4 Oct 2026 22:04:03 +0200 Subject: [PATCH 8/8] build: pin Microsandbox to digdir/microsandbox v0.7.4-digdir.3 Build against the Microsandbox fork in the digdir organization at its v0.7.4-digdir.3 release tag and install the runtime published with that release. The source matches the previous pin apart from the release location and version, so the runtime behaves the same. --- Cargo.lock | 60 +++++++++++++++--------------- Cargo.toml | 8 ++-- sandbox/microsandbox/src/client.rs | 12 +++--- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24ebc7f..1fdc911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3246,8 +3246,8 @@ dependencies = [ [[package]] name = "microsandbox" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "astral-tokio-tar", "async-compression", @@ -3303,8 +3303,8 @@ dependencies = [ [[package]] name = "microsandbox-agent-client" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "bytes", "ciborium", @@ -3322,8 +3322,8 @@ dependencies = [ [[package]] name = "microsandbox-control-client" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "microsandbox-protocol", "microsandbox-protocol-client", @@ -3338,8 +3338,8 @@ dependencies = [ [[package]] name = "microsandbox-db" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "async-trait", "microsandbox-types", @@ -3353,8 +3353,8 @@ dependencies = [ [[package]] name = "microsandbox-filesystem" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "bincode", "hex", @@ -3372,8 +3372,8 @@ dependencies = [ [[package]] name = "microsandbox-image" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "astral-tokio-tar", "async-compression", @@ -3403,8 +3403,8 @@ dependencies = [ [[package]] name = "microsandbox-metrics" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "chrono", "libc", @@ -3414,8 +3414,8 @@ dependencies = [ [[package]] name = "microsandbox-migration" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "microsandbox-db", "sea-orm-migration", @@ -3424,8 +3424,8 @@ dependencies = [ [[package]] name = "microsandbox-network" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "base64 0.22.1", "bytes", @@ -3467,8 +3467,8 @@ dependencies = [ [[package]] name = "microsandbox-protocol" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "bytes", "chrono", @@ -3484,8 +3484,8 @@ dependencies = [ [[package]] name = "microsandbox-protocol-client" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "microsandbox-protocol", "serde", @@ -3496,8 +3496,8 @@ dependencies = [ [[package]] name = "microsandbox-runtime" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "base64 0.23.1", "chrono", @@ -3528,8 +3528,8 @@ dependencies = [ [[package]] name = "microsandbox-types" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "chrono", "hex", @@ -3545,8 +3545,8 @@ dependencies = [ [[package]] name = "microsandbox-types-macros" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "proc-macro2", "quote", @@ -3555,8 +3555,8 @@ dependencies = [ [[package]] name = "microsandbox-utils" -version = "0.7.4-digdir.2" -source = "git+https://github.com/martinothamar/microsandbox.git?rev=c3a16753edba0ad4b21844ccd50ce7b615e43f84#c3a16753edba0ad4b21844ccd50ce7b615e43f84" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" dependencies = [ "dirs", "libc", diff --git a/Cargo.toml b/Cargo.toml index cf4b2b7..f5ce747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,11 +24,11 @@ futures-core = "0.3.34" futures-util = "0.3.34" flate2 = "1.1.9" ignore = "0.4.33" -# Microsandbox fork commit from `main-digdir`, tagged `digdir-v0.7.4-digdir.2`. +# Microsandbox fork commit tagged `v0.7.4-digdir.3`. # Update all three revisions together with the runtime bundle digests. -microsandbox = { git = "https://github.com/martinothamar/microsandbox.git", rev = "c3a16753edba0ad4b21844ccd50ce7b615e43f84", default-features = false, features = ["local", "net"] } -microsandbox-image = { git = "https://github.com/martinothamar/microsandbox.git", rev = "c3a16753edba0ad4b21844ccd50ce7b615e43f84", package = "microsandbox-image" } -microsandbox-network = { git = "https://github.com/martinothamar/microsandbox.git", rev = "c3a16753edba0ad4b21844ccd50ce7b615e43f84", package = "microsandbox-network" } +microsandbox = { git = "https://github.com/digdir/microsandbox.git", rev = "37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c", default-features = false, features = ["local", "net"] } +microsandbox-image = { git = "https://github.com/digdir/microsandbox.git", rev = "37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c", package = "microsandbox-image" } +microsandbox-network = { git = "https://github.com/digdir/microsandbox.git", rev = "37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c", package = "microsandbox-network" } mimalloc = "0.1.52" ratatui = "0.30.2" reqwest = { version = "0.13.4", features = ["json"] } diff --git a/sandbox/microsandbox/src/client.rs b/sandbox/microsandbox/src/client.rs index 0c17268..2f65a2d 100644 --- a/sandbox/microsandbox/src/client.rs +++ b/sandbox/microsandbox/src/client.rs @@ -20,13 +20,13 @@ use tokio::sync::OnceCell; use crate::{backend::RuntimeBundle, error}; -// Published runtime bundle digests for Microsandbox 0.7.4-digdir.2. Update these +// Published runtime bundle digests for Microsandbox 0.7.4-digdir.3. Update these // together with the pinned Microsandbox revisions in the workspace manifest. -const LINUX_X86_64_RUNTIME_SHA256: &str = "7f684243b99c1be03111953b405b23c89776b3add168b265de01192502ee9e46"; -const LINUX_AARCH64_RUNTIME_SHA256: &str = "05bf90aa1e029c3a0e8e2d603ed2cd310c4799e8eb19c026715c2f01ce7fdaa2"; -const MACOS_AARCH64_RUNTIME_SHA256: &str = "2d791295ab9cae4f5d019d3d530029f2af4cce95525c09b9b6d8f73fc8b69d1f"; -const WINDOWS_X86_64_RUNTIME_SHA256: &str = "9be3e9b4b15a03465f8d078e26683d5cea61fe2501df00369f91e3b5c8956a1e"; -const WINDOWS_AARCH64_RUNTIME_SHA256: &str = "afedcd54bccdc5c0d335c3c25698b5e99ebd2626b280c9135c04796b6af264b0"; +const LINUX_X86_64_RUNTIME_SHA256: &str = "0fe0304ea066b991982d56e715bb6fd8655489f0b373d18845b8ea5025496725"; +const LINUX_AARCH64_RUNTIME_SHA256: &str = "1969a9a01c1689ec358c05e1a2f40350f3f86579aaea247ba1a2724a6a5f78c0"; +const MACOS_AARCH64_RUNTIME_SHA256: &str = "51f7ac53078bd92b4564ac0464ae390711a9dba05ae36af4fae764f1f09f83cd"; +const WINDOWS_X86_64_RUNTIME_SHA256: &str = "7f2569658c32f546356d1071fda823ee8a8fcdcccd698d8eb8cfb85b6d73a5ff"; +const WINDOWS_AARCH64_RUNTIME_SHA256: &str = "a87f5cab0e0e6643c65111bf2b043e522c20ce2511748422627e6f7505c544a8"; /// Keeps Microsandbox's thread-safe ownership model at the SDK boundary. #[derive(Clone)]