From 2bef715211ddbe253c549fc6e0641e3b1357a478 Mon Sep 17 00:00:00 2001 From: Disasmer release dry run Date: Tue, 14 Jul 2026 10:08:15 +0200 Subject: [PATCH] Public dry run dryrun-20b59dc46b72 Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19 Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9 --- .gitignore | 7 + Cargo.lock | 3045 ++++++++ Cargo.toml | 44 + DISASMER_PUBLIC_TREE.json | 21 + LICENSE-APACHE | 175 + LICENSE-MIT | 21 + README.md | 421 ++ SECURITY.md | 26 + crates/disasmer-cli/Cargo.toml | 22 + crates/disasmer-cli/src/admin.rs | 236 + crates/disasmer-cli/src/agent.rs | 17 + crates/disasmer-cli/src/artifact.rs | 547 ++ crates/disasmer-cli/src/auth.rs | 780 ++ crates/disasmer-cli/src/auth_scope.rs | 79 + crates/disasmer-cli/src/build.rs | 322 + crates/disasmer-cli/src/bundle.rs | 379 + crates/disasmer-cli/src/client.rs | 183 + crates/disasmer-cli/src/config.rs | 149 + crates/disasmer-cli/src/confirm.rs | 35 + crates/disasmer-cli/src/debug.rs | 120 + crates/disasmer-cli/src/dispatch.rs | 343 + crates/disasmer-cli/src/doctor.rs | 163 + crates/disasmer-cli/src/errors.rs | 255 + crates/disasmer-cli/src/key.rs | 271 + crates/disasmer-cli/src/logout.rs | 127 + crates/disasmer-cli/src/logs.rs | 34 + crates/disasmer-cli/src/main.rs | 689 ++ crates/disasmer-cli/src/node.rs | 837 +++ crates/disasmer-cli/src/output.rs | 638 ++ crates/disasmer-cli/src/process.rs | 333 + crates/disasmer-cli/src/process_events.rs | 666 ++ crates/disasmer-cli/src/project.rs | 381 + crates/disasmer-cli/src/quota.rs | 111 + crates/disasmer-cli/src/run.rs | 943 +++ crates/disasmer-cli/src/run/local_services.rs | 177 + crates/disasmer-cli/src/task.rs | 106 + crates/disasmer-cli/src/tests.rs | 4738 ++++++++++++ crates/disasmer-cli/src/tools.rs | 63 + crates/disasmer-control/Cargo.toml | 13 + crates/disasmer-control/src/lib.rs | 262 + crates/disasmer-coordinator/Cargo.toml | 18 + crates/disasmer-coordinator/src/agents.rs | 174 + crates/disasmer-coordinator/src/durable.rs | 145 + crates/disasmer-coordinator/src/lib.rs | 1045 +++ crates/disasmer-coordinator/src/main.rs | 62 + .../src/postgres_store.rs | 576 ++ crates/disasmer-coordinator/src/service.rs | 423 ++ .../disasmer-coordinator/src/service/admin.rs | 147 + .../src/service/artifacts.rs | 649 ++ .../src/service/authenticated.rs | 139 + .../src/service/authorization.rs | 198 + .../disasmer-coordinator/src/service/debug.rs | 995 +++ .../src/service/debug/validation.rs | 92 + .../src/service/debug_requests.rs | 470 ++ .../src/service/durable_runtime.rs | 47 + .../disasmer-coordinator/src/service/keys.rs | 71 + .../disasmer-coordinator/src/service/logs.rs | 535 ++ .../src/service/main_runtime.rs | 999 +++ .../disasmer-coordinator/src/service/nodes.rs | 407 ++ .../src/service/panels.rs | 307 + .../src/service/process_launch.rs | 555 ++ .../src/service/processes.rs | 788 ++ .../src/service/protocol.rs | 644 ++ .../src/service/protocol/responses.rs | 492 ++ .../disasmer-coordinator/src/service/quota.rs | 421 ++ .../src/service/routing.rs | 778 ++ .../src/service/signed_nodes.rs | 365 + .../disasmer-coordinator/src/service/tcp.rs | 200 + .../disasmer-coordinator/src/service/tests.rs | 6323 +++++++++++++++++ .../src/service/wire_protocol.rs | 59 + crates/disasmer-coordinator/src/sessions.rs | 167 + crates/disasmer-core/Cargo.toml | 22 + crates/disasmer-core/src/artifact.rs | 1178 +++ crates/disasmer-core/src/auth.rs | 720 ++ crates/disasmer-core/src/bundle.rs | 459 ++ crates/disasmer-core/src/capability.rs | 218 + crates/disasmer-core/src/checkpoint.rs | 284 + crates/disasmer-core/src/debug.rs | 278 + crates/disasmer-core/src/digest.rs | 68 + crates/disasmer-core/src/environment.rs | 288 + crates/disasmer-core/src/execution.rs | 764 ++ crates/disasmer-core/src/ids.rs | 45 + crates/disasmer-core/src/lib.rs | 102 + crates/disasmer-core/src/limits.rs | 273 + crates/disasmer-core/src/operator_panel.rs | 377 + crates/disasmer-core/src/policy.rs | 134 + crates/disasmer-core/src/project.rs | 303 + crates/disasmer-core/src/scheduler.rs | 429 ++ crates/disasmer-core/src/source.rs | 324 + crates/disasmer-core/src/transport.rs | 243 + crates/disasmer-core/src/vfs.rs | 241 + crates/disasmer-core/src/wire.rs | 114 + crates/disasmer-dap/Cargo.toml | 21 + crates/disasmer-dap/src/adapter.rs | 814 +++ crates/disasmer-dap/src/breakpoints.rs | 340 + crates/disasmer-dap/src/dap_protocol.rs | 129 + crates/disasmer-dap/src/demo_backend.rs | 63 + crates/disasmer-dap/src/main.rs | 43 + crates/disasmer-dap/src/runtime_client.rs | 974 +++ .../src/runtime_client/debug_protocol.rs | 177 + .../src/runtime_client/local_tools.rs | 63 + .../src/runtime_client/transport.rs | 38 + crates/disasmer-dap/src/source.rs | 71 + crates/disasmer-dap/src/tests.rs | 817 +++ crates/disasmer-dap/src/variables.rs | 735 ++ crates/disasmer-dap/src/view_state.rs | 89 + crates/disasmer-dap/src/virtual_model.rs | 775 ++ crates/disasmer-macros/Cargo.toml | 17 + crates/disasmer-macros/src/lib.rs | 453 ++ crates/disasmer-node/Cargo.toml | 22 + crates/disasmer-node/src/assignment_runner.rs | 820 +++ .../src/assignment_runner/control_watcher.rs | 213 + .../src/assignment_runner/process_runner.rs | 265 + .../src/assignment_runner/tests.rs | 205 + .../src/assignment_runner/validation.rs | 164 + .../src/bin/disasmer-podman-smoke.rs | 95 + .../src/bin/disasmer-quic-smoke.rs | 138 + .../src/bin/disasmer-wasmtime-smoke.rs | 408 ++ crates/disasmer-node/src/command_runner.rs | 130 + .../disasmer-node/src/coordinator_session.rs | 38 + crates/disasmer-node/src/daemon.rs | 734 ++ crates/disasmer-node/src/debug_agent.rs | 47 + crates/disasmer-node/src/lib.rs | 1005 +++ crates/disasmer-node/src/main.rs | 12 + crates/disasmer-node/src/node_identity.rs | 224 + crates/disasmer-node/src/source_snapshot.rs | 424 ++ crates/disasmer-node/src/task_artifacts.rs | 984 +++ crates/disasmer-node/src/task_reports.rs | 379 + crates/disasmer-node/src/windows_dev.rs | 39 + crates/disasmer-sdk/Cargo.toml | 18 + crates/disasmer-sdk/src/__private.rs | 500 ++ crates/disasmer-sdk/src/lib.rs | 1001 +++ crates/disasmer-sdk/src/sdk_runtime.rs | 621 ++ crates/disasmer-sdk/src/task_args.rs | 250 + crates/disasmer-sdk/tests/macros.rs | 180 + crates/disasmer-wasm-runtime/Cargo.toml | 14 + crates/disasmer-wasm-runtime/src/lib.rs | 862 +++ .../src/task_host_linker.rs | 406 ++ crates/disasmer-wasm-runtime/src/tests.rs | 229 + docs/architecture.md | 46 + docs/artifacts.md | 37 + docs/debugging.md | 30 + docs/security.md | 54 + docs/self-hosting.md | 49 + docs/task-abi.md | 45 + examples/launch-build-demo/Cargo.toml | 18 + examples/launch-build-demo/README.md | 11 + .../envs/linux/Containerfile | 3 + .../launch-build-demo/envs/windows/Dockerfile | 2 + .../fixture/hello-disasmer.c | 6 + .../src/bin/sdk-product-runtime.rs | 33 + examples/launch-build-demo/src/build.rs | 327 + flake.lock | 27 + flake.nix | 35 + rust-toolchain.toml | 5 + scripts/acceptance-cli-first.sh | 83 + scripts/acceptance-doc-contract-smoke.js | 205 + .../acceptance-environment-contract-smoke.js | 262 + scripts/acceptance-evidence-contract-smoke.js | 1080 +++ scripts/acceptance-private.sh | 39 + scripts/acceptance-public.sh | 72 + scripts/acceptance-report-smoke.js | 113 + scripts/acceptance-report.js | 139 + scripts/agent-signing.js | 95 + scripts/artifact-download-smoke.js | 401 ++ scripts/artifact-export-smoke.js | 262 + scripts/cli-browser-login-flow-smoke.js | 223 + scripts/cli-error-exit-smoke.js | 365 + scripts/cli-first-contract-smoke.js | 773 ++ scripts/cli-happy-path-live-smoke.js | 2116 ++++++ scripts/cli-install-smoke.js | 61 + scripts/cli-local-run-smoke.js | 269 + scripts/cli-login-smoke.js | 72 + scripts/cli-output-mode-smoke.js | 182 + scripts/code-size-guard.js | 86 + scripts/coordinator-wire.js | 43 + scripts/dap-client.js | 132 + scripts/dap-smoke.js | 363 + scripts/docs-smoke.js | 413 ++ scripts/flagship-demo-smoke.js | 148 + scripts/hostile-input-contract-smoke.js | 198 + scripts/node-attach-smoke.js | 300 + scripts/node-lifecycle-contract-smoke.js | 164 + scripts/node-signing.js | 177 + scripts/operator-panel-smoke.js | 360 + scripts/phase3-ledger.js | 89 + scripts/podman-backend-smoke.js | 86 + scripts/podman-test-env.js | 41 + scripts/prepare-public-release-dryrun.js | 755 ++ .../public-browser-login-contract-smoke.js | 77 + scripts/public-local-demo-matrix-smoke.js | 69 + scripts/public-private-boundary-smoke.js | 185 + .../public-release-dryrun-contract-smoke.js | 321 + scripts/public-release-dryrun-e2e.js | 1422 ++++ .../public-release-dryrun-final-evidence.js | 515 ++ scripts/public-release-dryrun-preflight.js | 292 + scripts/public-story-contract-smoke.js | 101 + scripts/publish-public-release-dryrun.js | 349 + scripts/quic-smoke.js | 154 + scripts/real-flagship-harness.js | 230 + scripts/release-blocker-smoke.js | 189 + scripts/release-source-scan.sh | 71 + scripts/resource-metering-contract-smoke.js | 214 + scripts/scheduler-placement-smoke.js | 407 ++ scripts/sdk-spawn-runtime-smoke.js | 49 + scripts/self-hosted-coordinator-smoke.js | 723 ++ scripts/source-preparation-smoke.js | 218 + scripts/tenant-isolation-contract-smoke.js | 197 + scripts/user-session-token-boundary-smoke.js | 120 + scripts/verify-public-split.sh | 75 + scripts/vscode-extension-smoke.js | 232 + scripts/vscode-f5-smoke.js | 310 + scripts/wasmtime-assignment-smoke.js | 1313 ++++ scripts/wasmtime-node-smoke.js | 172 + scripts/website-inventory-contract-smoke.js | 105 + scripts/windows-best-effort-smoke.js | 302 + scripts/windows-runner-smoke.js | 479 ++ scripts/windows-validation-contract-smoke.js | 59 + vscode-extension/extension.js | 730 ++ vscode-extension/package.json | 234 + vscode-extension/resources/disasmer.svg | 3 + 221 files changed, 79792 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DISASMER_PUBLIC_TREE.json create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 crates/disasmer-cli/Cargo.toml create mode 100644 crates/disasmer-cli/src/admin.rs create mode 100644 crates/disasmer-cli/src/agent.rs create mode 100644 crates/disasmer-cli/src/artifact.rs create mode 100644 crates/disasmer-cli/src/auth.rs create mode 100644 crates/disasmer-cli/src/auth_scope.rs create mode 100644 crates/disasmer-cli/src/build.rs create mode 100644 crates/disasmer-cli/src/bundle.rs create mode 100644 crates/disasmer-cli/src/client.rs create mode 100644 crates/disasmer-cli/src/config.rs create mode 100644 crates/disasmer-cli/src/confirm.rs create mode 100644 crates/disasmer-cli/src/debug.rs create mode 100644 crates/disasmer-cli/src/dispatch.rs create mode 100644 crates/disasmer-cli/src/doctor.rs create mode 100644 crates/disasmer-cli/src/errors.rs create mode 100644 crates/disasmer-cli/src/key.rs create mode 100644 crates/disasmer-cli/src/logout.rs create mode 100644 crates/disasmer-cli/src/logs.rs create mode 100644 crates/disasmer-cli/src/main.rs create mode 100644 crates/disasmer-cli/src/node.rs create mode 100644 crates/disasmer-cli/src/output.rs create mode 100644 crates/disasmer-cli/src/process.rs create mode 100644 crates/disasmer-cli/src/process_events.rs create mode 100644 crates/disasmer-cli/src/project.rs create mode 100644 crates/disasmer-cli/src/quota.rs create mode 100644 crates/disasmer-cli/src/run.rs create mode 100644 crates/disasmer-cli/src/run/local_services.rs create mode 100644 crates/disasmer-cli/src/task.rs create mode 100644 crates/disasmer-cli/src/tests.rs create mode 100644 crates/disasmer-cli/src/tools.rs create mode 100644 crates/disasmer-control/Cargo.toml create mode 100644 crates/disasmer-control/src/lib.rs create mode 100644 crates/disasmer-coordinator/Cargo.toml create mode 100644 crates/disasmer-coordinator/src/agents.rs create mode 100644 crates/disasmer-coordinator/src/durable.rs create mode 100644 crates/disasmer-coordinator/src/lib.rs create mode 100644 crates/disasmer-coordinator/src/main.rs create mode 100644 crates/disasmer-coordinator/src/postgres_store.rs create mode 100644 crates/disasmer-coordinator/src/service.rs create mode 100644 crates/disasmer-coordinator/src/service/admin.rs create mode 100644 crates/disasmer-coordinator/src/service/artifacts.rs create mode 100644 crates/disasmer-coordinator/src/service/authenticated.rs create mode 100644 crates/disasmer-coordinator/src/service/authorization.rs create mode 100644 crates/disasmer-coordinator/src/service/debug.rs create mode 100644 crates/disasmer-coordinator/src/service/debug/validation.rs create mode 100644 crates/disasmer-coordinator/src/service/debug_requests.rs create mode 100644 crates/disasmer-coordinator/src/service/durable_runtime.rs create mode 100644 crates/disasmer-coordinator/src/service/keys.rs create mode 100644 crates/disasmer-coordinator/src/service/logs.rs create mode 100644 crates/disasmer-coordinator/src/service/main_runtime.rs create mode 100644 crates/disasmer-coordinator/src/service/nodes.rs create mode 100644 crates/disasmer-coordinator/src/service/panels.rs create mode 100644 crates/disasmer-coordinator/src/service/process_launch.rs create mode 100644 crates/disasmer-coordinator/src/service/processes.rs create mode 100644 crates/disasmer-coordinator/src/service/protocol.rs create mode 100644 crates/disasmer-coordinator/src/service/protocol/responses.rs create mode 100644 crates/disasmer-coordinator/src/service/quota.rs create mode 100644 crates/disasmer-coordinator/src/service/routing.rs create mode 100644 crates/disasmer-coordinator/src/service/signed_nodes.rs create mode 100644 crates/disasmer-coordinator/src/service/tcp.rs create mode 100644 crates/disasmer-coordinator/src/service/tests.rs create mode 100644 crates/disasmer-coordinator/src/service/wire_protocol.rs create mode 100644 crates/disasmer-coordinator/src/sessions.rs create mode 100644 crates/disasmer-core/Cargo.toml create mode 100644 crates/disasmer-core/src/artifact.rs create mode 100644 crates/disasmer-core/src/auth.rs create mode 100644 crates/disasmer-core/src/bundle.rs create mode 100644 crates/disasmer-core/src/capability.rs create mode 100644 crates/disasmer-core/src/checkpoint.rs create mode 100644 crates/disasmer-core/src/debug.rs create mode 100644 crates/disasmer-core/src/digest.rs create mode 100644 crates/disasmer-core/src/environment.rs create mode 100644 crates/disasmer-core/src/execution.rs create mode 100644 crates/disasmer-core/src/ids.rs create mode 100644 crates/disasmer-core/src/lib.rs create mode 100644 crates/disasmer-core/src/limits.rs create mode 100644 crates/disasmer-core/src/operator_panel.rs create mode 100644 crates/disasmer-core/src/policy.rs create mode 100644 crates/disasmer-core/src/project.rs create mode 100644 crates/disasmer-core/src/scheduler.rs create mode 100644 crates/disasmer-core/src/source.rs create mode 100644 crates/disasmer-core/src/transport.rs create mode 100644 crates/disasmer-core/src/vfs.rs create mode 100644 crates/disasmer-core/src/wire.rs create mode 100644 crates/disasmer-dap/Cargo.toml create mode 100644 crates/disasmer-dap/src/adapter.rs create mode 100644 crates/disasmer-dap/src/breakpoints.rs create mode 100644 crates/disasmer-dap/src/dap_protocol.rs create mode 100644 crates/disasmer-dap/src/demo_backend.rs create mode 100644 crates/disasmer-dap/src/main.rs create mode 100644 crates/disasmer-dap/src/runtime_client.rs create mode 100644 crates/disasmer-dap/src/runtime_client/debug_protocol.rs create mode 100644 crates/disasmer-dap/src/runtime_client/local_tools.rs create mode 100644 crates/disasmer-dap/src/runtime_client/transport.rs create mode 100644 crates/disasmer-dap/src/source.rs create mode 100644 crates/disasmer-dap/src/tests.rs create mode 100644 crates/disasmer-dap/src/variables.rs create mode 100644 crates/disasmer-dap/src/view_state.rs create mode 100644 crates/disasmer-dap/src/virtual_model.rs create mode 100644 crates/disasmer-macros/Cargo.toml create mode 100644 crates/disasmer-macros/src/lib.rs create mode 100644 crates/disasmer-node/Cargo.toml create mode 100644 crates/disasmer-node/src/assignment_runner.rs create mode 100644 crates/disasmer-node/src/assignment_runner/control_watcher.rs create mode 100644 crates/disasmer-node/src/assignment_runner/process_runner.rs create mode 100644 crates/disasmer-node/src/assignment_runner/tests.rs create mode 100644 crates/disasmer-node/src/assignment_runner/validation.rs create mode 100644 crates/disasmer-node/src/bin/disasmer-podman-smoke.rs create mode 100644 crates/disasmer-node/src/bin/disasmer-quic-smoke.rs create mode 100644 crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs create mode 100644 crates/disasmer-node/src/command_runner.rs create mode 100644 crates/disasmer-node/src/coordinator_session.rs create mode 100644 crates/disasmer-node/src/daemon.rs create mode 100644 crates/disasmer-node/src/debug_agent.rs create mode 100644 crates/disasmer-node/src/lib.rs create mode 100644 crates/disasmer-node/src/main.rs create mode 100644 crates/disasmer-node/src/node_identity.rs create mode 100644 crates/disasmer-node/src/source_snapshot.rs create mode 100644 crates/disasmer-node/src/task_artifacts.rs create mode 100644 crates/disasmer-node/src/task_reports.rs create mode 100644 crates/disasmer-node/src/windows_dev.rs create mode 100644 crates/disasmer-sdk/Cargo.toml create mode 100644 crates/disasmer-sdk/src/__private.rs create mode 100644 crates/disasmer-sdk/src/lib.rs create mode 100644 crates/disasmer-sdk/src/sdk_runtime.rs create mode 100644 crates/disasmer-sdk/src/task_args.rs create mode 100644 crates/disasmer-sdk/tests/macros.rs create mode 100644 crates/disasmer-wasm-runtime/Cargo.toml create mode 100644 crates/disasmer-wasm-runtime/src/lib.rs create mode 100644 crates/disasmer-wasm-runtime/src/task_host_linker.rs create mode 100644 crates/disasmer-wasm-runtime/src/tests.rs create mode 100644 docs/architecture.md create mode 100644 docs/artifacts.md create mode 100644 docs/debugging.md create mode 100644 docs/security.md create mode 100644 docs/self-hosting.md create mode 100644 docs/task-abi.md create mode 100644 examples/launch-build-demo/Cargo.toml create mode 100644 examples/launch-build-demo/README.md create mode 100644 examples/launch-build-demo/envs/linux/Containerfile create mode 100644 examples/launch-build-demo/envs/windows/Dockerfile create mode 100644 examples/launch-build-demo/fixture/hello-disasmer.c create mode 100644 examples/launch-build-demo/src/bin/sdk-product-runtime.rs create mode 100644 examples/launch-build-demo/src/build.rs create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rust-toolchain.toml create mode 100755 scripts/acceptance-cli-first.sh create mode 100755 scripts/acceptance-doc-contract-smoke.js create mode 100755 scripts/acceptance-environment-contract-smoke.js create mode 100755 scripts/acceptance-evidence-contract-smoke.js create mode 100755 scripts/acceptance-private.sh create mode 100755 scripts/acceptance-public.sh create mode 100644 scripts/acceptance-report-smoke.js create mode 100755 scripts/acceptance-report.js create mode 100644 scripts/agent-signing.js create mode 100755 scripts/artifact-download-smoke.js create mode 100644 scripts/artifact-export-smoke.js create mode 100644 scripts/cli-browser-login-flow-smoke.js create mode 100755 scripts/cli-error-exit-smoke.js create mode 100644 scripts/cli-first-contract-smoke.js create mode 100644 scripts/cli-happy-path-live-smoke.js create mode 100755 scripts/cli-install-smoke.js create mode 100755 scripts/cli-local-run-smoke.js create mode 100644 scripts/cli-login-smoke.js create mode 100644 scripts/cli-output-mode-smoke.js create mode 100644 scripts/code-size-guard.js create mode 100644 scripts/coordinator-wire.js create mode 100644 scripts/dap-client.js create mode 100644 scripts/dap-smoke.js create mode 100644 scripts/docs-smoke.js create mode 100644 scripts/flagship-demo-smoke.js create mode 100644 scripts/hostile-input-contract-smoke.js create mode 100755 scripts/node-attach-smoke.js create mode 100755 scripts/node-lifecycle-contract-smoke.js create mode 100644 scripts/node-signing.js create mode 100755 scripts/operator-panel-smoke.js create mode 100644 scripts/phase3-ledger.js create mode 100755 scripts/podman-backend-smoke.js create mode 100644 scripts/podman-test-env.js create mode 100755 scripts/prepare-public-release-dryrun.js create mode 100644 scripts/public-browser-login-contract-smoke.js create mode 100755 scripts/public-local-demo-matrix-smoke.js create mode 100644 scripts/public-private-boundary-smoke.js create mode 100644 scripts/public-release-dryrun-contract-smoke.js create mode 100755 scripts/public-release-dryrun-e2e.js create mode 100755 scripts/public-release-dryrun-final-evidence.js create mode 100755 scripts/public-release-dryrun-preflight.js create mode 100644 scripts/public-story-contract-smoke.js create mode 100755 scripts/publish-public-release-dryrun.js create mode 100755 scripts/quic-smoke.js create mode 100644 scripts/real-flagship-harness.js create mode 100755 scripts/release-blocker-smoke.js create mode 100755 scripts/release-source-scan.sh create mode 100644 scripts/resource-metering-contract-smoke.js create mode 100755 scripts/scheduler-placement-smoke.js create mode 100755 scripts/sdk-spawn-runtime-smoke.js create mode 100644 scripts/self-hosted-coordinator-smoke.js create mode 100755 scripts/source-preparation-smoke.js create mode 100644 scripts/tenant-isolation-contract-smoke.js create mode 100755 scripts/user-session-token-boundary-smoke.js create mode 100755 scripts/verify-public-split.sh create mode 100755 scripts/vscode-extension-smoke.js create mode 100755 scripts/vscode-f5-smoke.js create mode 100644 scripts/wasmtime-assignment-smoke.js create mode 100644 scripts/wasmtime-node-smoke.js create mode 100644 scripts/website-inventory-contract-smoke.js create mode 100755 scripts/windows-best-effort-smoke.js create mode 100755 scripts/windows-runner-smoke.js create mode 100755 scripts/windows-validation-contract-smoke.js create mode 100644 vscode-extension/extension.js create mode 100644 vscode-extension/package.json create mode 100644 vscode-extension/resources/disasmer.svg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a09c0df --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target/ +/.disasmer/ +**/.disasmer/ +/vscode-extension/node_modules/ +/private/*/Cargo.lock +/private/*/target/ +/scripts/containers-home/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e7f5400 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3045 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[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.18", + "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", + "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", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[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 = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "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 = "cranelift-assembler-x64" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.16.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" + +[[package]] +name = "cranelift-control" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" + +[[package]] +name = "cranelift-native" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +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 = "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", + "zeroize", +] + +[[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", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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" + +[[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", + "crypto-common 0.1.7", +] + +[[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 = "disasmer-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "disasmer-control", + "disasmer-core", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "wasmparser 0.245.1", +] + +[[package]] +name = "disasmer-control" +version = "0.1.0" +dependencies = [ + "serde_json", + "thiserror 1.0.69", + "ureq", +] + +[[package]] +name = "disasmer-coordinator" +version = "0.1.0" +dependencies = [ + "base64", + "disasmer-core", + "disasmer-wasm-runtime", + "postgres", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "wasmparser 0.245.1", +] + +[[package]] +name = "disasmer-core" +version = "0.1.0" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.3.4", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "syn", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "disasmer-dap" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "disasmer-control", + "disasmer-core", + "disasmer-node", + "serde_json", + "tempfile", +] + +[[package]] +name = "disasmer-macros" +version = "0.1.0" +dependencies = [ + "hex", + "proc-macro2", + "quote", + "serde_json", + "sha2 0.10.9", + "syn", +] + +[[package]] +name = "disasmer-node" +version = "0.1.0" +dependencies = [ + "base64", + "disasmer-control", + "disasmer-core", + "disasmer-wasm-runtime", + "libc", + "quinn", + "rcgen", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "tokio", + "wasmparser 0.245.1", +] + +[[package]] +name = "disasmer-sdk" +version = "0.1.0" +dependencies = [ + "base64", + "disasmer-control", + "disasmer-core", + "disasmer-macros", + "futures-executor", + "serde", + "serde_json", +] + +[[package]] +name = "disasmer-wasm-runtime" +version = "0.1.0" +dependencies = [ + "disasmer-core", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasmtime", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "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", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[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 = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[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 = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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 = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[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 = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "launch-build-demo" +version = "0.1.0" +dependencies = [ + "disasmer-sdk", + "futures-executor", + "serde", + "serde_json", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[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 = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "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-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "crc32fast", + "hashbrown 0.16.1", + "indexmap", + "memchr", +] + +[[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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "postgres" +version = "0.19.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd" +dependencies = [ + "bytes", + "fallible-iterator", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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 = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +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", +] + +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "smallvec", +] + +[[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", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[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 = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[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 = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "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.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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-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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[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 = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +dependencies = [ + "leb128fmt", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + +[[package]] +name = "wasmparser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasmtime" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" +dependencies = [ + "anyhow", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.16.1", + "indexmap", + "log", + "object", + "postcard", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmprinter", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f" + +[[package]] +name = "wasmtime-internal-core" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" +dependencies = [ + "hashbrown 0.16.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wast" +version = "252.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.252.0", +] + +[[package]] +name = "wat" +version = "1.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" +dependencies = [ + "wast", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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_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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-parser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.245.1", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "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", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3095f9b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +resolver = "2" +members = [ + "crates/disasmer-cli", + "crates/disasmer-control", + "crates/disasmer-coordinator", + "crates/disasmer-core", + "crates/disasmer-dap", + "crates/disasmer-macros", + "crates/disasmer-node", + "crates/disasmer-sdk", + "crates/disasmer-wasm-runtime", + "examples/launch-build-demo", +] + +[workspace.package] +edition = "2021" +license = "Apache-2.0 OR MIT" +repository = "https://git.michelpaulissen.com/michel/disasmer" + +[workspace.dependencies] +anyhow = "1.0" +base64 = "0.22" +clap = { version = "4.5", features = ["derive"] } +ed25519-dalek = "2" +futures-executor = "0.3" +getrandom = "0.3" +hex = "0.4" +libc = "0.2" +proc-macro2 = "1.0" +postgres = { version = "0.19", features = ["with-serde_json-1"] } +quinn = { version = "0.11.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] } +quote = "1.0" +rcgen = "0.14" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +syn = { version = "2.0", features = ["full"] } +tempfile = "3.10" +thiserror = "1.0" +ureq = { version = "2.12", default-features = false, features = ["tls"] } +tokio = { version = "1.52", features = ["io-util", "macros", "rt-multi-thread"] } +wasmtime = { version = "=43.0.2", default-features = false, features = ["async", "cranelift", "debug", "runtime", "std", "wat"] } +wasmparser = "0.245.1" diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json new file mode 100644 index 0000000..8c32bc8 --- /dev/null +++ b/DISASMER_PUBLIC_TREE.json @@ -0,0 +1,21 @@ +{ + "kind": "disasmer-filtered-public-tree", + "source_commit": "20b59dc46b72103c2f8a516c692b5cc3d54fab19", + "release_name": "dryrun-20b59dc46b72", + "filtered_out": [ + "private/**", + "experiments/**", + ".git", + "target", + "git-ignored source paths", + "root/*.md except README.md and SECURITY.md", + "**/.disasmer/**", + ".forgejo/**" + ], + "public_export": { + "host_neutral": true, + "include_forgejo_workflows": false + }, + "forgejo_host": "git.michelpaulissen.com", + "default_hosted_coordinator_endpoint": "https://disasmer.michelpaulissen.com" +} diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..45654bb --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,175 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..aa62566 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Disasmer contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..76475c7 --- /dev/null +++ b/README.md @@ -0,0 +1,421 @@ +# Disasmer + +Disasmer is a distributed Wasm runtime for source-debuggable, local-first execution. The flagship workflow is a build written as Rust source code: one virtual process, many virtual threads/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling. + +The MVP shape is: + +```text +examples/launch-build-demo/envs/linux/Containerfile +examples/launch-build-demo/src/build.rs +``` + +```rust +use disasmer::env; + +let linux = env!("linux"); +``` + +`envs//Containerfile` or `envs//Dockerfile` defines an environment named ``. Source code references that environment by logical name with `env!("name")`, not by runner label or machine name. + +Bundle metadata is inspectable before launch: + +```bash +disasmer bundle inspect --project examples/launch-build-demo +``` + +The inspection output includes discovered environments, selected input digests, default source-provider choices, and the bundle identity. Container images are not embedded by default. + +The development example lives in `examples/launch-build-demo`. Its coordinator +main prepares a node-local source snapshot, compiles a real static executable +with `cc` in the declared rootless-Podman environment, flushes the output file, +and passes its artifact handle to a downstream deterministic packaging task. + +Canonical public documentation covers the [architecture](docs/architecture.md), +[security model](docs/security.md), [task ABI](docs/task-abi.md), +[artifact semantics](docs/artifacts.md), [debugging](docs/debugging.md), and +[self-hosting](docs/self-hosting.md). + +## Quickstart + +Prerequisites for the local MVP path are a Rust toolchain, Node.js for smoke +scripts, rootless Podman for Linux environment materialization, and VS Code when +debugging through the extension. + +Build the public workspace and install the local command binaries: + +```bash +cargo build --workspace +cargo install --path crates/disasmer-cli --bin disasmer +cargo install --path crates/disasmer-node --bin disasmer-node +cargo install --path crates/disasmer-coordinator --bin disasmer-coordinator +cargo install --path crates/disasmer-dap --bin disasmer-debug-dap +``` + +Install or run the VS Code extension from this checkout: + +```bash +code --extensionDevelopmentPath "$(pwd)/vscode-extension" examples/launch-build-demo +``` + +For a persistent local install, copy or symlink `vscode-extension` into the VS +Code user extension directory under a versioned folder such as +`disasmer.disasmer-vscode-0.1.0`, then restart VS Code. + +Inspect and run the flagship project: + +```bash +disasmer bundle inspect --project examples/launch-build-demo +disasmer run --local --project examples/launch-build-demo build +``` + +For explicit process-boundary inspection, run the local services yourself: + +```bash +disasmer-coordinator --listen 127.0.0.1:7999 --allow-local-trusted-loopback +disasmer node attach --coordinator 127.0.0.1:7999 +disasmer run --local --coordinator 127.0.0.1:7999 --project examples/launch-build-demo build +``` + +`disasmer run --local` starts a loopback coordinator and user-attached local +node for the run when no coordinator address is supplied. `disasmer run [entry]` +selects an entrypoint such as `build`; `--project` overrides the project +directory. When the CLI has a hosted login and no local override is selected, +`disasmer run` uses the hosted coordinator. Use `--local` or +`--coordinator ` to force local coordinator mode. The +`--allow-local-trusted-loopback` switch is an explicit single-machine development +compatibility mode: it is rejected on non-loopback listeners and must not be +used for a shared or remotely reachable coordinator. + +The MVP keeps bundles inline in the existing 1 MiB control frame. The CLI +therefore supports raw Wasm modules up to approximately 696 KiB (712704 bytes) +after reserving space for the authenticated request and task metadata. It builds, +resolves, validates, and checks this boundary before creating the virtual +process. Larger bundles fail without leaving an active process; a larger-bundle +transport is explicitly post-MVP. + +In VS Code, open `examples/launch-build-demo`, start the `Disasmer: Launch +Virtual Process` configuration or press F5, inspect the Disasmer nodes, +processes, logs, artifacts, and inspector views, and use the ordinary debugger +controls for breakpoints, continue, pause, and restart. Pause is +participant-acknowledged; source stepping still fails explicitly instead of +simulating success. Restarting a terminal task rebuilds the bundle: a compatible +edit launches only that task under a fresh instance ID while preserving its +validated arguments, handles, environment, and clean VFS boundary. An active +task cannot be silently replaced, and an ABI-incompatible edit clearly requires +a whole-process restart. Artifact download +behavior is exercised by `node scripts/artifact-download-smoke.js`; final export +is explicit and should go through an attached receiver node or user-provided +storage integration rather than hidden coordinator storage. + +Cleanup for the local quickstart is ordinary process and artifact cleanup: stop +the coordinator process, stop attached node processes, remove any temporary +artifacts under `target/acceptance/`, and remove the local VS Code extension copy +or symlink if one was installed. + +## First-Run Diagnostics + +Use these messages as first checks before debugging infrastructure: + +- Missing nodes: attach a node with `disasmer node attach --coordinator ` or check the Disasmer Nodes view. +- Missing environments: add `envs//Containerfile` or `envs//Dockerfile`; the VS Code extension highlights unknown `env!("name")` references. +- Quota limits: hosted/community denials are returned before dispatch with a specific community tier reason. +- Unavailable artifacts: downloads fail before showing a link when retention, size, authorization, quota, or connectivity makes streaming impossible. +- Auth failures: browser login uses the device/browser flow, while agents and nodes use public-key identity and scoped enrollment grants. +- Failed debug freezes: all-stop reports the participant that could not freeze instead of claiming success. +- Source-provider capability gaps: source preparation stays pending until a node reports `SourceGit` or `SourceFilesystem`. + +## Repository Shape + +The public workspace contains the open-source contract layer: + +```text +crates/disasmer-core shared identities, policy traits, scheduling, VFS/artifacts +crates/disasmer-coordinator local coordinator state model +crates/disasmer-dap Debug Adapter Protocol adapter for VS Code/debug clients +crates/disasmer-macros #[disasmer::main] and #[disasmer::task] +crates/disasmer-node node backend interfaces and local node runtime +crates/disasmer-cli CLI command surface +crates/disasmer-sdk user-facing Rust SDK +scripts/verify-public-split.sh +``` + +Hosted-only policy code lives under `private/**`. The public split is verified by copying the repo without `private/**` and running the public workspace tests. + +Deployment names and authority names describe different things. The +**standalone Core coordinator** is the open-source self-hosted server. The +**Hosted coordinator** is the managed deployment that adds hosted policy around +Core. Within either deployment, **Client**, **Node**, **Identity**, and +**Operator** name authority lanes. “Public” and “private” describe source or +release visibility; they do not grant authority. + +## Hosted Coordinator + +Public CLI binaries default to the hosted coordinator at +`https://disasmer.michelpaulissen.com` unless local/self-hosted mode is +selected. The CLI asks the hosted coordinator to create a state-, nonce-, and +PKCE-bound login transaction, opens the returned Authentik authorization URL, +and polls with an opaque transaction secret. Authentik returns to the hosted +`/auth/callback`; provider codes and identity claims never pass through the CLI. +The hosted website for the MVP is deliberately barebones HTML with no CSS; +layout and UX polish are later work. + +The hosted coordinator combines private hosted policy code with the open-source +Core coordinator. Client traffic uses authenticated user sessions, Identity +traffic handles browser/OIDC login, Node traffic is signed by enrolled workers, +and privileged Operator actions require a separate private credential. It does +not give community users arbitrary hosted native commands or hosted containers. +Real work runs on attached nodes. + +Self-hosted users do not need the hosted website. They can run the public +coordinator and public node runtime from this repository, attach their own +nodes, and use the CLI/API for normal project, process, log, artifact, debug, +quota, and admin operations. + +The standalone Core coordinator uses strict Client authority by default. A +self-hosted operator supplies one scoped bootstrap session through protected +service configuration: + +```bash +DISASMER_SELF_HOSTED_SESSION_SECRET="$SELF_HOSTED_SESSION_SECRET" \ +DISASMER_SELF_HOSTED_TENANT=my-team \ +DISASMER_SELF_HOSTED_PROJECT=my-project \ +DISASMER_SELF_HOSTED_USER=me \ +disasmer-coordinator --listen 127.0.0.1:7999 +``` + +From the project directory, connect the CLI without placing the secret in a +process argument: + +```bash +printf '%s\n' "$SELF_HOSTED_SESSION_SECRET" | \ + disasmer auth connect-self-hosted \ + --coordinator 127.0.0.1:7999 \ + --tenant my-team --project-id my-project --user me \ + --session-secret-stdin +``` + +The CLI verifies the scoped session before writing `.disasmer/session.json`; on +Unix the file is mode `0600`. Enrollment-grant creation and ordinary Client +operations then use this authenticated session. Signed Node and Agent requests +remain separate authority lanes. + +The native Core transport is plaintext and therefore refuses non-loopback +listeners. For administration from another machine, create an authenticated SSH +tunnel to the coordinator loopback port and connect the CLI to the tunnel's +local `127.0.0.1` endpoint. Do not expose port 7999 directly. + +Release dry-run mechanics, Forgejo publication, filtered repository preparation, +deployment evidence, and public-release e2e commands are source-side release +operations, not the product quickstart. They live in +`public_release_dryrun.md`. + +## Coordinator State + +The coordinator separates durable project and identity state from live runtime state. Tenants, users, projects, node identities, credentials, source-provider configuration, durable service policy records, and explicit project permissions can be stored in Postgres. Active virtual processes, live virtual threads, scheduler state, debug epochs, ephemeral VFS manifests, and transient artifact locations remain in coordinator memory for the MVP and are not represented in the Postgres schema. + +Process lifecycle is independent of task count. `disasmer process list` and +`disasmer process status` read the live virtual-process registry directly. +`disasmer process cancel --yes` requests cooperative cancellation and leaves the +process visible as `cancelling` until its runtime exits. `disasmer process abort +--yes` is the explicit forced operation: it terminates the coordinator-side +process identity, signals active node work to stop, and immediately releases the +single-process slot. VS Code uses the same live registry for its sidebar and +offers Attach, Restart, Cancel, or Abort when F5 finds an existing process. + +The local workspace and the hosted **Coordinator Project** are distinct. A +Coordinator Project is the identity, authorization, quota, and one-active-process +boundary; a local workspace is the source/bundle launch target. When another +workspace launch is occupying the same Coordinator Project, VS Code avoids +blindly attaching with the wrong source and offers different-project actions. + +## Local-First Builds + +Disasmer is designed so local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes. + +`flush()` publishes metadata and visibility information. It makes produced artifacts visible to downstream tasks without implying durable coordinator storage. + +`sync()` is explicit. It may move bytes to another node or user-provided storage according to code or configured policy. + +User-provided storage/export integrations are ordinary project code or external +commands, such as publishing through a CLI. Disasmer records metadata and +coordinates capability, scheduling, and transfer decisions; it does not provide +or manage an explicit artifact-store feature as part of the MVP. + +Artifacts are best-effort retained on nodes by default. If unsynced node-local bytes are garbage collected or the retaining node is lost, the artifact becomes unavailable instead of silently recovering from coordinator storage. + +Artifact downloads are created only when current retention, size, authorization, and community tier accounting allow it. Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked, and streaming usage is charged before and during transfer. + +On Linux nodes, Containerfile and Dockerfile environments are materialized with rootless user Podman. The node runtime has a concrete runner path for `podman build` followed by `podman run`; tests use a recording runner, while real attached nodes can use the `std::process::Command` runner. A local build from an attached local checkout uses a node-local bind mount into the selected environment; the coordinator does not create a full-repo tarball or route compiler file reads. Command output is associated with the virtual thread that started it and can be staged into the VFS artifact namespace. `node scripts/wasmtime-node-smoke.js` builds the launch example for `wasm32-unknown-unknown`, runs its exported task through the node Wasmtime runtime, and verifies a Wasm task can invoke a native command through the versioned `disasmer.command_run_v1` host capability without moving command execution into the hosted coordinator. + +## Nodes And Trust + +Users attach their own nodes for real work. The hosted coordinator is a control plane for identity, rendezvous, scheduling metadata, debug sessions, logs, artifact metadata, and operator state. The community tier does not provide arbitrary hosted native commands or hosted containers. + +Agent and worker automation should use enrolled public-key identity. User OAuth or browser session tokens are not task credentials and should not be passed to nodes. + +```bash +disasmer login +disasmer login --browser +disasmer agent enroll --public-key +DISASMER_AGENT_PRIVATE_KEY= disasmer run --non-interactive build +disasmer run --local --non-interactive build +disasmer node attach --enrollment-grant --public-key +disasmer-node --coordinator https://disasmer.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready +``` + +`disasmer login` uses a short-lived human device flow and does not ask users to paste long-lived secrets. `disasmer login --browser` opens the server-provided authorization URL, polls the hosted transaction, and stores only normalized CLI-session metadata in `.disasmer/session.json`; provider authorization codes, OAuth tokens, and identity claims are not accepted or persisted by the CLI. `disasmer login --browser --plan` is the diagnostic JSON login plan path. `--non-interactive` never opens a browser; `disasmer login --browser --non-interactive` and an unauthenticated implicit hosted `disasmer run --non-interactive` fail with an authentication-category report and next actions instead of prompting or guessing. Node attach exchanges a short-lived enrollment grant for a scoped long-lived node identity and then exits; a long-lived `disasmer-node --worker` process must be running for coordinator-side launches and DAP live-services debugging to place real command/container work on that node. + +Hosted agent public keys are project-scoped records: a signed-in user can +register, list, rotate, and revoke an agent key without giving the agent browser +or OAuth credentials. An enrolled agent can run CLI commands non-interactively +with `DISASMER_AGENT_PRIVATE_KEY`; the CLI derives the registered public key, +signs workflow requests, and records the public-key fingerprint in its run plan +instead of starting a browser flow. `DISASMER_AGENT_PUBLIC_KEY` may identify or +cross-check the registered key, but cannot authenticate without the private key. + +`disasmer node attach` auto-detects OS, architecture, command/container, +VFS/artifact, source-provider, and environment capabilities. `--cap ` is +available as an override for unusual local setups, not as required normal +configuration. + +For local process-boundary testing, the public workspace includes a TCP JSON-line coordinator service and a node runtime binary: + +```bash +cargo run -p disasmer-coordinator -- --listen 127.0.0.1:0 +cargo run -p disasmer-node -- --coordinator --worker \ + --project-root examples/launch-build-demo +``` + +`scripts/real-flagship-harness.js` starts those as separate long-lived processes. The real `build` Wasm entrypoint spawns its named Wasm tasks through the coordinator; the Linux task selects `env!("linux")`, runs its command through rootless Podman against the node-local checkout, publishes content-addressed bytes, and reports task and artifact metadata. The download and export smokes reuse that workflow and then retrieve the retained bytes through an authorized reverse stream. `scripts/wasmtime-assignment-smoke.js` separately proves cooperative cancellation observed by task code and forced abort of uncooperative Wasm/native work. + +For self-hosted local clouds, trusted teams, or VPN deployments, run the public +coordinator on the chosen listen address and attach team-owned nodes with their +public keys. This path uses the open-source coordinator, scheduler, node +heartbeat, task metadata, retained-node downloads, and direct node-to-node export +planning without private hosted OIDC or community tier policy modules. +`node scripts/self-hosted-coordinator-smoke.js` exercises that public path with +two trusted Linux nodes. + +## Debugging + +The VS Code extension contributes the normal `disasmer` debug type, refreshes bundle metadata before launch, and starts the DAP adapter. Bundle probe ownership comes from actual `#[disasmer::main]` and `#[disasmer::task]` declarations and their declared names, not function-name heuristics. In the verified local-services path, an executing Wasm probe reports a breakpoint hit, every active participant acknowledges the freeze, and only then does DAP emit an all-stop `stopped` event. Runtime child tasks appear as dynamic virtual threads. Continue requests a real coordinator resume. Stack, task-argument, live task-handle, native-command status, and output data come from node acknowledgements; values the runtime did not report are labeled unavailable instead of being inferred or fabricated. Each dispatched task captures a clean entry checkpoint containing its TaskSpec, bundle, ABI, arguments, environment digest, VFS epoch, and required-artifact manifest. After a terminal failure, `restartFrame` rebuilds the current source and relaunches only a boundary-compatible task with a fresh instance ID; an ABI-incompatible edit requires a whole virtual-process restart without mutating the existing process. Active-task restart and arbitrary source stepping fail explicitly. Disasmer-specific side views expose nodes, virtual processes, logs, artifacts, and inspector state alongside the normal debugger UI. + +Live-services launch now uses the same bundle builder, Wasm entrypoint TaskSpec, +probe plan, worker placement, Debug Epoch observation, continuation, and restart +code as local-services; only ownership of the already-attached worker differs. +That path remains labeled integration-verified until the final revision-bound +hosted E2E is recorded. F5 and DAP acceptance therefore continue to exercise +local services as well as the strict hosted deployment rather than treating +deployment configuration alone as live proof. + +The built-in operator panel preview is rendered by the coordinator from live +process, task, log, and artifact metadata. It uses typed widgets only, keeps +program UI events scoped and rate-limited, and keeps control-plane actions such +as debug, cancel, restart, and artifact download available when program UI events +are disabled. + +## Windows + +Windows node support uses the same node protocol and capability model as Linux. MVP Windows command execution is user-attached development execution, labeled `windows-command-dev`. Production-grade managed Windows sandboxing is behind an explicit backend stub until it is implemented and validated independently. When the acceptance report shows `windows_validation: "not-run"`, Windows support is best-effort and unvalidated for that release; the public smoke only verifies protocol, placement, metadata, and download behavior for a Windows-capable node identity. + +Real Windows validation runs through the manual Forgejo workflow +`.forgejo/workflows/windows-validation.yml` when the intermittent Windows runner is +online. That workflow records `windows_validation: "forgejo-windows-runner"` in +the acceptance report, runs `disasmer node attach` against a coordinator, starts a +Windows node process, executes a simple `windows-command-dev` command, publishes +artifact metadata, checks Windows placement, and verifies the debugger can show a +Windows virtual thread. + +## Source Providers + +Git is an optional source-provider module included by default. The VFS core depends on source-provider manifests and snapshot handles, not on Git internals. Non-Git source providers can implement the public source-provider interface and run snapshot preparation as node work instead of requiring coordinator filesystem access. + +Source preparation is scheduled as node work. The coordinator can return a +pending source-preparation status while waiting for any capable node, then assign +the work after a node reports `SourceGit` or `SourceFilesystem` capability; it +does not need checkout or provider access itself. + +Source-provider manifests are validated as public input and must declare a +local-first transfer policy: local source bytes stay node-local, the coordinator +does not receive source bytes by default, and remote preparation uses required +content or explicit snapshot chunks rather than a full-repo tarball. + +## Verification + +Run the public workspace checks: + +```bash +scripts/acceptance-public.sh +``` + +Run the CLI-first non-e2e gate before any final public-release e2e attempt: + +```bash +scripts/acceptance-cli-first.sh +``` + +This gate composes the CLI/API, local-services, self-hosted, hosted-compat, +debugger, artifact, safety, and public dry-run contract checks without invoking +`public-release-dryrun-e2e.js` or the final evidence verifier. Leave +`DISASMER_PUBLIC_RELEASE_DRYRUN_E2E` and `DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL` +unset until the CLI-first criteria are otherwise implemented to pass. + +Or run the individual public checks: + +```bash +cargo test --workspace +node scripts/acceptance-report-smoke.js +node scripts/public-private-boundary-smoke.js +node scripts/release-blocker-smoke.js +node scripts/docs-smoke.js +node scripts/public-release-dryrun-contract-smoke.js +node scripts/wasmtime-node-smoke.js +node scripts/podman-backend-smoke.js +node scripts/vscode-extension-smoke.js +node scripts/vscode-f5-smoke.js +node scripts/node-attach-smoke.js +node scripts/wasmtime-assignment-smoke.js +node scripts/cli-local-run-smoke.js +node scripts/artifact-download-smoke.js +node scripts/artifact-export-smoke.js +node scripts/operator-panel-smoke.js +node scripts/source-preparation-smoke.js +node scripts/scheduler-placement-smoke.js +node scripts/windows-best-effort-smoke.js +node scripts/quic-smoke.js +node scripts/flagship-demo-smoke.js +node scripts/dap-smoke.js +node scripts/prepare-public-release-dryrun.js +scripts/verify-public-split.sh +``` + +When the Forgejo Windows runner is online, run the manual `Windows validation` +workflow as the release Windows gate. The local equivalent on a Windows machine is: + +```powershell +$env:DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner" +node scripts/acceptance-report.js windows +cargo fmt --all --check +cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution +node scripts/windows-runner-smoke.js +``` + +Run private hosted policy checks separately: + +```bash +scripts/acceptance-private.sh +``` + +The private hosted gate includes `hosted-client-compat-smoke.js`, which starts +the hosted dry-run service locally and verifies that the released Client CLI and +signed Node runtime can attach, launch work through coordinator task assignment, +and publish debug/log/artifact metadata through the hosted Client protocol +boundary. + +Both acceptance scripts write an environment report under `target/acceptance/` +with the commit SHA, toolchain versions, OS/kernel, Podman/Postgres discovery, +browser/VS Code harness metadata, and Windows-validation status. The report +records Podman as `available` only when rootless Podman is discoverable; if it is +missing or not rootless, Linux Podman backend behavior is marked `incomplete` +with the reason and the Podman backend smoke blocks acceptance with the same +incomplete reason. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..44df78b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security policy + +Disasmer is pre-release software that executes untrusted Wasm and can delegate +explicit host capabilities to attached nodes. Security reports are welcome even +when the affected behavior has not shipped in a numbered release. + +## Supported versions + +Until the first public release, only the current `main` branch is supported. +After releases begin, this table will identify supported release lines. + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. Email +`ops@michelpaulissen.com` with: + +- the affected commit or release; +- the relevant deployment mode (hosted, self-hosted, or attached node); +- reproduction steps or a minimal proof of concept; +- the security boundary or tenant scope that was crossed; and +- any known mitigations. + +Please avoid accessing other users' data, disrupting hosted services, or +retaining secrets while testing. We will acknowledge receipt, coordinate a fix +and disclosure, and credit reporters who want to be named. No response-time or +bounty commitment is made before the first public release. diff --git a/crates/disasmer-cli/Cargo.toml b/crates/disasmer-cli/Cargo.toml new file mode 100644 index 0000000..9562125 --- /dev/null +++ b/crates/disasmer-cli/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "disasmer-cli" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "disasmer" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +base64.workspace = true +clap.workspace = true +disasmer-core = { path = "../disasmer-core" } +disasmer-control = { path = "../disasmer-control" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +wasmparser.workspace = true diff --git a/crates/disasmer-cli/src/admin.rs b/crates/disasmer-cli/src/admin.rs new file mode 100644 index 0000000..254d38e --- /dev/null +++ b/crates/disasmer-cli/src/admin.rs @@ -0,0 +1,236 @@ +use std::path::PathBuf; + +use anyhow::Result; +use disasmer_core::admin_request_proof; +use serde_json::{json, Value}; + +use crate::client::JsonLineSession; +use crate::project::project_init_report; +use crate::tools::{command_nonce, unix_timestamp_seconds}; +use crate::{ + confirmation_required_report, AdminBootstrapArgs, AdminStatusArgs, AdminSuspendTenantArgs, + ProjectInitArgs, +}; + +pub(crate) fn admin_status_report(args: AdminStatusArgs) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let tenant = args.scope.tenant.clone(); + let user = args.scope.user.clone(); + let admin_token = admin_token_for_request(args.admin_token.as_deref())?; + let admin_nonce = command_nonce("admin-status"); + let issued_at_epoch_seconds = unix_timestamp_seconds(); + let admin_proof = admin_request_proof( + &admin_token, + "admin_status", + &tenant, + &user, + &tenant, + &admin_nonce, + issued_at_epoch_seconds, + ); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "admin_status", + "tenant": tenant, + "actor_user": user, + "admin_proof": admin_proof, + "admin_nonce": admin_nonce, + "issued_at_epoch_seconds": issued_at_epoch_seconds, + }))?; + return Ok(json!({ + "command": "admin status", + "coordinator": coordinator, + "tenant": tenant, + "user": user, + "suspended": response + .get("suspended") + .cloned() + .unwrap_or(json!(false)), + "response": response, + "safe_default": "read_only", + "private_website_required": false, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "admin status", + "mode": "self_hosted_local", + "safe_default": "read_only", + "private_website_required": false, + })) +} + +pub(crate) fn admin_bootstrap_report(args: AdminBootstrapArgs, cwd: PathBuf) -> Result { + let scope = args.scope.clone(); + let new_project = args.scope.project.clone(); + let project_init = project_init_report( + ProjectInitArgs { + scope: args.scope, + new_project, + name: args.name, + yes: args.yes, + }, + cwd, + )?; + let coordinator = scope + .coordinator + .clone() + .unwrap_or_else(|| "".to_owned()); + let tenant = scope.tenant.clone(); + let project = scope.project.clone(); + let user = scope.user.clone(); + Ok(json!({ + "command": "admin bootstrap", + "mode": if scope.coordinator.is_some() { "public_coordinator_api" } else { "self_hosted_local" }, + "tenant": tenant.clone(), + "project": project.clone(), + "user": user, + "coordinator": scope.coordinator, + "private_website_required": false, + "self_hosted_cli_only": true, + "project_config_written": project_init + .get("project_config_written") + .cloned() + .unwrap_or(json!(false)), + "project_init": project_init, + "admin_surfaces": { + "coordinator": "disasmer-coordinator", + "project": "disasmer project init/status/list/select", + "node": "disasmer node enroll/list/status/revoke", + "process": "disasmer run/process status/process restart/process cancel", + "logs": "disasmer logs", + "artifacts": "disasmer artifact list/download/export", + "quota": "disasmer quota status", + "policy": "disasmer admin status/suspend-tenant", + }, + "bootstrap_sequence": [ + { + "step": "start_self_hosted_coordinator", + "command": "disasmer-coordinator --listen 127.0.0.1:0", + "private_website_required": false, + }, + { + "step": "create_or_link_project", + "command": "disasmer project init --yes", + "completed": true, + "private_website_required": false, + }, + { + "step": "create_node_enrollment_grant", + "command": format!( + "disasmer node enroll --coordinator {coordinator} --tenant {} --project-id {}", + tenant, project + ), + "private_website_required": false, + }, + { + "step": "attach_worker_node", + "command": format!( + "disasmer node attach --coordinator {coordinator} --tenant {} --project-id {} --worker", + tenant, project + ), + "private_website_required": false, + }, + { + "step": "run_process", + "command": format!( + "disasmer run --coordinator {coordinator} --tenant {} --project-id {}", + tenant, project + ), + "private_website_required": false, + }, + { + "step": "inspect_status_logs_artifacts", + "commands": [ + "disasmer process status", + "disasmer task list", + "disasmer logs", + "disasmer artifact list", + "disasmer quota status", + ], + "private_website_required": false, + }, + { + "step": "revoke_access", + "command": "disasmer admin revoke-node --node --yes", + "private_website_required": false, + } + ], + })) +} + +pub(crate) fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result { + let tenant = args + .target_tenant + .unwrap_or_else(|| args.scope.tenant.clone()); + if !args.yes { + return Ok(confirmation_required_report( + "admin suspend-tenant", + "suspend_tenant", + json!({ + "coordinator": args.scope.coordinator, + "actor_tenant": args.scope.tenant, + "actor_user": args.scope.user, + "target_tenant": tenant, + }), + "disasmer admin suspend-tenant --yes".to_owned(), + )); + } + if let Some(coordinator) = &args.scope.coordinator { + let actor_tenant = args.scope.tenant.clone(); + let actor_user = args.scope.user.clone(); + let admin_token = admin_token_for_request(args.admin_token.as_deref())?; + let admin_nonce = command_nonce("admin-suspend-tenant"); + let issued_at_epoch_seconds = unix_timestamp_seconds(); + let admin_proof = admin_request_proof( + &admin_token, + "suspend_tenant", + &actor_tenant, + &actor_user, + &tenant, + &admin_nonce, + issued_at_epoch_seconds, + ); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(json!({ + "type": "suspend_tenant", + "tenant": actor_tenant, + "actor_user": actor_user, + "target_tenant": tenant, + "admin_proof": admin_proof, + "admin_nonce": admin_nonce, + "issued_at_epoch_seconds": issued_at_epoch_seconds, + }))?; + return Ok(json!({ + "command": "admin suspend-tenant", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "tenant": tenant, + "actor_tenant": actor_tenant, + "actor_user": actor_user, + "suspended": response.get("type").and_then(Value::as_str) == Some("tenant_suspended"), + "private_website_required": false, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "admin suspend-tenant", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "tenant": tenant, + "private_website_required": false, + })) +} + +fn admin_token_for_request(explicit: Option<&str>) -> Result { + explicit + .map(str::to_owned) + .or_else(|| std::env::var("DISASMER_ADMIN_TOKEN").ok()) + .filter(|token| !token.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "admin command requires --admin-token or DISASMER_ADMIN_TOKEN for coordinator requests" + ) + }) +} diff --git a/crates/disasmer-cli/src/agent.rs b/crates/disasmer-cli/src/agent.rs new file mode 100644 index 0000000..447f5ec --- /dev/null +++ b/crates/disasmer-cli/src/agent.rs @@ -0,0 +1,17 @@ +use disasmer_core::Digest; +use serde::Serialize; + +use crate::AgentEnrollArgs; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct AgentEnrollmentPlan { + pub(crate) public_key_fingerprint: Digest, + pub(crate) browser_interaction_required_each_run: bool, +} + +pub(crate) fn agent_enrollment_plan(args: AgentEnrollArgs) -> AgentEnrollmentPlan { + AgentEnrollmentPlan { + public_key_fingerprint: Digest::sha256(args.public_key), + browser_interaction_required_each_run: false, + } +} diff --git a/crates/disasmer-cli/src/artifact.rs b/crates/disasmer-cli/src/artifact.rs new file mode 100644 index 0000000..def5132 --- /dev/null +++ b/crates/disasmer-cli/src/artifact.rs @@ -0,0 +1,547 @@ +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; +use std::io::Write; +use std::path::Path; +use std::time::{Duration, Instant}; + +use crate::client::{ + authenticated_or_local_trusted_request, list_task_events_if_available_with_session, + JsonLineSession, +}; +use crate::config::StoredCliSession; +use crate::errors::cli_error_summary_for_category; +use crate::process_events::{ + artifact_download_grant_disclosures, artifact_download_session_summary, + artifact_export_plan_summary, artifact_response_machine_error, artifact_summaries, +}; +use crate::{ArtifactDownloadArgs, ArtifactExportArgs, ArtifactListArgs}; + +pub(crate) const DEFAULT_ARTIFACT_EXPORT_MAX_BYTES: u64 = 64 * 1024 * 1024; +const ARTIFACT_DOWNLOAD_CONTROL_CHUNK_BYTES: u64 = 256 * 1024; + +#[cfg(test)] +pub(crate) fn artifact_list_report(args: ArtifactListArgs) -> Result { + artifact_list_report_with_session(args, None) +} + +pub(crate) fn artifact_list_report_with_session( + args: ArtifactListArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + let events = list_task_events_if_available_with_session( + args.scope.coordinator.as_deref(), + &args.scope, + args.process.clone(), + stored_session, + )?; + let artifacts = artifact_summaries(events.as_ref()); + Ok(json!({ + "command": "artifact list", + "process": args.process, + "source": "task_events", + "artifacts": artifacts, + "default_durable_store_assumed": false, + "events": events, + })) +} + +#[cfg(test)] +pub(crate) fn artifact_download_report(args: ArtifactDownloadArgs) -> Result { + artifact_download_report_with_session(args, None) +} + +pub(crate) fn artifact_download_report_with_session( + args: ArtifactDownloadArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "create_artifact_download_link", + "artifact": args.artifact, + "max_bytes": args.max_bytes, + }), + json!({ + "type": "create_artifact_download_link", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "artifact": args.artifact, + "max_bytes": args.max_bytes, + }), + )?)?; + let download_session = artifact_download_session_summary(&response); + let grant_disclosures = artifact_download_grant_disclosures(&response); + let local_download = match &args.to { + Some(path) + if response.get("type").and_then(Value::as_str) + == Some("artifact_download_link") => + { + download_link_to_path( + &mut session, + coordinator, + &args.scope, + &args.artifact, + args.max_bytes, + path, + &response, + stored_session, + )? + } + Some(path) => json!({ + "status": "download_link_failed", + "local_path": path, + "local_bytes_written_by_cli": false, + "machine_error": artifact_response_machine_error( + &response, + "coordinator rejected artifact download", + "connectivity" + ), + }), + None => Value::Null, + }; + return Ok(json!({ + "command": "artifact download", + "coordinator": coordinator, + "artifact": args.artifact, + "max_bytes": args.max_bytes, + "to": args.to, + "download_session": download_session, + "local_download": local_download, + "grant_disclosures": grant_disclosures, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "artifact download", + "status": "requires_coordinator", + "artifact": args.artifact, + "max_bytes": args.max_bytes, + "to": args.to, + "download_session": { + "status": "requires_coordinator", + "link_issued": false, + "explicit_user_action_required": true, + "machine_error": cli_error_summary_for_category( + "connectivity", + "artifact download requires a coordinator" + ), + }, + "grant_disclosures": [], + })) +} + +#[cfg(test)] +pub(crate) fn artifact_export_report(args: ArtifactExportArgs) -> Result { + artifact_export_report_with_session(args, None) +} + +pub(crate) fn artifact_export_report_with_session( + args: ArtifactExportArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "export_artifact_to_node", + "artifact": args.artifact, + "receiver_node": args.receiver_node, + "direct_connectivity": true, + "failure_reason": "", + }), + json!({ + "type": "export_artifact_to_node", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "artifact": args.artifact, + "receiver_node": args.receiver_node, + "direct_connectivity": true, + "failure_reason": "", + }), + )?)?; + let mut export_plan = artifact_export_plan_summary(&response, &args.to); + let local_export = if response.get("type").and_then(Value::as_str) + == Some("artifact_export_plan") + { + artifact_export_local_write_followup(&mut session, coordinator, &args, stored_session)? + } else { + json!({ + "status": "skipped", + "explicit_user_action": true, + "local_path": &args.to, + "local_bytes_written_by_cli": false, + "machine_error": artifact_response_machine_error( + &response, + "coordinator rejected artifact export", + "connectivity" + ), + }) + }; + apply_local_export_summary(&mut export_plan, &local_export); + let grant_disclosures = local_export + .get("grant_disclosures") + .cloned() + .unwrap_or_else(|| json!([])); + return Ok(json!({ + "command": "artifact export", + "coordinator": coordinator, + "artifact": args.artifact, + "to": args.to, + "receiver_node": args.receiver_node, + "export_plan": export_plan, + "local_export": local_export, + "grant_disclosures": grant_disclosures, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "artifact export", + "status": "requires_coordinator", + "artifact": args.artifact, + "to": args.to, + "receiver_node": args.receiver_node, + "export_plan": { + "status": "requires_coordinator", + "explicit_user_action": true, + "local_bytes_written_by_cli": false, + "default_durable_store_assumed": false, + "machine_error": cli_error_summary_for_category( + "connectivity", + "artifact export requires a coordinator" + ), + }, + "grant_disclosures": [], + })) +} + +fn artifact_export_local_write_followup( + session: &mut JsonLineSession, + coordinator: &str, + args: &ArtifactExportArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + let link_response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "create_artifact_download_link", + "artifact": args.artifact, + "max_bytes": DEFAULT_ARTIFACT_EXPORT_MAX_BYTES, + }), + json!({ + "type": "create_artifact_download_link", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "artifact": args.artifact, + "max_bytes": DEFAULT_ARTIFACT_EXPORT_MAX_BYTES, + }), + )?)?; + if link_response.get("type").and_then(Value::as_str) != Some("artifact_download_link") { + return Ok(json!({ + "status": "download_link_failed", + "explicit_user_action": true, + "local_path": &args.to, + "local_bytes_written_by_cli": false, + "content_bytes_available": false, + "download_session": artifact_download_session_summary(&link_response), + "grant_disclosures": [], + "machine_error": artifact_response_machine_error( + &link_response, + "coordinator rejected artifact download for local export", + "connectivity" + ), + "link_response": link_response, + })); + } + download_link_to_path( + session, + coordinator, + &args.scope, + &args.artifact, + DEFAULT_ARTIFACT_EXPORT_MAX_BYTES, + &args.to, + &link_response, + stored_session, + ) +} + +#[allow(clippy::too_many_arguments)] +fn download_link_to_path( + session: &mut JsonLineSession, + coordinator: &str, + scope: &crate::CliScopeArgs, + artifact: &str, + max_bytes: u64, + destination: &Path, + link_response: &Value, + stored_session: Option<&StoredCliSession>, +) -> Result { + let download_session = artifact_download_session_summary(link_response); + let grant_disclosures = artifact_download_grant_disclosures(link_response); + let token_digest = link_response + .pointer("/link/scoped_token_digest") + .cloned() + .context("artifact download link did not include a scoped token digest")?; + let expected_digest: disasmer_core::Digest = serde_json::from_value( + link_response + .pointer("/link/artifact_digest") + .cloned() + .context("artifact download link omitted the published digest")?, + )?; + let expected_size = link_response + .pointer("/link/artifact_size_bytes") + .and_then(Value::as_u64) + .context("artifact download link omitted the published size")?; + if expected_size > max_bytes { + return Ok(json!({ + "status": "download_limit_failed", + "local_path": destination, + "local_bytes_written_by_cli": false, + "content_bytes_available": false, + "download_session": download_session, + "grant_disclosures": grant_disclosures, + "machine_error": cli_error_summary_for_category( + "quota", + &format!("artifact size {expected_size} exceeds requested limit {max_bytes}") + ), + })); + } + + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + let mut temporary = tempfile::Builder::new() + .prefix(".disasmer-download-") + .tempfile_in(parent) + .with_context(|| { + format!( + "failed to create a bounded download in {}", + parent.display() + ) + })?; + let started = Instant::now(); + let mut received_bytes = 0_u64; + let mut hasher = Sha256::new(); + let stream_response = loop { + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "open_artifact_download_stream", + "artifact": artifact, + "max_bytes": max_bytes, + "token_digest": token_digest, + "chunk_bytes": expected_size.clamp(1, ARTIFACT_DOWNLOAD_CONTROL_CHUNK_BYTES), + }), + json!({ + "type": "open_artifact_download_stream", + "tenant": scope.tenant, + "project": scope.project, + "actor_user": scope.user, + "artifact": artifact, + "max_bytes": max_bytes, + "token_digest": token_digest, + "chunk_bytes": expected_size.clamp(1, ARTIFACT_DOWNLOAD_CONTROL_CHUNK_BYTES), + }), + )?)?; + if response.get("type").and_then(Value::as_str) != Some("artifact_download_stream") { + break response; + } + if response + .get("content_bytes_available") + .and_then(Value::as_bool) + == Some(true) + { + let offset = response + .get("content_offset") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX); + if offset != received_bytes { + break json!({ + "type": "error", + "message": format!( + "artifact reverse stream returned offset {offset}, expected {received_bytes}" + ), + }); + } + let Some(content_base64) = response.get("content_base64").and_then(Value::as_str) + else { + break json!({ + "type": "error", + "message": "artifact reverse stream marked bytes available without content", + }); + }; + let content = BASE64_STANDARD + .decode(content_base64) + .context("artifact download stream returned invalid base64 content")?; + received_bytes = received_bytes + .checked_add(content.len() as u64) + .context("artifact download byte count overflowed")?; + if received_bytes > expected_size || received_bytes > max_bytes { + break json!({ + "type": "error", + "message": "artifact reverse stream exceeded its published size or CLI limit", + }); + } + temporary + .write_all(&content) + .context("write bounded artifact download")?; + hasher.update(&content); + if response.get("content_eof").and_then(Value::as_bool) == Some(true) { + break response; + } + continue; + } + if started.elapsed() >= Duration::from_secs(120) { + break json!({ + "type": "error", + "message": "timed out waiting for the retaining node to reverse-stream artifact bytes", + }); + } + std::thread::sleep(Duration::from_millis(25)); + }; + if stream_response.get("type").and_then(Value::as_str) != Some("artifact_download_stream") { + return Ok(json!({ + "status": "download_stream_failed", + "explicit_user_action": true, + "local_path": destination, + "local_bytes_written_by_cli": false, + "content_bytes_available": false, + "download_session": download_session, + "grant_disclosures": grant_disclosures, + "machine_error": artifact_response_machine_error( + &stream_response, + "coordinator rejected artifact download stream", + "connectivity" + ), + "stream": artifact_stream_summary(&stream_response), + })); + } + let actual_digest_hex = format!("{:x}", hasher.finalize()); + let actual_digest = + disasmer_core::Digest::from_sha256_hex(&actual_digest_hex).map_err(anyhow::Error::msg)?; + if received_bytes != expected_size || actual_digest != expected_digest { + return Ok(json!({ + "status": "download_integrity_failed", + "explicit_user_action": true, + "local_path": destination, + "local_bytes_written_by_cli": false, + "content_bytes_available": false, + "download_session": download_session, + "grant_disclosures": grant_disclosures, + "machine_error": cli_error_summary_for_category( + "program", + &format!( + "artifact download digest/size mismatch: received {received_bytes} bytes with {actual_digest}, expected {expected_size} bytes with {expected_digest}" + ) + ), + "stream": artifact_stream_summary(&stream_response), + })); + } + temporary + .as_file() + .sync_all() + .context("sync verified artifact download")?; + temporary.persist_noclobber(destination).map_err(|error| { + anyhow::anyhow!( + "refusing to replace artifact download destination {}: {}", + destination.display(), + error.error + ) + })?; + + Ok(json!({ + "status": "local_bytes_written", + "explicit_user_action": true, + "local_path": destination, + "local_bytes_written_by_cli": true, + "bytes_written": received_bytes, + "verified_digest": actual_digest, + "published_digest": expected_digest, + "content_bytes_available": true, + "content_source": stream_response.get("content_source").cloned().unwrap_or(Value::Null), + "download_session": download_session, + "grant_disclosures": grant_disclosures, + "stream": artifact_stream_summary(&stream_response), + })) +} + +pub(crate) fn artifact_stream_summary(response: &Value) -> Value { + let status = response + .get("type") + .and_then(Value::as_str) + .unwrap_or("coordinator_response"); + let mut summary = json!({ + "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), + "streamed_bytes": response.get("streamed_bytes").cloned().unwrap_or_else(|| json!(0)), + "charged_download_bytes": response.get("charged_download_bytes").cloned().unwrap_or_else(|| json!(0)), + "content_bytes_available": response.get("content_bytes_available").cloned().unwrap_or(json!(false)), + "content_offset": response.get("content_offset").cloned().unwrap_or(Value::Null), + "content_eof": response.get("content_eof").cloned().unwrap_or(json!(false)), + "content_source": response.get("content_source").cloned().unwrap_or(Value::Null), + "content_material_returned_in_report": false, + "error": response.get("message").cloned().unwrap_or(Value::Null), + }); + if status != "artifact_download_stream" { + if let Some(object) = summary.as_object_mut() { + object.insert( + "machine_error".to_owned(), + artifact_response_machine_error( + response, + "coordinator rejected artifact download stream", + "connectivity", + ), + ); + } + } + summary +} + +fn apply_local_export_summary(export_plan: &mut Value, local_export: &Value) { + let Some(object) = export_plan.as_object_mut() else { + return; + }; + object.insert( + "local_bytes_written_by_cli".to_owned(), + local_export + .get("local_bytes_written_by_cli") + .cloned() + .unwrap_or(json!(false)), + ); + object.insert( + "local_export_status".to_owned(), + local_export + .get("status") + .cloned() + .unwrap_or_else(|| json!("unknown")), + ); + object.insert( + "content_bytes_available".to_owned(), + local_export + .get("content_bytes_available") + .cloned() + .unwrap_or(json!(false)), + ); + if let Some(bytes_written) = local_export.get("bytes_written") { + object.insert("bytes_written".to_owned(), bytes_written.clone()); + object.insert( + "writes_require_data_plane_followup".to_owned(), + json!(false), + ); + } +} diff --git a/crates/disasmer-cli/src/auth.rs b/crates/disasmer-cli/src/auth.rs new file mode 100644 index 0000000..87810c8 --- /dev/null +++ b/crates/disasmer-cli/src/auth.rs @@ -0,0 +1,780 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::client::{ + authenticated_or_local_trusted_request, stored_session_for_coordinator, JsonLineSession, +}; +use crate::config::{ + default_hosted_coordinator_endpoint, effective_scope_value, read_cli_session, + read_project_config, write_cli_session, write_project_config, ProjectConfig, StoredCliSession, +}; +use crate::errors::cli_error_summary; +use crate::run::{session_from_env, CliSession}; +use crate::ConnectSelfHostedArgs; +use crate::{AuthStatusArgs, LoginArgs}; + +const DEFAULT_BROWSER_LOGIN_TRANSACTION_TIMEOUT_SECONDS: u64 = 300; + +pub(crate) fn read_session_secret_from_stdin() -> Result { + let mut secret = String::new(); + std::io::stdin() + .read_line(&mut secret) + .context("failed to read self-hosted session secret from stdin")?; + let secret = secret.trim_end_matches(['\r', '\n']).to_owned(); + if secret.trim().is_empty() { + anyhow::bail!("self-hosted session secret from stdin must not be empty"); + } + Ok(secret) +} + +pub(crate) fn connect_self_hosted_report( + args: ConnectSelfHostedArgs, + cwd: PathBuf, + session_secret: String, +) -> Result { + if !args.session_secret_stdin { + anyhow::bail!("self-hosted session configuration requires --session-secret-stdin"); + } + let coordinator = args + .scope + .coordinator + .as_deref() + .filter(|value| !value.trim().is_empty()) + .context("self-hosted session configuration requires --coordinator ")?; + if session_secret.trim().is_empty() { + anyhow::bail!("self-hosted session secret from stdin must not be empty"); + } + + let mut connection = JsonLineSession::connect(coordinator)?; + let response = connection.request(json!({ + "type": "authenticated", + "session_secret": session_secret, + "request": { "type": "auth_status" }, + }))?; + for (field, expected) in [ + ("tenant", args.scope.tenant.as_str()), + ("project", args.scope.project.as_str()), + ("actor", args.scope.user.as_str()), + ] { + let actual = response.get(field).and_then(Value::as_str).unwrap_or(""); + if actual != expected { + anyhow::bail!( + "self-hosted session {field} mismatch: coordinator returned {actual:?}, expected {expected:?}" + ); + } + } + if response.get("authenticated").and_then(Value::as_bool) != Some(true) { + anyhow::bail!("self-hosted coordinator did not confirm the CLI session"); + } + + let stored = StoredCliSession { + kind: "self_hosted".to_owned(), + coordinator: coordinator.to_owned(), + tenant: args.scope.tenant, + project: args.scope.project, + user: args.scope.user, + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some(session_secret), + token_expiry_posture: "configured_by_self_hosted_operator".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: unix_timestamp_seconds(), + }; + let session_file = write_cli_session(&cwd, &stored)?; + Ok(json!({ + "command": "auth connect-self-hosted", + "status": "connected", + "coordinator": coordinator, + "tenant": stored.tenant, + "project": stored.project, + "user": stored.user, + "session_file": session_file, + "session_secret_read_from_stdin": true, + "session_secret_exposed_in_report": false, + "provider_tokens_exposed_to_cli": false, + "provider_tokens_sent_to_nodes": false, + "coordinator_response": response, + "coordinator_session_requests": connection.requests(), + })) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct LoginPlan { + pub(crate) coordinator: String, + pub(crate) human_flow: LoginFlowPlan, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct LoginCompletionReport { + pub(crate) plan: LoginPlan, + pub(crate) boundary: LoginCompletionBoundaryEvidence, + pub(crate) coordinator_response: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct LoginCompletionBoundaryEvidence { + pub(crate) cli_contacted_coordinator: bool, + pub(crate) coordinator_address: String, + pub(crate) scoped_cli_session_received: bool, + pub(crate) local_cli_session_file_written: bool, + pub(crate) provider_tokens_persisted_locally: bool, + pub(crate) provider_tokens_exposed_to_cli: bool, + pub(crate) provider_tokens_sent_to_nodes: bool, + pub(crate) coordinator_session_requests: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) enum LoginFlowPlan { + Browser(HostedBrowserLoginPlan), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct HostedBrowserLoginPlan { + pub(crate) authorization_url: Option, + pub(crate) requested_project: String, + pub(crate) server_owns_state: bool, + pub(crate) server_owns_nonce: bool, + pub(crate) pkce_required: bool, + pub(crate) hosted_callback: bool, + pub(crate) cli_receives_provider_authorization_code: bool, + pub(crate) cli_submits_identity_claims: bool, +} + +pub(crate) fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { + let config = read_project_config(&cwd)?; + let stored_session = read_cli_session(&cwd)?; + let configured_coordinator = args + .scope + .coordinator + .clone() + .or_else(|| { + config + .as_ref() + .and_then(|config| config.coordinator.clone()) + }) + .or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }); + let active_coordinator = configured_coordinator + .clone() + .unwrap_or_else(default_hosted_coordinator_endpoint); + let tenant = effective_scope_value( + &args.scope.tenant, + config + .as_ref() + .map(|config| config.tenant.as_str()) + .or_else(|| { + stored_session + .as_ref() + .map(|session| session.tenant.as_str()) + }), + "tenant", + ); + let project = effective_scope_value( + &args.scope.project, + config + .as_ref() + .map(|config| config.project.as_str()) + .or_else(|| { + stored_session + .as_ref() + .map(|session| session.project.as_str()) + }), + "project", + ); + let principal = effective_scope_value( + &args.scope.user, + config + .as_ref() + .map(|config| config.user.as_str()) + .or_else(|| stored_session.as_ref().map(|session| session.user.as_str())), + "user", + ); + let session_scope_mismatch = crate::auth_scope::session_scope_mismatch( + stored_session.as_ref(), + &active_coordinator, + &tenant, + &project, + &principal, + ); + let session_matches_project = stored_session.is_some() && session_scope_mismatch.is_none(); + let coordinator_account_status = configured_coordinator + .as_ref() + .filter(|_| session_scope_mismatch.is_none()) + .map(|coordinator| { + coordinator_auth_status_summary( + coordinator, + &tenant, + &project, + &principal, + stored_session.as_ref(), + ) + }) + .unwrap_or_else(|| { + if let Some(fields) = &session_scope_mismatch { + return crate::auth_scope::session_scope_mismatch_status(fields); + } + json!({ + "checked": false, + "reason": "no project or session coordinator configured", + "suspension_known": false, + "account_state_known": false, + "account_status": "unknown", + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + }) + }); + Ok(json!({ + "command": "auth status", + "active_coordinator": active_coordinator, + "principal": principal, + "tenant": tenant, + "project": project, + "session": auth_state_value(&cwd)?, + "session_matches_current_project": session_matches_project, + "session_scope_mismatch": session_scope_mismatch, + "coordinator_account_status": coordinator_account_status, + "project_config": config, + })) +} + +fn coordinator_auth_status_summary( + coordinator: &str, + tenant: &str, + project: &str, + principal: &str, + stored_session: Option<&StoredCliSession>, +) -> Value { + let mut session = match JsonLineSession::connect(coordinator) { + Ok(session) => session, + Err(error) => { + let message = error.to_string(); + return json!({ + "checked": true, + "reachable": false, + "source": "public_coordinator_api", + "account_status": "unknown", + "suspension_known": false, + "account_state_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "machine_error": cli_error_summary(&message), + "error": message, + "next_actions": ["disasmer doctor", "check coordinator status"], + "coordinator_session_requests": 0, + }); + } + }; + let request = match authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "auth_status", + }), + json!({ + "type": "auth_status", + "tenant": tenant, + "project": project, + "actor_user": principal, + }), + ) { + Ok(request) => request, + Err(error) => { + let message = error.to_string(); + return json!({ + "checked": false, + "reachable": "not_checked", + "source": "public_coordinator_api", + "authenticated_for_current_project": false, + "account_status": "unknown", + "suspension_known": false, + "account_state_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "machine_error": cli_error_summary(&message), + "error": message, + "next_actions": ["disasmer login --browser"], + "coordinator_session_requests": 0, + }); + } + }; + let response = match session.request_allow_error(request) { + Ok(response) => response, + Err(error) => { + let message = error.to_string(); + return json!({ + "checked": true, + "reachable": false, + "source": "public_coordinator_api", + "account_status": "unknown", + "suspension_known": false, + "account_state_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "machine_error": cli_error_summary(&message), + "error": message, + "next_actions": ["disasmer doctor", "check coordinator status"], + "coordinator_session_requests": session.requests(), + }); + } + }; + let coordinator_session_requests = session.requests(); + if response.get("type").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("coordinator rejected auth status"); + return json!({ + "checked": true, + "reachable": true, + "source": "public_coordinator_api", + "account_status": "unknown", + "suspension_known": false, + "account_state_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "machine_error": cli_error_summary(message), + "coordinator_response_type": "error", + "next_actions": ["disasmer doctor", "disasmer login --browser"], + "coordinator_session_requests": coordinator_session_requests, + }); + } + let suspended = response + .get("suspended") + .and_then(Value::as_bool) + .unwrap_or(false); + let disabled = response + .get("disabled") + .and_then(Value::as_bool) + .unwrap_or(false); + let deleted = response + .get("deleted") + .and_then(Value::as_bool) + .unwrap_or(false); + let manual_review = response + .get("manual_review") + .and_then(Value::as_bool) + .unwrap_or(false); + let account_status = response + .get("account_status") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + if deleted { + "deleted" + } else if disabled { + "disabled" + } else if suspended { + "suspended" + } else if manual_review { + "manual_review" + } else { + "active" + } + .to_owned() + }); + let sanitized_reason = response.get("sanitized_reason").and_then(Value::as_str); + let next_actions = response + .get("next_actions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect::>(); + json!({ + "checked": true, + "reachable": true, + "source": "public_coordinator_api", + "used_cli_session_credential": stored_session_for_coordinator(coordinator, stored_session).is_some(), + "account_status": account_status, + "suspension_known": true, + "account_state_known": true, + "suspended": suspended, + "disabled": disabled, + "deleted": deleted, + "manual_review": manual_review, + "sanitized_reason": sanitized_reason, + "next_actions": next_actions, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "coordinator_response_type": response.get("type").and_then(Value::as_str).unwrap_or("auth_status"), + "coordinator_session_requests": coordinator_session_requests, + }) +} + +pub(crate) fn non_interactive_browser_login_report(args: &LoginArgs) -> Value { + let message = + "browser login requires an interactive browser, but non-interactive mode is enabled"; + let next_actions = vec![ + "rerun without --non-interactive to open the browser", + "disasmer login --browser --plan", + "use DISASMER_AGENT_PRIVATE_KEY for automation", + ]; + json!({ + "command": "login", + "status": "authentication_required", + "coordinator": args.coordinator, + "non_interactive": true, + "browser_requested": true, + "browser_opened": false, + "safe_failure": true, + "message": message, + "next_actions": next_actions, + "machine_error": crate::auth_scope::non_interactive_auth_machine_error(message, next_actions), + }) +} + +pub(crate) fn auth_state_value(cwd: &Path) -> Result { + match session_from_env()? { + CliSession::Anonymous => { + if let Some(session) = read_cli_session(cwd)? { + return Ok(json!({ + "kind": session.kind, + "authenticated": true, + "source": "session_file", + "coordinator": session.coordinator, + "tenant": session.tenant, + "project": session.project, + "principal": session.user, + "cli_session_credential_kind": session.cli_session_credential_kind, + "provider_tokens_exposed_to_cli": session.provider_tokens_exposed_to_cli, + "provider_tokens_exposed_to_nodes": session.provider_tokens_sent_to_nodes, + "expires_at": session.expires_at, + "token_expiry_posture": session.token_expiry_posture, + })); + } + Ok(json!({ + "kind": "anonymous", + "authenticated": false, + "source": "environment", + "token_expiry_posture": "no_session", + })) + } + CliSession::HumanSession => { + let expires_at = std::env::var("DISASMER_TOKEN_EXPIRES_AT").ok(); + Ok(json!({ + "kind": "human", + "authenticated": true, + "source": "DISASMER_TOKEN", + "provider_tokens_exposed_to_nodes": false, + "expires_at": expires_at, + "token_expiry_posture": if expires_at.is_some() { "expires_at" } else { "unknown_env_token" }, + })) + } + CliSession::AgentPublicKey { + agent, + public_key_fingerprint, + browser_interaction_required, + .. + } => Ok(json!({ + "kind": "agent_public_key", + "authenticated": true, + "agent": agent, + "source": "DISASMER_AGENT_PRIVATE_KEY", + "public_key_fingerprint": public_key_fingerprint, + "browser_interaction_required": browser_interaction_required, + "token_expiry_posture": "not_applicable_public_key", + })), + } +} + +pub(crate) fn login_plan(args: LoginArgs) -> LoginPlan { + let human_flow = LoginFlowPlan::Browser(HostedBrowserLoginPlan { + authorization_url: None, + requested_project: args.project.clone(), + server_owns_state: true, + server_owns_nonce: true, + pkce_required: true, + hosted_callback: true, + cli_receives_provider_authorization_code: false, + cli_submits_identity_claims: false, + }); + + LoginPlan { + coordinator: args.coordinator, + human_flow, + } +} + +fn finalize_browser_login( + args: LoginArgs, + plan: LoginPlan, + coordinator_response: Value, + coordinator_session_requests: u64, +) -> Result { + let coordinator = args.coordinator.clone(); + let scoped_cli_session_received = coordinator_response + .pointer("/session/cli_session_credential_kind") + .and_then(Value::as_str) + == Some("CliDeviceSession"); + let provider_tokens_sent_to_nodes = coordinator_response + .pointer("/session/provider_tokens_sent_to_nodes") + .and_then(Value::as_bool) + .unwrap_or(true); + let provider_tokens_exposed_to_cli = contains_provider_token_field(&coordinator_response); + let local_cli_session_file_written = if scoped_cli_session_received { + let cwd = std::env::current_dir()?; + let stored_session = stored_cli_session_from_login_response( + &coordinator, + &coordinator_response, + provider_tokens_exposed_to_cli, + provider_tokens_sent_to_nodes, + )?; + write_cli_session(&cwd, &stored_session)?; + write_project_config( + &cwd, + &ProjectConfig { + tenant: stored_session.tenant.clone(), + project: stored_session.project.clone(), + user: stored_session.user.clone(), + coordinator: Some(stored_session.coordinator.clone()), + }, + )?; + true + } else { + false + }; + + Ok(LoginCompletionReport { + plan, + boundary: LoginCompletionBoundaryEvidence { + cli_contacted_coordinator: true, + coordinator_address: coordinator, + scoped_cli_session_received, + local_cli_session_file_written, + provider_tokens_persisted_locally: false, + provider_tokens_exposed_to_cli, + provider_tokens_sent_to_nodes, + coordinator_session_requests, + }, + coordinator_response, + }) +} + +pub(crate) fn stored_cli_session_from_login_response( + coordinator: &str, + coordinator_response: &Value, + provider_tokens_exposed_to_cli: bool, + provider_tokens_sent_to_nodes: bool, +) -> Result { + let session = coordinator_response.get("session").unwrap_or(&Value::Null); + let expires_at = session + .get("expires_at") + .or_else(|| session.get("token_expires_at")) + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| { + session + .get("expires_at_epoch_seconds") + .and_then(Value::as_u64) + .map(|value| value.to_string()) + }); + let tenant = session + .get("tenant") + .and_then(Value::as_str) + .context("hosted login session omitted tenant")?; + let project = session + .get("project") + .and_then(Value::as_str) + .context("hosted login session omitted project")?; + let user = session + .get("user") + .and_then(Value::as_str) + .context("hosted login session omitted user")?; + let session_secret = session + .get("cli_session_secret") + .or_else(|| session.get("session_secret")) + .and_then(Value::as_str) + .context("hosted login session omitted CLI session secret")?; + Ok(StoredCliSession { + kind: "human".to_owned(), + coordinator: coordinator.to_owned(), + tenant: tenant.to_owned(), + project: project.to_owned(), + user: user.to_owned(), + cli_session_credential_kind: session + .get("cli_session_credential_kind") + .and_then(Value::as_str) + .unwrap_or("CliDeviceSession") + .to_owned(), + session_secret: Some(session_secret.to_owned()), + token_expiry_posture: if expires_at.is_some() { + "expires_at".to_owned() + } else { + "unknown_coordinator_session".to_owned() + }, + expires_at, + provider_tokens_exposed_to_cli, + provider_tokens_sent_to_nodes, + created_at_unix_seconds: unix_timestamp_seconds(), + }) +} + +pub(crate) fn execute_interactive_browser_login(args: LoginArgs) -> Result { + let coordinator = args.coordinator.clone(); + let mut session = JsonLineSession::connect(&coordinator)?; + let started = session.request(json!({ + "type": "begin_oidc_browser_login", + "requested_project": args.project, + }))?; + if started.get("type").and_then(Value::as_str) != Some("oidc_browser_login_started") { + anyhow::bail!("coordinator did not start a hosted browser login transaction"); + } + let transaction_id = started + .get("transaction_id") + .and_then(Value::as_str) + .context("hosted login transaction omitted transaction id")? + .to_owned(); + let polling_secret = started + .get("polling_secret") + .and_then(Value::as_str) + .context("hosted login transaction omitted polling secret")? + .to_owned(); + let authorization_url = started + .get("authorization_url") + .and_then(Value::as_str) + .context("hosted login transaction omitted authorization URL")? + .to_owned(); + let loopback_test_flow = coordinator.starts_with("http://") + && crate::client::is_loopback_coordinator(&coordinator) + && authorization_url.starts_with("http://") + && crate::client::is_loopback_coordinator(&authorization_url); + if !authorization_url.starts_with("https://") && !loopback_test_flow { + anyhow::bail!( + "hosted login authorization URL must use HTTPS (plain HTTP is accepted only when both coordinator and identity provider are loopback test services)" + ); + } + let plan = LoginPlan { + coordinator: coordinator.clone(), + human_flow: LoginFlowPlan::Browser(HostedBrowserLoginPlan { + authorization_url: Some(authorization_url.clone()), + requested_project: args.project.clone(), + server_owns_state: true, + server_owns_nonce: true, + pkce_required: true, + hosted_callback: true, + cli_receives_provider_authorization_code: false, + cli_submits_identity_claims: false, + }), + }; + + eprintln!("Opening Disasmer browser login: {authorization_url}"); + eprintln!("Waiting for the hosted login callback to complete."); + open_browser(&authorization_url)?; + + let deadline = Instant::now() + browser_login_timeout(); + loop { + let response = session.request(json!({ + "type": "poll_oidc_browser_login", + "transaction_id": transaction_id, + "polling_secret": polling_secret, + }))?; + match response.get("type").and_then(Value::as_str) { + Some("oidc_browser_login_pending") => { + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for hosted browser login completion"); + } + std::thread::sleep(Duration::from_millis(500)); + } + Some("oidc_browser_session") => { + return finalize_browser_login(args, plan, response, session.requests()); + } + _ => anyhow::bail!("coordinator returned an invalid hosted login status"), + } + } +} + +pub(crate) fn print_browser_login_success(report: &LoginCompletionReport) { + let session = report.coordinator_response.get("session"); + let tenant = session + .and_then(|value| value.get("tenant")) + .and_then(Value::as_str) + .unwrap_or("tenant"); + let project = session + .and_then(|value| value.get("project")) + .and_then(Value::as_str) + .unwrap_or("project"); + let user = session + .and_then(|value| value.get("user")) + .and_then(Value::as_str) + .unwrap_or("user"); + println!( + "Signed in to {} as {user} for {tenant}/{project}.", + report.plan.coordinator + ); + if report.boundary.scoped_cli_session_received { + println!("Received a scoped CLI session from the coordinator."); + } +} + +fn open_browser(url: &str) -> Result<()> { + let mut command = if let Some(command) = std::env::var_os("DISASMER_BROWSER_OPEN_COMMAND") { + Command::new(command) + } else { + platform_browser_command() + }; + command + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command + .spawn() + .with_context(|| format!("failed to start browser opener for {url}"))?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn platform_browser_command() -> Command { + Command::new("open") +} + +#[cfg(target_os = "windows")] +fn platform_browser_command() -> Command { + let mut command = Command::new("cmd"); + command.args(["/C", "start", ""]); + command +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +fn platform_browser_command() -> Command { + Command::new("xdg-open") +} + +fn browser_login_timeout() -> Duration { + let seconds = std::env::var("DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(DEFAULT_BROWSER_LOGIN_TRANSACTION_TIMEOUT_SECONDS); + Duration::from_secs(seconds) +} + +fn unix_timestamp_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub(crate) fn contains_provider_token_field(value: &Value) -> bool { + match value { + Value::Object(object) => object.iter().any(|(key, value)| { + matches!( + key.as_str(), + "access_token" | "refresh_token" | "id_token" | "provider_token" | "oauth_token" + ) || contains_provider_token_field(value) + }), + Value::Array(items) => items.iter().any(contains_provider_token_field), + _ => false, + } +} diff --git a/crates/disasmer-cli/src/auth_scope.rs b/crates/disasmer-cli/src/auth_scope.rs new file mode 100644 index 0000000..39af42f --- /dev/null +++ b/crates/disasmer-cli/src/auth_scope.rs @@ -0,0 +1,79 @@ +use std::path::Path; + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::client::control_endpoint_identity; +use crate::config::{ + effective_scope_value, read_project_config, StoredCliSession, + DEFAULT_HOSTED_COORDINATOR_ENDPOINT, +}; +use crate::errors::cli_error_summary_for_category; +use crate::LoginArgs; + +pub(crate) fn login_args_for_project(mut args: LoginArgs, cwd: &Path) -> Result { + let Some(config) = read_project_config(cwd)? else { + return Ok(args); + }; + args.project = effective_scope_value(&args.project, Some(&config.project), "project"); + if args.coordinator == DEFAULT_HOSTED_COORDINATOR_ENDPOINT { + if let Some(coordinator) = config.coordinator { + args.coordinator = coordinator; + } + } + Ok(args) +} + +pub(crate) fn session_scope_mismatch( + session: Option<&StoredCliSession>, + active_coordinator: &str, + tenant: &str, + project: &str, + user: &str, +) -> Option> { + session.and_then(|session| { + let mut fields = Vec::new(); + if control_endpoint_identity(&session.coordinator).ok() + != control_endpoint_identity(active_coordinator).ok() + { + fields.push("coordinator"); + } + if session.tenant != tenant { + fields.push("tenant"); + } + if session.project != project { + fields.push("project"); + } + if session.user != user { + fields.push("user"); + } + (!fields.is_empty()).then_some(fields) + }) +} + +pub(crate) fn session_scope_mismatch_status(fields: &[&str]) -> Value { + json!({ + "checked": false, + "reason": "stored CLI session does not match the current project", + "mismatched_fields": fields, + "authenticated_for_current_project": false, + "account_status": "unknown", + "suspension_known": false, + "account_state_known": false, + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "next_actions": ["disasmer login --browser"], + }) +} + +pub(crate) fn non_interactive_auth_machine_error( + message: &str, + next_actions: Vec<&'static str>, +) -> Value { + let mut machine_error = cli_error_summary_for_category("authentication", message); + if let Some(object) = machine_error.as_object_mut() { + object.insert("next_actions".to_owned(), json!(next_actions)); + object.insert("browser_opened".to_owned(), json!(false)); + } + machine_error +} diff --git a/crates/disasmer-cli/src/build.rs b/crates/disasmer-cli/src/build.rs new file mode 100644 index 0000000..51dd7f3 --- /dev/null +++ b/crates/disasmer-cli/src/build.rs @@ -0,0 +1,322 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use disasmer_core::Digest; +use serde_json::{json, Value}; +use wasmparser::{Parser, Payload}; + +use crate::errors::cli_error_summary_for_category; +use crate::{bundle_inspection, BuildArgs, BundleInspectArgs}; + +pub(crate) fn build_report(args: BuildArgs, cwd: PathBuf) -> Result { + let inspection = bundle_inspection( + BundleInspectArgs { + project: args.project.clone(), + source_provider: args.source_provider.clone(), + disabled_source_providers: args.disabled_source_providers.clone(), + json: true, + }, + cwd, + )?; + let diagnostics = inspection.pre_schedule_diagnostics.clone(); + let blocking_diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.severity == "error") + .cloned(); + if let Some(diagnostic) = blocking_diagnostic { + let machine_category = match diagnostic.category.as_str() { + "environment" => "environment", + "source_provider" | "capability" => "capability", + _ => "unknown", + }; + return Ok(json!({ + "command": "build", + "status": "blocked_before_schedule", + "bundle": inspection, + "diagnostics": diagnostics, + "contains_full_repository_upload": false, + "content_addressed": false, + "debug_metadata_available": false, + "scheduled_work": false, + "machine_error": cli_error_summary_for_category(machine_category, &diagnostic.message), + })); + } + + let mut wasm = compile_project_wasm(&inspection.project)?; + let environment_manifest = serde_json::to_vec(&inspection.metadata.environments)?; + append_custom_section( + &mut wasm.bytes, + "disasmer.environments", + &environment_manifest, + ); + let bundle_digest = Digest::sha256(&wasm.bytes); + let task_descriptors = descriptor_records(&wasm.bytes, "disasmer.tasks")?; + let entrypoint_descriptors = descriptor_records(&wasm.bytes, "disasmer.entrypoints")?; + if task_descriptors.is_empty() { + bail!( + "compiled Wasm module contains no #[disasmer::task] descriptors; annotate at least one exported task" + ); + } + if entrypoint_descriptors.is_empty() { + bail!( + "compiled Wasm module contains no #[disasmer::main] descriptors; annotate at least one entrypoint" + ); + } + let output = args.output.unwrap_or_else(|| { + inspection.project.join(".disasmer/build").join( + bundle_digest + .as_str() + .trim_start_matches("sha256:") + .get(..16) + .unwrap_or("bundle"), + ) + }); + let bundle_artifact = write_bundle( + &output, + &wasm, + &bundle_digest, + &inspection, + &task_descriptors, + &entrypoint_descriptors, + )?; + + Ok(json!({ + "command": "build", + "status": "built", + "bundle": inspection, + "bundle_artifact": bundle_artifact, + "diagnostics": diagnostics, + "contains_full_repository_upload": false, + "content_addressed": true, + "debug_metadata_available": true, + "scheduled_work": false, + })) +} + +fn append_custom_section(module: &mut Vec, name: &str, data: &[u8]) { + let mut section = Vec::new(); + encode_unsigned_leb(name.len() as u64, &mut section); + section.extend_from_slice(name.as_bytes()); + section.extend_from_slice(data); + module.push(0); + encode_unsigned_leb(section.len() as u64, module); + module.extend_from_slice(§ion); +} + +fn encode_unsigned_leb(mut value: u64, output: &mut Vec) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + output.push(byte); + if value == 0 { + break; + } + } +} + +struct CompiledWasm { + bytes: Vec, + package: String, + target: String, + source_path: PathBuf, +} + +fn compile_project_wasm(project: &Path) -> Result { + let manifest = project.join("Cargo.toml"); + let metadata_output = Command::new("cargo") + .args([ + "metadata", + "--format-version", + "1", + "--no-deps", + "--manifest-path", + ]) + .arg(&manifest) + .output() + .with_context(|| format!("failed to run cargo metadata for {}", manifest.display()))?; + if !metadata_output.status.success() { + bail!( + "cargo metadata failed for {}: {}", + manifest.display(), + String::from_utf8_lossy(&metadata_output.stderr).trim() + ); + } + let metadata: Value = serde_json::from_slice(&metadata_output.stdout)?; + let canonical_manifest = std::fs::canonicalize(&manifest)?; + let package = metadata["packages"] + .as_array() + .and_then(|packages| { + packages.iter().find(|package| { + package["manifest_path"] + .as_str() + .and_then(|path| std::fs::canonicalize(path).ok()) + .as_ref() + == Some(&canonical_manifest) + }) + }) + .context("cargo metadata did not return the requested project package")?; + let package_name = package["name"] + .as_str() + .context("cargo package name missing")?; + let target = package["targets"] + .as_array() + .and_then(|targets| { + targets.iter().find(|target| { + target["crate_types"] + .as_array() + .is_some_and(|types| types.iter().any(|kind| kind == "cdylib")) + }) + }) + .context("Disasmer project library must include crate-type = [\"cdylib\"]")?; + let target_name = target["name"] + .as_str() + .context("cargo target name missing")?; + let build = Command::new("cargo") + // The MVP transports one Wasm bundle in a bounded control frame. These are + // ordinary Cargo release-profile settings, applied only to the guest build, + // that keep the product SDK/runtime inside that accepted boundary. + .env("CARGO_PROFILE_RELEASE_OPT_LEVEL", "z") + .env("CARGO_PROFILE_RELEASE_LTO", "thin") + .env("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1") + .args([ + "build", + "--quiet", + "--release", + "--target", + "wasm32-unknown-unknown", + "--lib", + "--manifest-path", + ]) + .arg(&manifest) + .output() + .with_context(|| format!("failed to compile {} to Wasm", manifest.display()))?; + if !build.status.success() { + bail!( + "Wasm bundle compilation failed for {}: {}", + manifest.display(), + String::from_utf8_lossy(&build.stderr).trim() + ); + } + let target_directory = metadata["target_directory"] + .as_str() + .context("cargo target_directory missing")?; + let source_path = Path::new(target_directory) + .join("wasm32-unknown-unknown/release") + .join(format!("{}.wasm", target_name.replace('-', "_"))); + let bytes = std::fs::read(&source_path) + .with_context(|| format!("compiled Wasm module missing at {}", source_path.display()))?; + Ok(CompiledWasm { + bytes, + package: package_name.to_owned(), + target: target_name.to_owned(), + source_path, + }) +} + +fn descriptor_records(module: &[u8], section_name: &str) -> Result> { + let mut records: Vec = Vec::new(); + for payload in Parser::new(0).parse_all(module) { + let Payload::CustomSection(section) = payload? else { + continue; + }; + if section.name() != section_name { + continue; + } + for record in section + .data() + .split(|byte| *byte == b'\n' || *byte == 0) + .filter(|record| !record.is_empty()) + { + records.push(serde_json::from_slice(record).with_context(|| { + format!("invalid descriptor record in Wasm custom section {section_name}") + })?); + } + } + records.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); + Ok(records) +} + +fn write_bundle( + output: &Path, + wasm: &CompiledWasm, + bundle_digest: &Digest, + inspection: &crate::bundle::BundleInspection, + tasks: &[Value], + entrypoints: &[Value], +) -> Result { + std::fs::create_dir_all(output)?; + let module_path = output.join("module.wasm"); + let task_path = output.join("task-descriptors.json"); + let entrypoint_path = output.join("entrypoints.json"); + let environment_path = output.join("environments.json"); + let source_path = output.join("source-provider.json"); + let vfs_path = output.join("vfs-seed.json"); + let debug_path = output.join("debug-metadata.json"); + let manifest_path = output.join("manifest.json"); + std::fs::write(&module_path, &wasm.bytes)?; + write_json(&task_path, &json!(tasks))?; + write_json(&entrypoint_path, &json!(entrypoints))?; + write_json(&environment_path, &json!(inspection.metadata.environments))?; + write_json(&source_path, &json!(inspection.source_provider_manifest))?; + write_json( + &vfs_path, + &json!({ + "epoch": 0, + "mounts": ["/vfs/artifacts", "/vfs/sources", "/vfs/blobs"], + "large_bytes_embedded": false, + }), + )?; + write_json(&debug_path, &json!(inspection.metadata.debug_metadata))?; + let manifest = json!({ + "kind": "disasmer-bundle", + "format_version": 1, + "package": wasm.package, + "target": wasm.target, + "bundle_digest": bundle_digest, + "module": "module.wasm", + "module_size_bytes": wasm.bytes.len(), + "task_descriptors": "task-descriptors.json", + "entrypoints": "entrypoints.json", + "environments": "environments.json", + "source_provider": "source-provider.json", + "vfs_seed": "vfs-seed.json", + "debug_metadata": "debug-metadata.json", + "required_capabilities": tasks.iter().flat_map(|task| { + task["required_capabilities"].as_array().into_iter().flatten().cloned() + }).collect::>(), + "metadata_identity": inspection.metadata.identity, + "coordinator_receives_source_bytes_by_default": false, + "embeds_full_repository": false, + }); + write_json(&manifest_path, &manifest)?; + Ok(json!({ + "directory": output, + "manifest": manifest_path, + "module": module_path, + "compiled_module_source": wasm.source_path, + "bundle_digest": bundle_digest, + "module_size_bytes": wasm.bytes.len(), + "task_descriptor_count": tasks.len(), + "entrypoint_count": entrypoints.len(), + "files": [ + "manifest.json", + "module.wasm", + "task-descriptors.json", + "entrypoints.json", + "environments.json", + "source-provider.json", + "vfs-seed.json", + "debug-metadata.json", + ], + })) +} + +fn write_json(path: &Path, value: &Value) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(value)?; + bytes.push(b'\n'); + std::fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display())) +} diff --git a/crates/disasmer-cli/src/bundle.rs b/crates/disasmer-cli/src/bundle.rs new file mode 100644 index 0000000..994e00c --- /dev/null +++ b/crates/disasmer-cli/src/bundle.rs @@ -0,0 +1,379 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use disasmer_core::{ + diagnose_environment_references, discover_source_debug_probes, BundleDebugProbe, + BundleIdentityInputs, BundleMetadata, Digest, EnvironmentReference, ProjectModel, + SelectedInput, SourceProviderKind, SourceProviderManifest, +}; +use serde::Serialize; + +use crate::BundleInspectArgs; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct BundleInspection { + pub(crate) project: PathBuf, + pub(crate) default_source_providers: Vec, + pub(crate) source_provider_manifest: SourceProviderManifest, + pub(crate) source_provider_statuses: Vec, + pub(crate) environment_diagnostics: Vec, + pub(crate) pre_schedule_diagnostics: Vec, + pub(crate) metadata: BundleMetadata, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct SourceProviderStatus { + pub(crate) provider: String, + pub(crate) status: String, + pub(crate) active: bool, + pub(crate) reason: String, + pub(crate) coordinator_checkout_required: bool, + pub(crate) coordinator_receives_source_bytes_by_default: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct EnvironmentDiagnosticReport { + pub(crate) path: String, + pub(crate) reference: EnvironmentReference, + pub(crate) message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct CliDiagnostic { + pub(crate) severity: String, + pub(crate) category: String, + pub(crate) code: String, + pub(crate) message: String, + pub(crate) next_actions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SourceProviderSelection { + active_provider: SourceProviderKind, + statuses: Vec, +} + +pub(crate) fn discovered_environment_names(inspection: Option<&BundleInspection>) -> Vec { + inspection + .map(|inspection| { + inspection + .metadata + .environments + .iter() + .map(|environment| environment.name.clone()) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn bundle_inspection(args: BundleInspectArgs, cwd: PathBuf) -> Result { + let project = args.project.unwrap_or(cwd); + let model = ProjectModel::discover_without_config(&project)?; + let selected_inputs = discover_selected_inputs(&project)?; + let debug_probes = discover_debug_probes(&project, &selected_inputs)?; + let provider_selection = source_provider_selection( + &project, + args.source_provider.as_deref(), + &args.disabled_source_providers, + ); + let source_provider_manifest = source_provider_manifest(&provider_selection.active_provider); + source_provider_manifest.validate_public_mvp()?; + let environment_diagnostics = + environment_diagnostics_for_inputs(&project, &selected_inputs, &model.environments)?; + let identity_inputs = BundleIdentityInputs { + wasm_code: wasm_source_proxy_digest(&selected_inputs), + task_abi: task_abi_digest(&model), + entrypoints: model.entrypoints.keys().cloned().collect(), + default_entrypoint: model.default_entrypoint.clone(), + environments: model.environments.clone(), + source_provider_manifest: source_provider_manifest.digest.clone(), + source_transfer_policy: source_provider_manifest.transfer_policy.clone(), + selected_inputs, + }; + let mut metadata = identity_inputs.inspectable_metadata(); + metadata.debug_metadata.probes = debug_probes; + let pre_schedule_diagnostics = pre_schedule_diagnostics( + &provider_selection.statuses, + &environment_diagnostics, + &model.environments, + ); + + Ok(BundleInspection { + project, + default_source_providers: vec![SourceProviderKind::Filesystem, SourceProviderKind::Git], + source_provider_manifest, + source_provider_statuses: provider_selection.statuses, + environment_diagnostics, + pre_schedule_diagnostics, + metadata, + }) +} + +fn discover_selected_inputs(project: &Path) -> Result> { + let mut inputs = Vec::new(); + for path in [ + "Cargo.toml", + "Cargo.lock", + "src/main.rs", + "src/lib.rs", + "src/build.rs", + ] { + let absolute = project.join(path); + if !absolute.is_file() { + continue; + } + let bytes = std::fs::read(&absolute)?; + inputs.push(SelectedInput { + path: path.to_owned(), + digest: Digest::from_parts([ + b"bundle-selected-input:v1".as_slice(), + path.as_bytes(), + bytes.as_slice(), + ]), + }); + } + Ok(inputs) +} + +fn source_provider_selection( + project: &Path, + requested_provider: Option<&str>, + disabled_providers: &[String], +) -> SourceProviderSelection { + let has_git_checkout = project.join(".git").exists(); + let disabled = disabled_providers + .iter() + .map(|provider| provider.trim().to_ascii_lowercase()) + .collect::>(); + let requested = requested_provider.map(source_provider_kind_from_id); + let active_provider = requested.clone().unwrap_or_else(|| { + if has_git_checkout && !disabled.contains("git") { + SourceProviderKind::Git + } else { + SourceProviderKind::Filesystem + } + }); + let active_provider_id = active_provider.provider_id().to_owned(); + let mut statuses = vec![ + builtin_source_provider_status( + SourceProviderKind::Filesystem, + true, + disabled.contains("filesystem"), + active_provider_id == "filesystem", + ), + builtin_source_provider_status( + SourceProviderKind::Git, + has_git_checkout, + disabled.contains("git"), + active_provider_id == "git", + ), + SourceProviderStatus { + provider: "custom".to_owned(), + status: "disabled".to_owned(), + active: false, + reason: "no custom source-provider module was configured for this project".to_owned(), + coordinator_checkout_required: false, + coordinator_receives_source_bytes_by_default: false, + }, + ]; + + if let Some(SourceProviderKind::Custom(provider)) = requested { + statuses.push(SourceProviderStatus { + provider, + status: "unsupported".to_owned(), + active: true, + reason: "custom source-provider modules must be supplied by public plugin/API code before use".to_owned(), + coordinator_checkout_required: false, + coordinator_receives_source_bytes_by_default: false, + }); + } + + SourceProviderSelection { + active_provider, + statuses, + } +} + +fn source_provider_kind_from_id(provider: &str) -> SourceProviderKind { + match provider.trim().to_ascii_lowercase().as_str() { + "filesystem" => SourceProviderKind::Filesystem, + "git" => SourceProviderKind::Git, + other => SourceProviderKind::Custom(other.to_owned()), + } +} + +fn builtin_source_provider_status( + kind: SourceProviderKind, + available: bool, + disabled: bool, + active: bool, +) -> SourceProviderStatus { + let provider = kind.provider_id().to_owned(); + let (status, reason) = if disabled { + ( + "disabled", + format!("source provider `{provider}` was disabled by CLI override"), + ) + } else if available { + ( + "enabled", + format!("source provider `{provider}` is available in this checkout"), + ) + } else { + ( + "missing", + format!("source provider `{provider}` is not available for this checkout"), + ) + }; + SourceProviderStatus { + provider, + status: status.to_owned(), + active, + reason, + coordinator_checkout_required: false, + coordinator_receives_source_bytes_by_default: false, + } +} + +fn source_provider_manifest(kind: &SourceProviderKind) -> SourceProviderManifest { + SourceProviderManifest::local_first( + kind.clone(), + "default source provider manifest; snapshot creation can be scheduled as a node task", + ) +} + +fn environment_diagnostics_for_inputs( + project: &Path, + selected_inputs: &[SelectedInput], + environments: &[disasmer_core::EnvironmentResource], +) -> Result> { + let mut diagnostics = Vec::new(); + for input in selected_inputs { + if !input.path.ends_with(".rs") { + continue; + } + let source = std::fs::read_to_string(project.join(&input.path)) + .with_context(|| format!("failed to read {}", project.join(&input.path).display()))?; + diagnostics.extend( + diagnose_environment_references(&source, environments) + .into_iter() + .map(|diagnostic| EnvironmentDiagnosticReport { + path: input.path.clone(), + reference: diagnostic.reference, + message: diagnostic.message, + }), + ); + } + Ok(diagnostics) +} + +fn discover_debug_probes( + project: &Path, + selected_inputs: &[SelectedInput], +) -> Result> { + let mut probes = Vec::new(); + for input in selected_inputs { + if !input.path.ends_with(".rs") { + continue; + } + let source = std::fs::read_to_string(project.join(&input.path)) + .with_context(|| format!("failed to read {}", project.join(&input.path).display()))?; + probes.extend(discover_source_debug_probes(&input.path, &source)); + } + Ok(probes) +} + +fn pre_schedule_diagnostics( + source_provider_statuses: &[SourceProviderStatus], + environment_diagnostics: &[EnvironmentDiagnosticReport], + environments: &[disasmer_core::EnvironmentResource], +) -> Vec { + let mut diagnostics = Vec::new(); + diagnostics.extend( + environment_diagnostics + .iter() + .map(|diagnostic| CliDiagnostic { + severity: "error".to_owned(), + category: "environment".to_owned(), + code: "missing_environment".to_owned(), + message: format!("{} at {}", diagnostic.message, diagnostic.path), + next_actions: vec![ + "create the missing envs//Containerfile or envs//Dockerfile" + .to_owned(), + "rerun disasmer inspect".to_owned(), + ], + }), + ); + + diagnostics.extend( + source_provider_statuses + .iter() + .filter(|status| { + status.active + && matches!( + status.status.as_str(), + "missing" | "disabled" | "unsupported" + ) + }) + .map(|status| CliDiagnostic { + severity: "error".to_owned(), + category: "source_provider".to_owned(), + code: format!("source_provider_{}", status.status), + message: format!( + "active source provider `{}` is {}: {}", + status.provider, status.status, status.reason + ), + next_actions: vec![ + "choose an available source provider with --source-provider".to_owned(), + "rerun disasmer inspect --json".to_owned(), + ], + }), + ); + + for environment in environments { + if environment.requirements.capabilities.is_empty() { + continue; + } + let mut capabilities = environment + .requirements + .capabilities + .iter() + .map(|capability| format!("{capability:?}")) + .collect::>(); + capabilities.sort(); + diagnostics.push(CliDiagnostic { + severity: "info".to_owned(), + category: "capability".to_owned(), + code: "environment_capability_requirements".to_owned(), + message: format!( + "environment `{}` requires node capabilities: {}", + environment.name, + capabilities.join(", ") + ), + next_actions: vec![ + "attach a node that reports these capabilities before scheduling work".to_owned(), + ], + }); + } + + diagnostics +} + +fn wasm_source_proxy_digest(selected_inputs: &[SelectedInput]) -> Digest { + let mut parts = vec![b"wasm-source-proxy:v1".to_vec()]; + for input in selected_inputs { + parts.push(input.path.as_bytes().to_vec()); + parts.push(input.digest.as_str().as_bytes().to_vec()); + } + Digest::from_parts(parts) +} + +pub(crate) fn task_abi_digest(model: &ProjectModel) -> Digest { + let mut parts = vec![b"task-abi:v1".to_vec()]; + for entrypoint in model.entrypoints.values() { + parts.push(entrypoint.name.as_bytes().to_vec()); + parts.push(entrypoint.function.as_bytes().to_vec()); + } + Digest::from_parts(parts) +} diff --git a/crates/disasmer-cli/src/client.rs b/crates/disasmer-cli/src/client.rs new file mode 100644 index 0000000..0a809d6 --- /dev/null +++ b/crates/disasmer-cli/src/client.rs @@ -0,0 +1,183 @@ +use anyhow::{Context, Result}; +use disasmer_control::{endpoint_identity, endpoint_is_loopback, ControlSession}; +use disasmer_core::coordinator_wire_request; +use serde_json::{json, Value}; + +use crate::config::StoredCliSession; +use crate::errors::cli_error_summary; +use crate::CliScopeArgs; + +pub(crate) struct JsonLineSession { + inner: ControlSession, +} + +impl JsonLineSession { + pub(crate) fn connect(addr: &str) -> Result { + let inner = ControlSession::connect(addr) + .with_context(|| format!("failed to connect to coordinator {addr}"))?; + Ok(Self { inner }) + } + + pub(crate) fn request(&mut self, value: Value) -> Result { + let response = self.request_allow_error(value)?; + if response.get("type").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| response.to_string()); + let machine_error = cli_error_summary(&message); + let category = machine_error + .get("category") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let exit_code = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .unwrap_or(1); + anyhow::bail!("coordinator error ({category}, exit {exit_code}): {response}"); + } + Ok(response) + } + + pub(crate) fn request_allow_error(&mut self, value: Value) -> Result { + let request_id = format!("cli-{}", self.inner.requests() + 1); + let wire_request = coordinator_wire_request(request_id, value); + self.inner + .request(&wire_request) + .map_err(anyhow::Error::from) + } + + pub(crate) fn requests(&self) -> u64 { + self.inner.requests() + } +} + +pub(crate) fn control_endpoint_identity(endpoint: &str) -> Result { + endpoint_identity(endpoint).map_err(anyhow::Error::from) +} + +pub(crate) fn authenticated_or_local_trusted_request( + coordinator: &str, + stored_session: Option<&StoredCliSession>, + authenticated_request: Value, + local_trusted_request: Value, +) -> Result { + if let Some(session_secret) = stored_session_for_coordinator(coordinator, stored_session) + .and_then(|session| session.session_secret.as_ref()) + { + Ok(json!({ + "type": "authenticated", + "session_secret": session_secret, + "request": authenticated_request, + })) + } else if is_loopback_coordinator(coordinator) { + Ok(local_trusted_request) + } else { + anyhow::bail!( + "no authenticated CLI session matches coordinator {coordinator}; run `disasmer login --browser` from the current project" + ) + } +} + +pub(crate) fn is_loopback_coordinator(coordinator: &str) -> bool { + endpoint_is_loopback(coordinator) +} + +pub(crate) fn stored_session_for_coordinator<'a>( + coordinator: &str, + stored_session: Option<&'a StoredCliSession>, +) -> Option<&'a StoredCliSession> { + stored_session.filter(|session| { + session.session_secret.is_some() + && control_endpoint_identity(&session.coordinator).ok() + == control_endpoint_identity(coordinator).ok() + }) +} + +pub(crate) fn list_task_events_if_available_with_session( + coordinator: Option<&str>, + scope: &CliScopeArgs, + process: Option, + stored_session: Option<&StoredCliSession>, +) -> Result> { + let Some(coordinator) = coordinator else { + return Ok(None); + }; + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "list_task_events", + "process": process, + }), + json!({ + "type": "list_task_events", + "tenant": scope.tenant, + "project": scope.project, + "actor_user": scope.user, + "process": process, + }), + )?)?; + Ok(Some(json!({ + "coordinator": coordinator, + "response": response, + "coordinator_session_requests": session.requests(), + }))) +} + +pub(crate) fn list_attached_nodes_if_available_with_session( + coordinator: Option<&str>, + scope: &CliScopeArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + let Some(coordinator) = coordinator else { + return Ok(json!({ + "checked": false, + "source": "no_coordinator", + "count": 0, + "online": 0, + "response": null, + })); + }; + + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "list_node_descriptors", + }), + json!({ + "type": "list_node_descriptors", + "tenant": scope.tenant, + "project": scope.project, + "actor_user": scope.user, + }), + )?)?; + let descriptors = response + .get("descriptors") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let online = descriptors + .iter() + .filter(|descriptor| { + descriptor + .get("online") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + + Ok(json!({ + "checked": true, + "source": "coordinator", + "coordinator": coordinator, + "count": descriptors.len(), + "online": online, + "response": response, + "coordinator_session_requests": session.requests(), + })) +} diff --git a/crates/disasmer-cli/src/config.rs b/crates/disasmer-cli/src/config.rs new file mode 100644 index 0000000..853de18 --- /dev/null +++ b/crates/disasmer-cli/src/config.rs @@ -0,0 +1,149 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::CliScopeArgs; + +pub(crate) const DEFAULT_HOSTED_COORDINATOR_ENDPOINT: &str = "https://disasmer.michelpaulissen.com"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ProjectConfig { + pub(crate) tenant: String, + pub(crate) project: String, + pub(crate) user: String, + pub(crate) coordinator: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct StoredCliSession { + pub(crate) kind: String, + pub(crate) coordinator: String, + pub(crate) tenant: String, + pub(crate) project: String, + pub(crate) user: String, + pub(crate) cli_session_credential_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) session_secret: Option, + pub(crate) token_expiry_posture: String, + pub(crate) expires_at: Option, + pub(crate) provider_tokens_exposed_to_cli: bool, + pub(crate) provider_tokens_sent_to_nodes: bool, + pub(crate) created_at_unix_seconds: u64, +} + +pub(crate) fn default_hosted_coordinator_endpoint() -> String { + DEFAULT_HOSTED_COORDINATOR_ENDPOINT.to_owned() +} + +pub(crate) fn project_config_file(project: &Path) -> PathBuf { + project.join(".disasmer").join("project.json") +} + +pub(crate) fn session_config_file(project: &Path) -> PathBuf { + project.join(".disasmer").join("session.json") +} + +pub(crate) fn read_project_config(project: &Path) -> Result> { + let file = project_config_file(project); + if !file.exists() { + return Ok(None); + } + let bytes = + std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; + let config = serde_json::from_slice(&bytes) + .with_context(|| format!("failed to parse {}", file.display()))?; + Ok(Some(config)) +} + +pub(crate) fn write_project_config(project: &Path, config: &ProjectConfig) -> Result<()> { + let file = project_config_file(project); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::write(&file, serde_json::to_vec_pretty(config)?) + .with_context(|| format!("failed to write {}", file.display())) +} + +pub(crate) fn read_cli_session(project: &Path) -> Result> { + let file = session_config_file(project); + if !file.exists() { + return Ok(None); + } + let bytes = + std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; + let session = serde_json::from_slice(&bytes) + .with_context(|| format!("failed to parse {}", file.display()))?; + Ok(Some(session)) +} + +pub(crate) fn write_cli_session(project: &Path, session: &StoredCliSession) -> Result { + let file = session_config_file(project); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let bytes = serde_json::to_vec_pretty(session)?; + let mut options = std::fs::OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + use std::io::Write; + let mut output = options + .open(&file) + .with_context(|| format!("failed to open {}", file.display()))?; + output + .write_all(&bytes) + .with_context(|| format!("failed to write {}", file.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to secure {}", file.display()))?; + } + Ok(file) +} + +pub(crate) fn effective_project_scope( + scope: &CliScopeArgs, + config: Option<&ProjectConfig>, +) -> CliScopeArgs { + CliScopeArgs { + coordinator: scope + .coordinator + .clone() + .or_else(|| config.and_then(|config| config.coordinator.clone())), + tenant: effective_scope_value( + &scope.tenant, + config.map(|config| config.tenant.as_str()), + "tenant", + ), + project: effective_scope_value( + &scope.project, + config.map(|config| config.project.as_str()), + "project", + ), + user: effective_scope_value( + &scope.user, + config.map(|config| config.user.as_str()), + "user", + ), + json: scope.json, + } +} + +pub(crate) fn effective_scope_value( + cli_value: &str, + config_value: Option<&str>, + default_value: &str, +) -> String { + if cli_value == default_value { + config_value.unwrap_or(cli_value).to_owned() + } else { + cli_value.to_owned() + } +} diff --git a/crates/disasmer-cli/src/confirm.rs b/crates/disasmer-cli/src/confirm.rs new file mode 100644 index 0000000..58d8dab --- /dev/null +++ b/crates/disasmer-cli/src/confirm.rs @@ -0,0 +1,35 @@ +use serde_json::{json, Value}; + +use crate::errors::cli_error_summary_for_category; + +pub(crate) fn confirmation_required_report( + command: &str, + operation: &str, + target: Value, + confirm_command: String, +) -> Value { + let message = format!("{command} requires --yes before {operation}"); + let next_actions = json!([ + confirm_command, + "review the command target before confirming", + "rerun with --json to inspect the safe failure" + ]); + let mut machine_error = cli_error_summary_for_category("policy", &message); + if let Some(object) = machine_error.as_object_mut() { + object.insert("confirmation_required".to_owned(), json!(true)); + object.insert("next_actions".to_owned(), next_actions.clone()); + } + json!({ + "command": command, + "status": "confirmation_required", + "operation": operation, + "target": target, + "requires_confirmation": true, + "confirmation_required": true, + "explicit_user_action_required": true, + "coordinator_request_sent": false, + "safe_failure": true, + "next_actions": next_actions, + "machine_error": machine_error, + }) +} diff --git a/crates/disasmer-cli/src/debug.rs b/crates/disasmer-cli/src/debug.rs new file mode 100644 index 0000000..897283a --- /dev/null +++ b/crates/disasmer-cli/src/debug.rs @@ -0,0 +1,120 @@ +use std::process::Command; + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; + +use crate::client::{ + authenticated_or_local_trusted_request, stored_session_for_coordinator, JsonLineSession, +}; +use crate::config::StoredCliSession; +use crate::tools::dap_binary_path; +use crate::{DapArgs, DebugAttachArgs}; + +pub(crate) fn dap_plan(args: DapArgs) -> Result { + Ok(json!({ + "command": "dap", + "adapter": dap_binary_path()?.display().to_string(), + "args": args.args, + "private_website_required": false, + })) +} + +pub(crate) fn exec_dap(args: DapArgs) -> Result<()> { + let status = Command::new(dap_binary_path()?) + .args(args.args) + .status() + .context("failed to launch disasmer-debug-dap")?; + if !status.success() { + anyhow::bail!("disasmer-debug-dap exited with {status}"); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn debug_attach_report_with_dap(args: DebugAttachArgs, dap: String) -> Result { + debug_attach_report_with_dap_and_session(args, dap, None) +} + +pub(crate) fn debug_attach_report_with_dap_and_session( + mut args: DebugAttachArgs, + dap: String, + stored_session: Option<&StoredCliSession>, +) -> Result { + if args.scope.coordinator.is_none() { + args.scope.coordinator = stored_session + .filter(|session| session.session_secret.is_some()) + .map(|session| session.coordinator.clone()); + } + if let Some(bound_session) = args + .scope + .coordinator + .as_deref() + .and_then(|coordinator| stored_session_for_coordinator(coordinator, stored_session)) + { + args.scope.tenant = bound_session.tenant.clone(); + args.scope.project = bound_session.project.clone(); + args.scope.user = bound_session.user.clone(); + } + if let Some(coordinator) = &args.scope.coordinator { + let tenant = args.scope.tenant.clone(); + let project = args.scope.project.clone(); + let user = args.scope.user.clone(); + let process = args.process.clone(); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "debug_attach", + "process": process, + }), + json!({ + "type": "debug_attach", + "tenant": tenant, + "project": project, + "actor_user": user, + "process": process, + }), + )?)?; + let authorization = response.get("authorization").cloned().unwrap_or_else( + || json!({"allowed": false, "reason": "missing authorization response"}), + ); + return Ok(json!({ + "command": "debug attach", + "process": process, + "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "dap": dap, + "authorized": authorization + .get("allowed") + .cloned() + .unwrap_or(json!(false)), + "authorization": authorization, + "audit_event": response.get("audit_event").cloned().unwrap_or(Value::Null), + "charged_debug_read_bytes": response + .get("charged_debug_read_bytes") + .cloned() + .unwrap_or_else(|| json!(0)), + "used_debug_read_bytes": response + .get("used_debug_read_bytes") + .cloned() + .unwrap_or_else(|| json!(0)), + "debug_reads_quota_limited": true, + "private_website_required": false, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "debug attach", + "process": args.process, + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "dap": dap, + "authorized": "unknown_without_coordinator", + "debug_reads_quota_limited": "unknown_without_coordinator", + "private_website_required": false, + })) +} diff --git a/crates/disasmer-cli/src/dispatch.rs b/crates/disasmer-cli/src/dispatch.rs new file mode 100644 index 0000000..8f726eb --- /dev/null +++ b/crates/disasmer-cli/src/dispatch.rs @@ -0,0 +1,343 @@ +use anyhow::Result; +use clap::Parser; + +use super::*; + +pub(crate) fn run_cli() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Doctor(args) => { + let json_output = args.scope.json; + let report = doctor_report(args, std::env::current_dir()?)?; + emit_report(&report, json_output)?; + } + Commands::Login(args) => { + let args = crate::auth_scope::login_args_for_project(args, &std::env::current_dir()?)?; + let json_output = args.json; + if args.non_interactive && !args.plan { + let report = non_interactive_browser_login_report(&args); + emit_report(&report, json_output)?; + } else if !args.plan { + let report = execute_interactive_browser_login(args)?; + if json_output { + emit_report(&report, true)?; + } else { + print_browser_login_success(&report); + } + } else { + let plan = login_plan(args); + emit_report(&plan, json_output)?; + } + } + Commands::Logout(args) => { + let json_output = args.scope.json; + let report = logout_report(args, std::env::current_dir()?, "logout")?; + emit_report(&report, json_output)?; + } + Commands::Auth { command } => { + let (report, json_output) = match command { + AuthCommands::Status(args) => { + let json_output = args.scope.json; + ( + auth_status_report(args, std::env::current_dir()?)?, + json_output, + ) + } + AuthCommands::ConnectSelfHosted(args) => { + let json_output = args.scope.json; + let secret = read_session_secret_from_stdin()?; + ( + connect_self_hosted_report(args, std::env::current_dir()?, secret)?, + json_output, + ) + } + AuthCommands::Logout(args) => { + let json_output = args.scope.json; + ( + auth_logout_report(args, std::env::current_dir()?)?, + json_output, + ) + } + }; + emit_report(&report, json_output)?; + } + Commands::Agent { + command: AgentCommands::Enroll(args), + } => { + let json_output = args.json; + let plan = agent_enrollment_plan(args); + emit_report(&plan, json_output)?; + } + Commands::Key { command } => { + let cwd = std::env::current_dir()?; + let stored_session = read_cli_session(&cwd)?; + let (report, json_output) = match command { + KeyCommands::Add(args) => { + let json_output = args.scope.json; + ( + key_add_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + KeyCommands::List(args) => { + let json_output = args.scope.json; + ( + key_list_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + KeyCommands::Revoke(args) => { + let json_output = args.scope.json; + ( + key_revoke_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + }; + emit_report(&report, json_output)?; + } + Commands::Project { command } => { + let cwd = std::env::current_dir()?; + let (report, json_output) = match command { + ProjectCommands::Init(args) => { + let json_output = args.scope.json; + (project_init_report(args, cwd)?, json_output) + } + ProjectCommands::Status(args) => { + let json_output = args.scope.json; + (project_status_report(args, cwd)?, json_output) + } + ProjectCommands::List(args) => { + let json_output = args.scope.json; + (project_list_report(args, cwd)?, json_output) + } + ProjectCommands::Select(args) => { + let json_output = args.scope.json; + (project_select_report(args, cwd)?, json_output) + } + }; + emit_report(&report, json_output)?; + } + Commands::Inspect(args) => { + let json_output = args.json; + let inspection = bundle_inspection(args, std::env::current_dir()?)?; + emit_report(&inspection, json_output)?; + } + Commands::Build(args) => { + let json_output = args.json; + let report = build_report(args, std::env::current_dir()?)?; + emit_report(&report, json_output)?; + } + Commands::Bundle { + command: BundleCommands::Inspect(args), + } => { + let json_output = args.json; + let inspection = bundle_inspection(args, std::env::current_dir()?)?; + emit_report(&inspection, json_output)?; + } + Commands::Run(args) => { + let json_output = args.json; + let cwd = std::env::current_dir()?; + let session_project = args.project.clone().unwrap_or_else(|| cwd.clone()); + let session = session_from_sources(&session_project)?; + let report = run_report(args, cwd, session)?; + emit_report(&report, json_output)?; + } + Commands::Node { + command: NodeCommands::Attach(args), + } => { + let json_output = args.json; + if args.coordinator.is_some() { + let report = execute_node_attach(args)?; + emit_report(&report, json_output)?; + } else { + let plan = attach_plan(args); + emit_report(&plan, json_output)?; + } + } + Commands::Node { command } => { + let cwd = std::env::current_dir()?; + let (report, json_output) = match command { + NodeCommands::Enroll(args) => { + let json_output = args.scope.json; + (node_enroll_report(args, cwd.clone())?, json_output) + } + NodeCommands::List(args) => { + let json_output = args.scope.json; + (node_list_report(args, cwd.clone())?, json_output) + } + NodeCommands::Status(args) => { + let json_output = args.scope.json; + (node_status_report(args, cwd.clone())?, json_output) + } + NodeCommands::Revoke(args) => { + let json_output = args.scope.json; + (node_revoke_report(args, cwd)?, json_output) + } + NodeCommands::Attach(_) => unreachable!("node attach is handled above"), + }; + emit_report(&report, json_output)?; + } + Commands::Process { command } => { + let cwd = std::env::current_dir()?; + let stored_session = read_cli_session(&cwd)?; + let (report, json_output) = match command { + ProcessCommands::List(args) => { + let json_output = args.scope.json; + ( + process_list_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + ProcessCommands::Status(args) => { + let json_output = args.scope.json; + ( + process_status_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + ProcessCommands::Restart(args) => { + let json_output = args.scope.json; + ( + process_restart_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + ProcessCommands::Cancel(args) => { + let json_output = args.scope.json; + ( + process_cancel_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + ProcessCommands::Abort(args) => { + let json_output = args.scope.json; + ( + process_abort_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + }; + emit_report(&report, json_output)?; + } + Commands::Task { command } => { + let cwd = std::env::current_dir()?; + let stored_session = read_cli_session(&cwd)?; + let (report, json_output) = match command { + TaskCommands::List(args) => { + let json_output = args.scope.json; + ( + task_list_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + TaskCommands::Restart(args) => { + let json_output = args.scope.json; + ( + task_restart_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + }; + emit_report(&report, json_output)?; + } + Commands::Logs(args) => { + let json_output = args.scope.json; + let cwd = std::env::current_dir()?; + let stored_session = read_cli_session(&cwd)?; + emit_report( + &logs_report_with_session(args, stored_session.as_ref())?, + json_output, + )?; + } + Commands::Artifact { command } => { + let cwd = std::env::current_dir()?; + let stored_session = read_cli_session(&cwd)?; + let (report, json_output) = match command { + ArtifactCommands::List(args) => { + let json_output = args.scope.json; + ( + artifact_list_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + ArtifactCommands::Download(args) => { + let json_output = args.scope.json; + ( + artifact_download_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + ArtifactCommands::Export(args) => { + let json_output = args.scope.json; + ( + artifact_export_report_with_session(args, stored_session.as_ref())?, + json_output, + ) + } + }; + emit_report(&report, json_output)?; + } + Commands::Dap(args) => { + let json_output = args.json; + if args.plan { + emit_report(&dap_plan(args)?, json_output)?; + } else { + return exec_dap(args); + } + } + Commands::Debug { + command: DebugCommands::Attach(args), + } => { + let json_output = args.scope.json; + let cwd = std::env::current_dir()?; + let stored_session = read_cli_session(&cwd)?; + let dap = dap_binary_path()?.display().to_string(); + emit_report( + &debug_attach_report_with_dap_and_session(args, dap, stored_session.as_ref())?, + json_output, + )?; + } + Commands::Quota { + command: QuotaCommands::Status(args), + } => { + let json_output = args.scope.json; + emit_report( + "a_status_report(args, std::env::current_dir()?)?, + json_output, + )?; + } + Commands::Admin { command } => { + let (report, json_output) = match command { + AdminCommands::Status(args) => { + let json_output = args.scope.json; + (admin_status_report(args)?, json_output) + } + AdminCommands::Bootstrap(args) => { + let json_output = args.scope.json; + ( + admin_bootstrap_report(args, std::env::current_dir()?)?, + json_output, + ) + } + AdminCommands::RevokeNode(args) => { + let json_output = args.scope.json; + ( + node_revoke_report(args, std::env::current_dir()?)?, + json_output, + ) + } + AdminCommands::StopProcess(args) => { + let json_output = args.scope.json; + (process_cancel_report(args)?, json_output) + } + AdminCommands::SuspendTenant(args) => { + let json_output = args.scope.json; + (admin_suspend_tenant_report(args)?, json_output) + } + }; + emit_report(&report, json_output)?; + } + } + Ok(()) +} diff --git a/crates/disasmer-cli/src/doctor.rs b/crates/disasmer-cli/src/doctor.rs new file mode 100644 index 0000000..dce6e87 --- /dev/null +++ b/crates/disasmer-cli/src/doctor.rs @@ -0,0 +1,163 @@ +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result}; +use disasmer_control::ControlSession; +use disasmer_core::{coordinator_wire_request, Capability, NodeCapabilities}; +use serde_json::{json, Value}; + +use crate::auth::auth_state_value; +use crate::config::read_project_config; +use crate::tools::{command_available, sibling_binary}; +use crate::DoctorArgs; + +const DOCTOR_COORDINATOR_TIMEOUT: Duration = Duration::from_millis(500); + +pub(crate) fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { + let config = read_project_config(&cwd)?; + let coordinator = args.scope.coordinator.or_else(|| { + config + .as_ref() + .and_then(|config| config.coordinator.clone()) + }); + let coordinator_reachability = coordinator_reachability(coordinator.as_deref()); + let dependencies = json!({ + "cargo": command_available("cargo"), + "git": command_available("git"), + "podman": command_available("podman"), + "disasmer-node": command_available("disasmer-node") || sibling_binary("disasmer-node").is_some(), + "disasmer-coordinator": command_available("disasmer-coordinator") || sibling_binary("disasmer-coordinator").is_some(), + "disasmer-debug-dap": command_available("disasmer-debug-dap") || sibling_binary("disasmer-debug-dap").is_some(), + }); + let node_readiness = NodeCapabilities::detect_current(); + let node_readiness_summary = node_readiness_summary(&node_readiness, &dependencies); + Ok(json!({ + "command": "doctor", + "cwd": cwd, + "coordinator": coordinator, + "coordinator_reachability": coordinator_reachability, + "auth": auth_state_value(&cwd)?, + "project": config, + "dependencies": dependencies, + "node_readiness": node_readiness, + "node_readiness_summary": node_readiness_summary, + "next_actions": [ + "disasmer login --browser", + "disasmer project init", + "disasmer node attach", + "disasmer run" + ] + })) +} + +fn node_readiness_summary(capabilities: &NodeCapabilities, dependencies: &Value) -> Value { + let has_command = capabilities.capabilities.contains(&Capability::Command); + let has_vfs_artifacts = capabilities + .capabilities + .contains(&Capability::VfsArtifacts); + let has_source_filesystem = capabilities + .capabilities + .contains(&Capability::SourceFilesystem); + let has_container_backend = capabilities + .environment_backends + .contains(&disasmer_core::EnvironmentBackend::Container); + let podman_available = dependencies + .get("podman") + .and_then(Value::as_bool) + .unwrap_or(false); + let git_available = dependencies + .get("git") + .and_then(Value::as_bool) + .unwrap_or(false); + let disasmer_node_available = dependencies + .get("disasmer-node") + .and_then(Value::as_bool) + .unwrap_or(false); + + let mut missing = Vec::new(); + if has_container_backend && !podman_available { + missing.push("podman"); + } + if capabilities.source_providers.contains("git") && !git_available { + missing.push("git"); + } + if !disasmer_node_available { + missing.push("disasmer-node"); + } + + let basic_runtime_ready = true; + let local_dependencies_ready = missing.is_empty(); + let status = if basic_runtime_ready && local_dependencies_ready { + "ready_to_attach" + } else if basic_runtime_ready { + "local_dependencies_missing" + } else { + "limited_capabilities" + }; + let next_actions = if status == "ready_to_attach" { + vec![ + "disasmer node enroll", + "disasmer node attach", + "disasmer-node --worker", + ] + } else { + vec![ + "install missing local dependencies", + "rerun disasmer doctor", + ] + }; + + json!({ + "status": status, + "explicit_attach_required": true, + "command_execution_capability": has_command, + "wasm_runtime": "wasmtime", + "wasm_runtime_is_baseline": true, + "artifact_capability": has_vfs_artifacts, + "source_filesystem_capability": has_source_filesystem, + "container_backend_reported": has_container_backend, + "podman_binary_available": podman_available, + "git_binary_available": git_available, + "node_binary_available": disasmer_node_available, + "missing_local_dependencies": missing, + "source_providers": capabilities.source_providers.iter().collect::>(), + "next_actions": next_actions, + }) +} + +fn coordinator_reachability(coordinator: Option<&str>) -> Value { + let Some(coordinator) = coordinator else { + return json!({ + "checked": false, + "status": "not_configured", + "next_action": "run disasmer login --browser or pass --coordinator" + }); + }; + + match ping_coordinator(coordinator, DOCTOR_COORDINATOR_TIMEOUT) { + Ok(response) => json!({ + "checked": true, + "status": "reachable", + "coordinator": coordinator, + "response": response + }), + Err(err) => json!({ + "checked": true, + "status": "unreachable", + "coordinator": coordinator, + "error": err.to_string(), + "next_action": "check the coordinator URL, network, and service status" + }), + } +} + +fn ping_coordinator(coordinator: &str, timeout: Duration) -> Result { + let mut session = ControlSession::connect_with_timeouts(coordinator, timeout, timeout) + .with_context(|| format!("failed to connect to coordinator {coordinator}"))?; + session + .request(&coordinator_wire_request( + "doctor-1", + json!({ "type": "ping" }), + )) + .with_context(|| format!("coordinator ping failed for {coordinator}")) +} diff --git a/crates/disasmer-cli/src/errors.rs b/crates/disasmer-cli/src/errors.rs new file mode 100644 index 0000000..6d74cd6 --- /dev/null +++ b/crates/disasmer-cli/src/errors.rs @@ -0,0 +1,255 @@ +use serde_json::{json, Value}; + +pub(crate) fn cli_error_summary(message: &str) -> Value { + let category = classify_cli_error_message(message); + cli_error_summary_for_category(category, message) +} + +pub(crate) fn cli_error_summary_with_default( + message: &str, + default_category: &'static str, +) -> Value { + let category = classify_cli_error_message(message); + let category = if category == "unknown" { + default_category + } else { + category + }; + cli_error_summary_for_category(category, message) +} + +pub(crate) fn cli_error_summary_for_category(category: &'static str, message: &str) -> Value { + let mut summary = json!({ + "category": category, + "stable_exit_code": cli_error_exit_code(category), + "process_exit_code_applied": false, + "retryable_after_user_action": cli_error_retryable_after_user_action(category), + "message": message, + "safe_failure": true, + "next_actions": cli_error_next_actions(category), + }); + if category == "quota" { + if let Some(object) = summary.as_object_mut() { + object.insert( + "resource_category".to_owned(), + json!( + quota_error_resource_category(message).unwrap_or_else(|| "unknown".to_owned()) + ), + ); + object.insert("community_tier_language".to_owned(), json!(true)); + object.insert("community_tier_label".to_owned(), json!("community tier")); + object.insert("private_abuse_heuristics_exposed".to_owned(), json!(false)); + } + } + summary +} + +fn quota_error_resource_category(message: &str) -> Option { + let lower = message.to_ascii_lowercase(); + for marker in [ + "resource limit exceeded for ", + "quota unavailable for ", + "quota exceeded for ", + "limit exceeded for ", + "metering limit exceeded for ", + "quota limit for ", + "resource category ", + "resource=", + "resource:", + ] { + if let Some(index) = lower.find(marker) { + let start = index + marker.len(); + let rest = &message[start..]; + let value: String = rest + .chars() + .skip_while(|ch| ch.is_ascii_whitespace()) + .take_while(|ch| { + ch.is_ascii_alphanumeric() + || *ch == '_' + || *ch == '-' + || *ch == '.' + || *ch == ':' + }) + .collect(); + let value = value.trim_matches(|ch: char| ch == '.' || ch == ':' || ch == ','); + if !value.is_empty() { + return Some(value.to_owned()); + } + } + } + None +} + +fn classify_cli_error_message(message: &str) -> &'static str { + let message = message.to_ascii_lowercase(); + if message.contains("already has active virtual process") || message.contains("active process") + { + return "active_process"; + } + if message_mentions_locality_failure(&message) { + return "connectivity"; + } + if message.contains("no capable node") + || message.contains("missing capability") + || message.contains("capability") + || message.contains("placement failed") + || message.contains("cmd.run") + || message.contains("node required") + { + return "capability"; + } + if message.contains("quota") + || message.contains("resource limit") + || message.contains("limit exceeded") + || message.contains("metering") + { + return "quota"; + } + if message.contains("policy") + || message.contains("disallowed") + || message.contains("not allowed") + || message.contains("suspended") + || message.contains("blocked by") + || message.contains("denied") + { + return "policy"; + } + if message.contains("unauthenticated") + || message.contains("not authenticated") + || message.contains("login required") + || message.contains("browser login required") + || message.contains("missing session") + || message.contains("no cli session") + || message.contains("session credential has expired") + || message.contains("session credential has been revoked") + || message.contains("401") + { + return "authentication"; + } + if message.contains("unauthorized") + || message.contains("not authorized") + || message.contains("forbidden") + || message.contains("permission") + || message.contains("tenant mismatch") + || message.contains("project mismatch") + { + return "authorization"; + } + if message.contains("failed to connect") + || message.contains("connection refused") + || message.contains("closed session") + || message.contains("timed out") + || message.contains("timeout") + || message.contains("name resolution") + || message.contains("network unreachable") + || message.contains("dns") + { + return "connectivity"; + } + if message.contains("missing environment") + || message.contains("environment mismatch") + || message.contains("incompatible environment") + || message.contains("containerfile") + || message.contains("dockerfile") + || message.contains("envs/") + { + return "environment"; + } + if message.contains("task exited") + || message.contains("exit status") + || message.contains("status code") + || message.contains("stderr") + || message.contains("panic") + || message.contains("program error") + || message.contains("command failed") + { + return "program"; + } + "unknown" +} + +pub(crate) fn message_mentions_locality_failure(message: &str) -> bool { + message.contains("direct connectivity unavailable") + || message.contains("direct transfer") + || message.contains("source snapshot unavailable") + || (message.contains("required artifact") + && message.contains("unavailable") + && message.contains("direct connectivity")) + || message.contains("locality assumption") +} + +fn cli_error_exit_code(category: &str) -> i64 { + match category { + "authentication" => 20, + "authorization" => 21, + "quota" => 22, + "policy" => 23, + "capability" => 24, + "connectivity" => 25, + "environment" => 26, + "program" => 27, + "active_process" => 28, + _ => 1, + } +} + +fn cli_error_retryable_after_user_action(category: &str) -> bool { + matches!( + category, + "authentication" + | "authorization" + | "quota" + | "policy" + | "capability" + | "connectivity" + | "environment" + | "program" + | "active_process" + ) +} + +fn cli_error_next_actions(category: &str) -> Vec<&'static str> { + match category { + "authentication" => vec!["disasmer login --browser", "disasmer auth status"], + "authorization" => vec![ + "disasmer auth status", + "check tenant/project selection", + "ask an admin to grant access", + ], + "quota" => vec![ + "disasmer quota status", + "reduce concurrent work or wait for usage to fall", + ], + "policy" => vec![ + "disasmer doctor", + "check coordinator policy for this action", + ], + "capability" => vec![ + "disasmer node list", + "attach a node with the required capabilities", + "check tenant/project on the attached node", + ], + "connectivity" => vec![ + "disasmer doctor", + "check the coordinator endpoint and network reachability", + ], + "environment" => vec![ + "disasmer inspect", + "check envs//Containerfile or envs//Dockerfile", + ], + "program" => vec!["disasmer logs", "fix the program or task command and rerun"], + "active_process" => vec![ + "disasmer process list", + "disasmer process status", + "disasmer debug attach", + "disasmer process restart --yes", + "disasmer process cancel --yes", + "disasmer process abort --yes", + "use another Coordinator Project", + ], + _ => vec![ + "disasmer doctor", + "rerun with --json for machine-readable details", + ], + } +} diff --git a/crates/disasmer-cli/src/key.rs b/crates/disasmer-cli/src/key.rs new file mode 100644 index 0000000..7a9f0de --- /dev/null +++ b/crates/disasmer-cli/src/key.rs @@ -0,0 +1,271 @@ +use anyhow::Result; +use disasmer_core::Digest; +use serde_json::{json, Value}; + +use crate::client::{authenticated_or_local_trusted_request, JsonLineSession}; +use crate::config::StoredCliSession; +use crate::{confirmation_required_report, KeyAddArgs, KeyListArgs, KeyRevokeArgs}; + +#[cfg(test)] +pub(crate) fn key_add_report(args: KeyAddArgs) -> Result { + key_add_report_with_session(args, None) +} + +pub(crate) fn key_add_report_with_session( + args: KeyAddArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + let coordinator = effective_coordinator(&args.scope.coordinator, stored_session); + if let Some(coordinator) = &coordinator { + let tenant = effective_session_value(stored_session, &args.scope.tenant, |session| { + &session.tenant + }); + let project = effective_session_value(stored_session, &args.scope.project, |session| { + &session.project + }); + let user = + effective_session_value(stored_session, &args.scope.user, |session| &session.user); + let agent = args.agent.clone(); + let public_key_fingerprint = Digest::sha256(&args.public_key); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "register_agent_public_key", + "agent": agent, + "public_key": args.public_key, + }), + json!({ + "type": "register_agent_public_key", + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + "public_key": args.public_key, + }), + )?)?; + let record = response.get("record").cloned().unwrap_or_else(|| { + json!({ + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + "public_key_fingerprint": public_key_fingerprint, + "scopes": ["project:read", "project:run"], + "human_account_creation_privilege": false, + "browser_interaction_required_each_run": false, + }) + }); + return Ok(json!({ + "command": "key add", + "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + "public_key_fingerprint": record + .get("public_key_fingerprint") + .cloned() + .unwrap_or_else(|| json!(public_key_fingerprint)), + "credential_scope": { + "tenant": tenant, + "project": project, + "actions": record + .get("scopes") + .cloned() + .unwrap_or_else(|| json!(["project:read", "project:run"])), + "human_account_creation_privilege": record + .get("human_account_creation_privilege") + .cloned() + .unwrap_or(json!(false)), + }, + "browser_interaction_required_each_run": record + .get("browser_interaction_required_each_run") + .cloned() + .unwrap_or(json!(false)), + "attribution": { + "registered_by_user": user, + "agent": agent, + "credential_kind": "public_key", + }, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "key add", + "status": "planned_without_coordinator", + "agent": args.agent, + "public_key_fingerprint": Digest::sha256(args.public_key), + "browser_interaction_required_each_run": false, + })) +} + +#[cfg(test)] +pub(crate) fn key_list_report(args: KeyListArgs) -> Result { + key_list_report_with_session(args, None) +} + +pub(crate) fn key_list_report_with_session( + args: KeyListArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + let coordinator = effective_coordinator(&args.scope.coordinator, stored_session); + if let Some(coordinator) = &coordinator { + let tenant = effective_session_value(stored_session, &args.scope.tenant, |session| { + &session.tenant + }); + let project = effective_session_value(stored_session, &args.scope.project, |session| { + &session.project + }); + let user = + effective_session_value(stored_session, &args.scope.user, |session| &session.user); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "list_agent_public_keys", + }), + json!({ + "type": "list_agent_public_keys", + "tenant": tenant, + "project": project, + "user": user, + }), + )?)?; + return Ok(json!({ + "command": "key list", + "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "records": response + .get("records") + .cloned() + .unwrap_or_else(|| json!([])), + "credential_scope": { + "tenant": tenant, + "project": project, + "listed_for_user": user, + "human_account_creation_privilege": false, + }, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "key list", + "status": "requires_coordinator", + "records": [], + })) +} + +#[cfg(test)] +pub(crate) fn key_revoke_report(args: KeyRevokeArgs) -> Result { + key_revoke_report_with_session(args, None) +} + +pub(crate) fn key_revoke_report_with_session( + args: KeyRevokeArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + if !args.yes { + return Ok(confirmation_required_report( + "key revoke", + "revoke_agent_public_key", + json!({ + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "user": args.scope.user, + "agent": args.agent, + }), + format!("disasmer key revoke --agent {} --yes", args.agent), + )); + } + let coordinator = effective_coordinator(&args.scope.coordinator, stored_session); + if let Some(coordinator) = &coordinator { + let tenant = effective_session_value(stored_session, &args.scope.tenant, |session| { + &session.tenant + }); + let project = effective_session_value(stored_session, &args.scope.project, |session| { + &session.project + }); + let user = + effective_session_value(stored_session, &args.scope.user, |session| &session.user); + let agent = args.agent.clone(); + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "revoke_agent_public_key", + "agent": agent, + }), + json!({ + "type": "revoke_agent_public_key", + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + }), + )?)?; + return Ok(json!({ + "command": "key revoke", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "tenant": tenant, + "project": project, + "user": user, + "agent": agent, + "revoked": response + .pointer("/record/revoked") + .cloned() + .unwrap_or(json!(true)), + "credential_scope": { + "tenant": tenant, + "project": project, + "revoked_for_user": user, + "human_account_creation_privilege": false, + }, + "attribution": { + "revoked_by_user": user, + "agent": agent, + "credential_kind": "public_key", + }, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "key revoke", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "agent": args.agent, + })) +} + +fn effective_coordinator( + requested: &Option, + stored_session: Option<&StoredCliSession>, +) -> Option { + requested.clone().or_else(|| { + stored_session + .filter(|session| session.session_secret.is_some()) + .map(|session| session.coordinator.clone()) + }) +} + +fn effective_session_value( + stored_session: Option<&StoredCliSession>, + requested: &str, + value: impl FnOnce(&StoredCliSession) -> &String, +) -> String { + stored_session + .filter(|session| session.session_secret.is_some()) + .map(value) + .cloned() + .unwrap_or_else(|| requested.to_owned()) +} diff --git a/crates/disasmer-cli/src/logout.rs b/crates/disasmer-cli/src/logout.rs new file mode 100644 index 0000000..1da7e3a --- /dev/null +++ b/crates/disasmer-cli/src/logout.rs @@ -0,0 +1,127 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; + +use crate::client::JsonLineSession; +use crate::config::{read_cli_session, session_config_file, StoredCliSession}; +use crate::confirm::confirmation_required_report; +use crate::errors::cli_error_summary; +use crate::AuthLogoutArgs; + +pub(crate) fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result { + logout_report(args, cwd, "auth logout") +} + +pub(crate) fn logout_report(args: AuthLogoutArgs, cwd: PathBuf, command: &str) -> Result { + let session_file = session_config_file(&cwd); + if !args.yes { + return Ok(confirmation_required_report( + command, + "delete_cli_session", + json!({ + "session_file": session_file, + "node_credentials_untouched": true, + }), + format!("disasmer {command} --yes"), + )); + } + let stored_session = read_cli_session(&cwd).ok().flatten(); + let coordinator_revocation = revoke_stored_cli_session_if_possible(stored_session.as_ref()); + let existed = session_file.exists(); + if existed { + std::fs::remove_file(&session_file) + .with_context(|| format!("failed to remove {}", session_file.display()))?; + } + Ok(json!({ + "command": command, + "requires_confirmation": !args.yes, + "removed_cli_session_file": existed, + "server_session_revocation": coordinator_revocation, + "node_credentials_untouched": true, + "session_file": session_file, + })) +} + +fn revoke_stored_cli_session_if_possible(stored_session: Option<&StoredCliSession>) -> Value { + let Some(session) = stored_session else { + return json!({ + "attempted": false, + "revoked": false, + "reason": "no_parseable_cli_session", + }); + }; + let Some(session_secret) = session.session_secret.as_deref() else { + return json!({ + "attempted": false, + "revoked": false, + "reason": "no_cli_session_secret", + "coordinator": session.coordinator, + }); + }; + // A CLI session credential is authority issued by one coordinator. Never + // redirect it to a command-line endpoint override. + let coordinator = session.coordinator.as_str(); + let mut coordinator_session = match JsonLineSession::connect(coordinator) { + Ok(session) => session, + Err(error) => { + let message = error.to_string(); + return json!({ + "attempted": true, + "revoked": false, + "reachable": false, + "coordinator": coordinator, + "error": message, + "machine_error": cli_error_summary(&message), + "next_actions": ["disasmer login --browser"], + }); + } + }; + let response = match coordinator_session.request_allow_error(json!({ + "type": "authenticated", + "session_secret": session_secret, + "request": { + "type": "revoke_cli_session", + }, + })) { + Ok(response) => response, + Err(error) => { + let message = error.to_string(); + return json!({ + "attempted": true, + "revoked": false, + "reachable": false, + "coordinator": coordinator, + "error": message, + "machine_error": cli_error_summary(&message), + "coordinator_session_requests": coordinator_session.requests(), + "next_actions": ["disasmer login --browser"], + }); + } + }; + let revoked = response.get("type").and_then(Value::as_str) == Some("cli_session_revoked"); + if !revoked { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("coordinator did not revoke CLI session"); + return json!({ + "attempted": true, + "revoked": false, + "reachable": true, + "coordinator": coordinator, + "coordinator_response_type": response.get("type").and_then(Value::as_str).unwrap_or("unknown"), + "machine_error": cli_error_summary(message), + "coordinator_session_requests": coordinator_session.requests(), + "next_actions": ["disasmer login --browser"], + }); + } + json!({ + "attempted": true, + "revoked": true, + "reachable": true, + "coordinator": coordinator, + "coordinator_response_type": "cli_session_revoked", + "coordinator_session_requests": coordinator_session.requests(), + }) +} diff --git a/crates/disasmer-cli/src/logs.rs b/crates/disasmer-cli/src/logs.rs new file mode 100644 index 0000000..a12815a --- /dev/null +++ b/crates/disasmer-cli/src/logs.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::client::list_task_events_if_available_with_session; +use crate::config::StoredCliSession; +use crate::process_events::log_entries; +use crate::LogsArgs; + +#[cfg(test)] +pub(crate) fn logs_report(args: LogsArgs) -> Result { + logs_report_with_session(args, None) +} + +pub(crate) fn logs_report_with_session( + args: LogsArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + let events = list_task_events_if_available_with_session( + args.scope.coordinator.as_deref(), + &args.scope, + args.process.clone(), + stored_session, + )?; + let log_entries = log_entries(events.as_ref(), args.task.as_deref()); + Ok(json!({ + "command": "logs", + "process": args.process, + "task": args.task, + "log_entries": log_entries, + "logs_are_capped": true, + "secret_redaction_policy": "configured-redaction-boundary", + "events": events, + })) +} diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs new file mode 100644 index 0000000..06b1fe2 --- /dev/null +++ b/crates/disasmer-cli/src/main.rs @@ -0,0 +1,689 @@ +#[cfg(test)] +use std::io::{BufRead, BufReader, Write}; +#[cfg(test)] +use std::net::TcpListener; +#[cfg(test)] +use std::path::Path; +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; +#[cfg(test)] +use disasmer_core::{Capability, Digest, ProjectModel, SourceProviderKind}; +#[cfg(test)] +use serde_json::json; +use serde_json::Value; + +mod admin; +mod agent; +mod artifact; +mod auth; +mod auth_scope; +mod build; +mod bundle; +mod client; +mod config; +mod confirm; +mod debug; +mod dispatch; +mod doctor; +mod errors; +mod key; +mod logout; +mod logs; +mod node; +mod output; +mod process; +mod process_events; +mod project; +mod quota; +mod run; +mod task; +mod tools; + +use admin::{admin_bootstrap_report, admin_status_report, admin_suspend_tenant_report}; +use agent::agent_enrollment_plan; +#[cfg(test)] +use artifact::{ + artifact_download_report, artifact_export_report, artifact_list_report, + artifact_stream_summary, DEFAULT_ARTIFACT_EXPORT_MAX_BYTES, +}; +use artifact::{ + artifact_download_report_with_session, artifact_export_report_with_session, + artifact_list_report_with_session, +}; +use auth::{ + auth_status_report, connect_self_hosted_report, execute_interactive_browser_login, login_plan, + non_interactive_browser_login_report, print_browser_login_success, + read_session_secret_from_stdin, +}; +#[cfg(test)] +use auth::{contains_provider_token_field, stored_cli_session_from_login_response, LoginFlowPlan}; +#[cfg(test)] +use auth_scope::login_args_for_project; +use build::build_report; +#[cfg(test)] +use bundle::task_abi_digest; +pub(crate) use bundle::{bundle_inspection, discovered_environment_names}; +#[cfg(test)] +use client::control_endpoint_identity; +use config::{default_hosted_coordinator_endpoint, read_cli_session}; +#[cfg(test)] +use config::{ + read_project_config, session_config_file, write_cli_session, write_project_config, + ProjectConfig, StoredCliSession, DEFAULT_HOSTED_COORDINATOR_ENDPOINT, +}; +pub(crate) use confirm::confirmation_required_report; +#[cfg(test)] +use debug::debug_attach_report_with_dap; +use debug::{dap_plan, debug_attach_report_with_dap_and_session, exec_dap}; +use doctor::doctor_report; +use errors::cli_error_summary; +#[cfg(test)] +use errors::cli_error_summary_for_category; +#[cfg(test)] +use key::{key_add_report, key_list_report, key_revoke_report}; +use key::{ + key_add_report_with_session, key_list_report_with_session, key_revoke_report_with_session, +}; +use logout::{auth_logout_report, logout_report}; +#[cfg(test)] +use logs::logs_report; +use logs::logs_report_with_session; +use node::{ + attach_plan, execute_node_attach, node_enroll_report, node_list_report, node_revoke_report, + node_status_report, +}; +use output::emit_report; +#[cfg(test)] +use output::{apply_command_report_exit_code, human_report}; +use process::{ + process_abort_report_with_session, process_cancel_report, process_cancel_report_with_session, + process_list_report_with_session, process_restart_report_with_session, + process_status_report_with_session, +}; +#[cfg(test)] +use process::{process_restart_report, process_status_report}; +#[cfg(test)] +use process_events::task_summaries; +#[cfg(test)] +use process_events::{ + artifact_download_session_summary, artifact_export_plan_summary, log_entries, +}; +use project::{ + project_init_report, project_list_report, project_select_report, project_status_report, +}; +use quota::quota_status_report; +#[cfg(test)] +use run::{ + agent_session_from_keys, run_plan, run_start_summary, should_execute_local_node, CliSession, + CoordinatorSelection, +}; +use run::{run_report, session_from_sources}; +#[cfg(test)] +use task::{task_list_report, task_restart_report}; +use task::{task_list_report_with_session, task_restart_report_with_session}; +use tools::dap_binary_path; + +#[derive(Clone, Debug, Parser)] +#[command( + name = "disasmer", + version, + arg_required_else_help = true, + about = "Disasmer distributed Wasm runtime CLI.", + after_help = "Primary workflow: + 1. disasmer login --browser + 2. disasmer project init + 3. disasmer node enroll; disasmer node attach; disasmer-node --worker + 4. disasmer run [entry] --project + 5. Debug with VS Code \"Disasmer: Launch Virtual Process\" or disasmer dap + 6. Inspect with disasmer process list, disasmer process status, task list, logs, and artifact list + 7. Request cooperative shutdown with process cancel; force termination with process abort + +Use --json on primary commands for scriptable output. Hosted account creation happens in the browser login flow." +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Clone, Debug, Subcommand)] +enum Commands { + Doctor(DoctorArgs), + Login(LoginArgs), + Logout(AuthLogoutArgs), + Auth { + #[command(subcommand)] + command: AuthCommands, + }, + Agent { + #[command(subcommand)] + command: AgentCommands, + }, + Key { + #[command(subcommand)] + command: KeyCommands, + }, + Project { + #[command(subcommand)] + command: ProjectCommands, + }, + Inspect(BundleInspectArgs), + Build(BuildArgs), + Bundle { + #[command(subcommand)] + command: BundleCommands, + }, + Run(RunArgs), + Node { + #[command(subcommand)] + command: NodeCommands, + }, + Process { + #[command(subcommand)] + command: ProcessCommands, + }, + Task { + #[command(subcommand)] + command: TaskCommands, + }, + Logs(LogsArgs), + Artifact { + #[command(subcommand)] + command: ArtifactCommands, + }, + Dap(DapArgs), + Debug { + #[command(subcommand)] + command: DebugCommands, + }, + Quota { + #[command(subcommand)] + command: QuotaCommands, + }, + Admin { + #[command(subcommand)] + command: AdminCommands, + }, +} + +#[derive(Clone, Debug, Parser)] +struct DoctorArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct LoginArgs { + #[arg(long = "browser")] + _browser: bool, + #[arg(long)] + non_interactive: bool, + #[arg(long)] + plan: bool, + #[arg(long)] + json: bool, + #[arg(long, default_value_t = default_hosted_coordinator_endpoint())] + coordinator: String, + #[arg(long = "project-id", default_value = "project")] + project: String, +} + +#[derive(Clone, Debug, Subcommand)] +enum AuthCommands { + Status(AuthStatusArgs), + ConnectSelfHosted(ConnectSelfHostedArgs), + Logout(AuthLogoutArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AuthStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct ConnectSelfHostedArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, required = true)] + session_secret_stdin: bool, +} + +#[derive(Clone, Debug, Parser)] +struct AuthLogoutArgs { + #[arg(long)] + yes: bool, + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Subcommand)] +enum AgentCommands { + Enroll(AgentEnrollArgs), +} + +#[derive(Clone, Debug, Subcommand)] +enum KeyCommands { + Add(KeyAddArgs), + List(KeyListArgs), + Revoke(KeyRevokeArgs), +} + +#[derive(Clone, Debug, Subcommand)] +enum BundleCommands { + Inspect(BundleInspectArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AgentEnrollArgs { + #[arg(long)] + json: bool, + #[arg(long = "public-key")] + public_key: String, +} + +#[derive(Clone, Debug, Parser)] +struct KeyAddArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "agent")] + agent: String, + #[arg(long = "public-key")] + public_key: String, +} + +#[derive(Clone, Debug, Parser)] +struct KeyListArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct KeyRevokeArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "agent")] + agent: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum ProjectCommands { + Init(ProjectInitArgs), + Status(ProjectStatusArgs), + List(ProjectListArgs), + Select(ProjectSelectArgs), +} + +#[derive(Clone, Debug, Parser)] +struct ProjectInitArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "project")] + new_project: String, + #[arg(long, default_value = "Disasmer Project")] + name: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct ProjectStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct ProjectListArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct ProjectSelectArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(value_name = "PROJECT")] + selected_project: String, +} + +#[derive(Clone, Debug, Parser)] +struct BundleInspectArgs { + #[arg(long)] + project: Option, + #[arg(long = "source-provider")] + source_provider: Option, + #[arg(long = "disable-source-provider")] + disabled_source_providers: Vec, + #[arg(long)] + json: bool, +} + +#[derive(Clone, Debug, Parser)] +struct BuildArgs { + #[arg(long)] + project: Option, + #[arg(long = "source-provider")] + source_provider: Option, + #[arg(long = "disable-source-provider")] + disabled_source_providers: Vec, + #[arg(long)] + output: Option, + #[arg(long)] + json: bool, +} + +#[derive(Clone, Debug, Parser)] +struct RunArgs { + entry: Option, + #[arg(long)] + project: Option, + #[arg(long)] + coordinator: Option, + #[arg(long)] + local: bool, + #[arg(long)] + non_interactive: bool, + #[arg(long)] + json: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum NodeCommands { + Attach(AttachArgs), + Enroll(NodeEnrollArgs), + List(NodeListArgs), + Status(NodeStatusArgs), + Revoke(NodeRevokeArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AttachArgs { + #[arg(long)] + coordinator: Option, + #[arg(long, default_value = "tenant")] + tenant: String, + #[arg(long = "project-id", default_value = "project")] + project: String, + #[arg(long)] + node: Option, + #[arg(long = "cap")] + caps: Vec, + #[arg(long = "enrollment-grant")] + enrollment_grant: Option, + #[arg(long = "public-key")] + public_key: Option, + #[arg(long)] + json: bool, +} + +#[derive(Clone, Debug, Parser)] +struct NodeEnrollArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value_t = 900)] + ttl_seconds: u64, +} + +#[derive(Clone, Debug, Parser)] +struct NodeListArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct NodeStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + node: Option, +} + +#[derive(Clone, Debug, Parser)] +struct NodeRevokeArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + node: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum ProcessCommands { + List(ProcessListArgs), + Status(ProcessStatusArgs), + Restart(ProcessRestartArgs), + Cancel(ProcessCancelArgs), + Abort(ProcessAbortArgs), +} + +#[derive(Clone, Debug, Parser)] +struct ProcessListArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Parser)] +struct ProcessStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, +} + +#[derive(Clone, Debug, Parser)] +struct ProcessRestartArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct ProcessCancelArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, + #[arg(long)] + node: Option, + #[arg(long)] + task: Option, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct ProcessAbortArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum TaskCommands { + List(TaskListArgs), + Restart(TaskRestartArgs), +} + +#[derive(Clone, Debug, Parser)] +struct TaskListArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + process: Option, +} + +#[derive(Clone, Debug, Parser)] +struct TaskRestartArgs { + #[command(flatten)] + scope: CliScopeArgs, + task: String, + #[arg(long, default_value = "vp-current")] + process: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct LogsArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + process: Option, + #[arg(long)] + task: Option, +} + +#[derive(Clone, Debug, Subcommand)] +enum ArtifactCommands { + List(ArtifactListArgs), + Download(ArtifactDownloadArgs), + Export(ArtifactExportArgs), +} + +#[derive(Clone, Debug, Parser)] +struct ArtifactListArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + process: Option, +} + +#[derive(Clone, Debug, Parser)] +struct ArtifactDownloadArgs { + #[command(flatten)] + scope: CliScopeArgs, + artifact: String, + /// Write the verified artifact bytes to this local path. + #[arg(long)] + to: Option, + #[arg(long, default_value_t = 64 * 1024 * 1024)] + max_bytes: u64, +} + +#[derive(Clone, Debug, Parser)] +struct ArtifactExportArgs { + #[command(flatten)] + scope: CliScopeArgs, + artifact: String, + #[arg(long)] + to: PathBuf, + #[arg(long, default_value = "node-local")] + receiver_node: String, +} + +#[derive(Clone, Debug, Parser)] +struct DapArgs { + #[arg(long)] + plan: bool, + #[arg(long)] + json: bool, + #[arg(last = true)] + args: Vec, +} + +#[derive(Clone, Debug, Subcommand)] +enum DebugCommands { + Attach(DebugAttachArgs), +} + +#[derive(Clone, Debug, Parser)] +struct DebugAttachArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "vp-current")] + process: String, +} + +#[derive(Clone, Debug, Subcommand)] +enum QuotaCommands { + Status(QuotaStatusArgs), +} + +#[derive(Clone, Debug, Parser)] +struct QuotaStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, +} + +#[derive(Clone, Debug, Subcommand)] +enum AdminCommands { + Status(AdminStatusArgs), + Bootstrap(AdminBootstrapArgs), + RevokeNode(NodeRevokeArgs), + StopProcess(ProcessCancelArgs), + SuspendTenant(AdminSuspendTenantArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AdminStatusArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long)] + admin_token: Option, +} + +#[derive(Clone, Debug, Parser)] +struct AdminBootstrapArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long, default_value = "Disasmer Project")] + name: String, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Parser)] +struct AdminSuspendTenantArgs { + #[command(flatten)] + scope: CliScopeArgs, + #[arg(long = "target-tenant")] + target_tenant: Option, + #[arg(long)] + admin_token: Option, + #[arg(long)] + yes: bool, +} + +#[derive(Clone, Debug, Args)] +struct CliScopeArgs { + #[arg(long)] + coordinator: Option, + #[arg(long, default_value = "tenant")] + tenant: String, + #[arg(long = "project-id", default_value = "project")] + project: String, + #[arg(long, default_value = "user")] + user: String, + #[arg(long)] + json: bool, +} + +fn main() { + if let Err(error) = dispatch::run_cli() { + let message = error.to_string(); + let machine_error = cli_error_summary(&message); + let category = machine_error + .get("category") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let exit_code = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()) + .unwrap_or(1); + eprintln!("Error ({category}, exit {exit_code}): {error:#}"); + std::process::exit(exit_code); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/disasmer-cli/src/node.rs b/crates/disasmer-cli/src/node.rs new file mode 100644 index 0000000..c8dbb5c --- /dev/null +++ b/crates/disasmer-cli/src/node.rs @@ -0,0 +1,837 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use disasmer_core::{ + generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request, + signed_request_payload_digest, Capability, Digest, EnvironmentBackend, NodeCapabilities, + NodeId, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::client::{authenticated_or_local_trusted_request, JsonLineSession}; +use crate::config::{effective_scope_value, read_cli_session, StoredCliSession}; +use crate::tools::{command_available, command_nonce, unix_timestamp_seconds}; +use crate::{ + confirmation_required_report, AttachArgs, CliScopeArgs, NodeEnrollArgs, NodeListArgs, + NodeRevokeArgs, NodeStatusArgs, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct NodeAttachPlan { + pub(crate) node: String, + pub(crate) coordinator: Option, + pub(crate) capabilities: NodeCapabilities, + pub(crate) detection: NodeAttachDetectionEvidence, + pub(crate) grant_disclosures: Vec, + pub(crate) enrollment: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct NodeAttachReport { + pub(crate) command: String, + pub(crate) node: String, + pub(crate) plan: NodeAttachPlan, + pub(crate) grant_disclosures: Vec, + pub(crate) boundary: NodeAttachBoundaryEvidence, + pub(crate) coordinator_response: Value, + pub(crate) heartbeat_response: Value, + pub(crate) capability_response: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct NodeAttachBoundaryEvidence { + pub(crate) cli_contacted_coordinator: bool, + pub(crate) coordinator_address: String, + pub(crate) used_enrollment_exchange: bool, + pub(crate) coordinator_session_requests: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct CapabilityGrantDisclosure { + pub(crate) capability: Capability, + pub(crate) grant: String, + pub(crate) description: String, + pub(crate) risk: String, + pub(crate) coordinator_policy_limited: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct NodeAttachDetectionEvidence { + pub(crate) auto_detected: bool, + pub(crate) os: disasmer_core::Os, + pub(crate) arch: String, + pub(crate) command_backend: String, + pub(crate) command_backend_available: bool, + pub(crate) container_backend: Option, + pub(crate) container_backend_reported: bool, + pub(crate) container_backend_available: bool, + pub(crate) source_provider_backends: Vec, + pub(crate) manual_capability_overrides_allowed: bool, + pub(crate) manual_capability_overrides: Vec, + pub(crate) recognized_capability_overrides: Vec, + pub(crate) unrecognized_capability_overrides: Vec, + pub(crate) os_arch_capabilities_require_manual_flags: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct SourceProviderBackendStatus { + pub(crate) provider: String, + pub(crate) detected: bool, + pub(crate) available: bool, + pub(crate) reason: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct NodeEnrollmentPlan { + pub(crate) grant: String, + pub(crate) public_key_fingerprint: Digest, + pub(crate) exchanges_short_lived_grant_for_long_lived_node_identity: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct StoredNodeCredential { + kind: String, + node: String, + private_key: String, + public_key: String, + credential_scope: String, +} + +pub(crate) fn node_enroll_report(args: NodeEnrollArgs, cwd: PathBuf) -> Result { + let stored_session = read_cli_session(&cwd)?; + let coordinator = args.scope.coordinator.clone().or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }); + if let Some(coordinator) = &coordinator { + let tenant = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.tenant, + |session| session.tenant.as_str(), + "tenant", + ); + let project = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.project, + |session| session.project.as_str(), + "project", + ); + let user = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.user, + |session| session.user.as_str(), + "user", + ); + let ttl_seconds = args.ttl_seconds; + let mut session = JsonLineSession::connect(coordinator)?; + let request = authenticated_or_local_trusted_request( + coordinator, + stored_session.as_ref(), + json!({ + "type": "create_node_enrollment_grant", + "ttl_seconds": ttl_seconds, + }), + json!({ + "type": "create_node_enrollment_grant", + "tenant": tenant.clone(), + "project": project.clone(), + "actor_user": user.clone(), + "ttl_seconds": ttl_seconds, + }), + )?; + let response = session.request(request)?; + let enrollment_grant = + node_enrollment_grant_summary(&response, &tenant, &project, &user, ttl_seconds)?; + return Ok(json!({ + "command": "node enroll", + "status": "created", + "coordinator": coordinator, + "tenant": tenant, + "project": project, + "user": user, + "private_website_required": false, + "enrollment_grant": enrollment_grant, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "node enroll", + "status": "requires_coordinator", + "private_website_required": false, + "requested_ttl_seconds": args.ttl_seconds, + "enrollment_grant": null, + "reason": "enrollment grants are generated by the coordinator and cannot be planned client-side", + })) +} + +fn node_enrollment_grant_summary( + response: &Value, + tenant: &str, + project: &str, + user: &str, + ttl_seconds: u64, +) -> Result { + let grant = response + .get("grant") + .and_then(Value::as_str) + .context("coordinator did not return its generated enrollment grant")?; + Ok(json!({ + "grant": grant, + "tenant": response + .get("tenant") + .cloned() + .unwrap_or_else(|| json!(tenant)), + "project": response + .get("project") + .cloned() + .unwrap_or_else(|| json!(project)), + "user": user, + "scope": response + .get("scope") + .cloned() + .unwrap_or_else(|| json!("node:attach")), + "ttl_seconds": ttl_seconds, + "expires_at_epoch_seconds": response + .get("expires_at_epoch_seconds") + .cloned() + .unwrap_or(Value::Null), + "short_lived": true, + "exchange_for_persistent_node_identity": true, + "node_credentials_separate_from_user_session": true, + })) +} + +pub(crate) fn node_list_report(args: NodeListArgs, cwd: PathBuf) -> Result { + node_descriptors_report("node list", args.scope, None, cwd) +} + +pub(crate) fn node_status_report(args: NodeStatusArgs, cwd: PathBuf) -> Result { + node_descriptors_report("node status", args.scope, args.node, cwd) +} + +fn node_descriptors_report( + command: &str, + scope: CliScopeArgs, + node: Option, + cwd: PathBuf, +) -> Result { + let stored_session = read_cli_session(&cwd)?; + let coordinator = scope.coordinator.clone().or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }); + if let Some(coordinator) = &coordinator { + let tenant = session_or_effective_scope_value( + stored_session.as_ref(), + &scope.tenant, + |session| session.tenant.as_str(), + "tenant", + ); + let project = session_or_effective_scope_value( + stored_session.as_ref(), + &scope.project, + |session| session.project.as_str(), + "project", + ); + let user = session_or_effective_scope_value( + stored_session.as_ref(), + &scope.user, + |session| session.user.as_str(), + "user", + ); + let mut session = JsonLineSession::connect(coordinator)?; + let request = authenticated_or_local_trusted_request( + coordinator, + stored_session.as_ref(), + json!({ + "type": "list_node_descriptors", + }), + json!({ + "type": "list_node_descriptors", + "tenant": tenant.clone(), + "project": project.clone(), + "actor_user": user.clone(), + }), + )?; + let response = session.request(request)?; + return Ok(json!({ + "command": command, + "coordinator": coordinator, + "node": node, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": command, + "status": "local_capability_snapshot", + "node": node.unwrap_or_else(default_node_id), + "capabilities": NodeCapabilities::detect_current(), + })) +} + +pub(crate) fn node_revoke_report(args: NodeRevokeArgs, cwd: PathBuf) -> Result { + if !args.yes { + return Ok(confirmation_required_report( + "node revoke", + "revoke_node_credential", + json!({ + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "user": args.scope.user, + "node": args.node, + }), + format!("disasmer node revoke --node {} --yes", args.node), + )); + } + let stored_session = read_cli_session(&cwd)?; + let coordinator = args.scope.coordinator.clone().or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }); + if let Some(coordinator) = &coordinator { + let tenant = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.tenant, + |session| session.tenant.as_str(), + "tenant", + ); + let project = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.project, + |session| session.project.as_str(), + "project", + ); + let user = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.user, + |session| session.user.as_str(), + "user", + ); + let node = args.node.clone(); + let mut session = JsonLineSession::connect(coordinator)?; + let request = authenticated_or_local_trusted_request( + coordinator, + stored_session.as_ref(), + json!({ + "type": "revoke_node_credential", + "node": node.clone(), + }), + json!({ + "type": "revoke_node_credential", + "tenant": tenant.clone(), + "project": project.clone(), + "actor_user": user.clone(), + "node": node.clone(), + }), + )?; + let response = session.request(request)?; + return Ok(json!({ + "command": "node revoke", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "tenant": tenant, + "project": project, + "user": user, + "node": node, + "credential_revoked": response.get("type").and_then(Value::as_str) == Some("node_credential_revoked"), + "descriptor_removed": response + .get("descriptor_removed") + .cloned() + .unwrap_or(json!(false)), + "queued_assignments_removed": response + .get("queued_assignments_removed") + .cloned() + .unwrap_or_else(|| json!(0)), + "node_credentials_separate_from_user_session": true, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "node revoke", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "node": args.node, + })) +} + +fn session_or_effective_scope_value( + stored_session: Option<&StoredCliSession>, + cli_value: &str, + session_value: impl FnOnce(&StoredCliSession) -> &str, + default_value: &str, +) -> String { + if let Some(session) = stored_session.filter(|session| session.session_secret.is_some()) { + session_value(session).to_owned() + } else { + effective_scope_value(cli_value, stored_session.map(session_value), default_value) + } +} + +pub(crate) fn attach_plan(args: AttachArgs) -> NodeAttachPlan { + let mut capabilities = NodeCapabilities::detect_current(); + let mut recognized_capability_overrides = Vec::new(); + let mut unrecognized_capability_overrides = Vec::new(); + for cap in &args.caps { + if let Some(parsed) = parse_capability(cap) { + recognized_capability_overrides.push(parsed.clone()); + capabilities.capabilities.insert(parsed); + } else { + unrecognized_capability_overrides.push(cap.clone()); + } + } + recognized_capability_overrides.sort(); + recognized_capability_overrides.dedup(); + let node = args.node.unwrap_or_else(default_node_id); + let public_key = args + .public_key + .unwrap_or_else(|| default_node_public_key_for_plan(&node)); + let enrollment = args.enrollment_grant.map(|grant| NodeEnrollmentPlan { + grant, + public_key_fingerprint: Digest::sha256(public_key), + exchanges_short_lived_grant_for_long_lived_node_identity: true, + }); + let detection = node_attach_detection_evidence( + &capabilities, + args.caps, + recognized_capability_overrides, + unrecognized_capability_overrides, + ); + let grant_disclosures = capability_grant_disclosures(&capabilities); + + NodeAttachPlan { + node, + coordinator: args.coordinator, + capabilities, + detection, + grant_disclosures, + enrollment, + } +} + +fn node_attach_detection_evidence( + capabilities: &NodeCapabilities, + manual_capability_overrides: Vec, + recognized_capability_overrides: Vec, + unrecognized_capability_overrides: Vec, +) -> NodeAttachDetectionEvidence { + let command_backend_available = capabilities.capabilities.contains(&Capability::Command); + let container_backend_reported = capabilities + .environment_backends + .contains(&EnvironmentBackend::Container); + let container_backend = container_backend_reported.then(|| "rootless-podman".to_owned()); + let container_backend_available = container_backend_reported + && capabilities + .capabilities + .contains(&Capability::RootlessPodman) + && command_available("podman"); + + NodeAttachDetectionEvidence { + auto_detected: true, + os: capabilities.os.clone(), + arch: capabilities.arch.clone(), + command_backend: if command_backend_available { + "native-command".to_owned() + } else { + "unavailable".to_owned() + }, + command_backend_available, + container_backend, + container_backend_reported, + container_backend_available, + source_provider_backends: source_provider_backend_statuses(capabilities), + manual_capability_overrides_allowed: true, + manual_capability_overrides, + recognized_capability_overrides, + unrecognized_capability_overrides, + os_arch_capabilities_require_manual_flags: false, + } +} + +fn source_provider_backend_statuses( + capabilities: &NodeCapabilities, +) -> Vec { + let mut statuses = BTreeMap::new(); + for provider in &capabilities.source_providers { + let available = match provider.as_str() { + "filesystem" => capabilities + .capabilities + .contains(&Capability::SourceFilesystem), + "git" => command_available("git"), + _ => true, + }; + statuses.insert( + provider.clone(), + SourceProviderBackendStatus { + provider: provider.clone(), + detected: true, + available, + reason: if available { + "detected by local node capability probe".to_owned() + } else { + format!( + "source provider `{provider}` was detected but its local helper is missing" + ) + }, + }, + ); + } + statuses.into_values().collect() +} + +fn capability_grant_disclosures(capabilities: &NodeCapabilities) -> Vec { + let mut disclosures = Vec::new(); + let mut push = |capability: Capability, grant: &str, description: &str, risk: &str| { + if capabilities.capabilities.contains(&capability) { + disclosures.push(CapabilityGrantDisclosure { + capability, + grant: grant.to_owned(), + description: description.to_owned(), + risk: risk.to_owned(), + coordinator_policy_limited: true, + }); + } + }; + + push( + Capability::Command, + "native_command_execution", + "placed tasks may run native commands on this node", + "local process execution under the node account", + ); + push( + Capability::WindowsCommandDev, + "native_command_execution", + "placed tasks may run Windows developer commands on this node", + "local process execution under the node account", + ); + push( + Capability::SourceFilesystem, + "source_access", + "placed tasks may read the local project/source checkout exposed by this node", + "broad local source visibility", + ); + push( + Capability::SourceGit, + "source_access", + "placed tasks may use Git-backed source access exposed by this node", + "source-provider visibility", + ); + push( + Capability::Network, + "network_access", + "placed tasks may use outbound network access from this node", + "network egress from the node environment", + ); + push( + Capability::HostFilesystem, + "host_filesystem_access", + "placed tasks may access configured host filesystem mounts", + "host file visibility outside the project checkout", + ); + push( + Capability::Secrets, + "secret_access", + "placed tasks may receive configured secret material", + "secret exposure to authorized task code", + ); + push( + Capability::InboundPorts, + "inbound_ports", + "placed tasks may expose inbound ports from this node", + "network service exposure from the node environment", + ); + push( + Capability::ArbitrarySyscalls, + "arbitrary_syscalls", + "placed tasks may use broader host syscall surface", + "reduced host isolation", + ); + + disclosures.sort_by(|left, right| { + left.grant + .cmp(&right.grant) + .then_with(|| left.capability.cmp(&right.capability)) + }); + disclosures +} + +pub(crate) fn execute_node_attach(args: AttachArgs) -> Result { + let coordinator = args + .coordinator + .clone() + .context("node attach execution requires --coordinator")?; + let tenant = args.tenant.clone(); + let project = args.project.clone(); + let node = args.node.clone().unwrap_or_else(default_node_id); + let node_private_key = node_private_key_for_attach(&node)?; + let derived_public_key = + node_ed25519_public_key_from_private_key(&node_private_key).map_err(anyhow::Error::msg)?; + let public_key = args + .public_key + .clone() + .unwrap_or(derived_public_key.clone()); + if public_key != derived_public_key { + anyhow::bail!( + "node attach --public-key must match DISASMER_NODE_PRIVATE_KEY or the stored local node credential" + ); + } + let mut plan = attach_plan(args); + if let Some(enrollment) = &mut plan.enrollment { + enrollment.public_key_fingerprint = Digest::sha256(&public_key); + } + + let mut session = JsonLineSession::connect(&coordinator)?; + let used_enrollment_exchange = plan.enrollment.is_some(); + let coordinator_response = if let Some(enrollment) = &plan.enrollment { + session.request(json!({ + "type": "exchange_node_enrollment_grant", + "tenant": &tenant, + "project": &project, + "node": &node, + "public_key": &public_key, + "enrollment_grant": enrollment.grant, + }))? + } else { + session.request(json!({ + "type": "attach_node", + "tenant": &tenant, + "project": &project, + "node": &node, + "public_key": &public_key, + }))? + }; + let heartbeat_request = json!({ + "type": "node_heartbeat", + "node": &plan.node, + }); + let heartbeat_signature = sign_node_request( + &node_private_key, + &NodeId::from(plan.node.as_str()), + "node_heartbeat", + &signed_request_payload_digest(&heartbeat_request), + command_nonce("node-heartbeat"), + unix_timestamp_seconds(), + ) + .map_err(anyhow::Error::msg)?; + let mut heartbeat_request = heartbeat_request; + heartbeat_request["node_signature"] = json!(heartbeat_signature); + let heartbeat_response = session.request(heartbeat_request)?; + let capability_response = session.request(signed_node_request_json( + &node_private_key, + &plan.node, + "report_node_capabilities", + json!({ + "type": "report_node_capabilities", + "tenant": &tenant, + "project": &project, + "node": &plan.node, + "capabilities": &plan.capabilities, + "cached_environment_digests": [], + "dependency_cache_digests": [], + "source_snapshots": [], + "artifact_locations": [], + "direct_connectivity": true, + "online": false, + }), + )?)?; + + Ok(NodeAttachReport { + command: "node attach".to_owned(), + node: plan.node.clone(), + grant_disclosures: plan.grant_disclosures.clone(), + plan, + boundary: NodeAttachBoundaryEvidence { + cli_contacted_coordinator: true, + coordinator_address: coordinator, + used_enrollment_exchange, + coordinator_session_requests: session.requests(), + }, + coordinator_response, + heartbeat_response, + capability_response, + }) +} + +fn node_private_key_for_attach(node: &str) -> Result { + if let Ok(private_key) = std::env::var("DISASMER_NODE_PRIVATE_KEY") { + return Ok(private_key); + } + load_or_create_local_node_credential(&std::env::current_dir()?, node) +} + +pub(crate) fn load_or_create_local_node_credential(project: &Path, node: &str) -> Result { + let file = local_node_credential_file(project, node); + if credential_file_exists_without_symlink(&file)? { + let bytes = + std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; + let credential: StoredNodeCredential = serde_json::from_slice(&bytes) + .with_context(|| format!("failed to parse {}", file.display()))?; + if credential.node != node { + anyhow::bail!( + "stored node credential {} belongs to node `{}` instead of `{}`", + file.display(), + credential.node, + node + ); + } + let public_key = node_ed25519_public_key_from_private_key(&credential.private_key) + .map_err(anyhow::Error::msg)?; + if public_key != credential.public_key { + anyhow::bail!( + "stored node credential {} has a public key that does not match its private key", + file.display() + ); + } + return Ok(credential.private_key); + } + + let private_key = generate_ed25519_private_key().map_err(anyhow::Error::msg)?; + let public_key = + node_ed25519_public_key_from_private_key(&private_key).map_err(anyhow::Error::msg)?; + let credential = StoredNodeCredential { + kind: "disasmer_node_credential".to_owned(), + node: node.to_owned(), + private_key: private_key.clone(), + public_key, + credential_scope: "local_project_node_identity".to_owned(), + }; + persist_node_credential(&file, &credential)?; + Ok(private_key) +} + +fn credential_file_exists_without_symlink(file: &Path) -> Result { + match std::fs::symlink_metadata(file) { + Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!( + "refusing to read node credential through symbolic link {}", + file.display() + ), + Ok(metadata) if !metadata.is_file() => anyhow::bail!( + "node credential path {} is not a regular file", + file.display() + ), + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("failed to inspect {}", file.display())), + } +} + +fn persist_node_credential(file: &Path, credential: &StoredNodeCredential) -> Result<()> { + use std::io::Write; + + let parent = file + .parent() + .with_context(|| format!("node credential path {} has no parent", file.display()))?; + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + if std::fs::symlink_metadata(parent)?.file_type().is_symlink() { + anyhow::bail!( + "refusing to store node credential through symbolic-link directory {}", + parent.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to secure {}", parent.display()))?; + } + + let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| { + format!( + "failed to create temporary credential in {}", + parent.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + temporary + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + temporary.write_all(&serde_json::to_vec_pretty(credential)?)?; + temporary.as_file().sync_all()?; + temporary.persist_noclobber(file).map_err(|error| { + anyhow::anyhow!( + "refusing to overwrite node credential {}: {}", + file.display(), + error.error + ) + })?; + Ok(()) +} + +pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf { + let digest = Digest::sha256(node); + let file_stem = digest.as_str().trim_start_matches("sha256:"); + project + .join(".disasmer") + .join("nodes") + .join(format!("{file_stem}.json")) +} + +fn default_node_public_key_for_plan(node: &str) -> String { + let private_key = generate_ed25519_private_key() + .unwrap_or_else(|_| format!("unavailable-random-node-plan-key:{node}")); + node_ed25519_public_key_from_private_key(&private_key) + .unwrap_or_else(|_| format!("{node}-public-key")) +} + +fn signed_node_request_json( + node_private_key: &str, + node: &str, + request_kind: &str, + request: Value, +) -> Result { + let payload_digest = signed_request_payload_digest(&request); + let node_signature = sign_node_request( + node_private_key, + &NodeId::from(node), + request_kind, + &payload_digest, + command_nonce(request_kind), + unix_timestamp_seconds(), + ) + .map_err(anyhow::Error::msg)?; + Ok(json!({ + "type": "signed_node", + "node": node, + "node_signature": node_signature, + "request": request, + })) +} + +pub(crate) fn default_node_id() -> String { + std::env::var("DISASMER_NODE_ID") + .or_else(|_| std::env::var("HOSTNAME")) + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "node-local".to_owned()) +} + +fn parse_capability(cap: &str) -> Option { + match cap { + "command" => Some(Capability::Command), + "containers" => Some(Capability::Containers), + "rootless-podman" => Some(Capability::RootlessPodman), + "source-filesystem" => Some(Capability::SourceFilesystem), + "source-git" => Some(Capability::SourceGit), + "host-filesystem" => Some(Capability::HostFilesystem), + "network" => Some(Capability::Network), + "secrets" => Some(Capability::Secrets), + "inbound-ports" => Some(Capability::InboundPorts), + "arbitrary-syscalls" => Some(Capability::ArbitrarySyscalls), + "vfs-artifacts" => Some(Capability::VfsArtifacts), + "windows-command-dev" => Some(Capability::WindowsCommandDev), + "quic-direct" => Some(Capability::QuicDirect), + _ => None, + } +} diff --git a/crates/disasmer-cli/src/output.rs b/crates/disasmer-cli/src/output.rs new file mode 100644 index 0000000..77e725a --- /dev/null +++ b/crates/disasmer-cli/src/output.rs @@ -0,0 +1,638 @@ +use anyhow::Result; +use serde::Serialize; +use serde_json::{json, Value}; + +pub(crate) fn emit_report(report: &T, json_output: bool) -> Result<()> { + let mut value = serde_json::to_value(report)?; + let exit_code = apply_command_report_exit_code(&mut value); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("{}", human_report(&value)); + } + if let Some(exit_code) = exit_code { + std::process::exit(exit_code); + } + Ok(()) +} + +pub(crate) fn human_report(value: &Value) -> String { + let mut lines = Vec::new(); + let title = value + .get("command") + .and_then(Value::as_str) + .map(|command| format!("Disasmer {command}")) + .or_else(|| { + if value.get("human_flow").is_some() { + Some("Disasmer login".to_owned()) + } else if value.get("metadata").is_some() + && value.get("source_provider_manifest").is_some() + { + Some("Disasmer bundle inspect".to_owned()) + } else if value.get("entry").is_some() && value.get("session").is_some() { + Some("Disasmer run".to_owned()) + } else if value.get("capabilities").is_some() && value.get("node").is_some() { + Some("Disasmer node attach".to_owned()) + } else if value.get("public_key_fingerprint").is_some() { + Some("Disasmer agent enroll".to_owned()) + } else { + None + } + }) + .unwrap_or_else(|| "Disasmer report".to_owned()); + lines.push(title); + + push_string_field(&mut lines, value, "status", "status"); + push_string_field(&mut lines, value, "coordinator", "coordinator"); + push_string_field(&mut lines, value, "active_coordinator", "coordinator"); + push_string_field(&mut lines, value, "tenant", "tenant"); + push_string_field(&mut lines, value, "project", "project"); + push_string_field(&mut lines, value, "process", "process"); + push_string_field(&mut lines, value, "active_process", "active process"); + push_string_field(&mut lines, value, "node", "node"); + push_string_field(&mut lines, value, "artifact", "artifact"); + push_string_field(&mut lines, value, "entry", "entry"); + push_string_field(&mut lines, value, "quota_tier", "quota tier"); + push_string_field(&mut lines, value, "config_file", "config"); + push_string_field(&mut lines, value, "adapter", "adapter"); + + if let Some(project) = value.get("project").and_then(Value::as_str) { + if !lines + .iter() + .any(|line| line == &format!("project: {project}")) + { + lines.push(format!("project: {project}")); + } + } + if let Some(project_config) = value.get("project_config").or_else(|| value.get("project")) { + if project_config.is_object() { + push_nested_string_field(&mut lines, project_config, "tenant", "tenant"); + push_nested_string_field(&mut lines, project_config, "project", "project"); + push_nested_string_field(&mut lines, project_config, "coordinator", "coordinator"); + } + } + if let Some(link) = value.get("current_directory_link") { + if link + .get("links_current_directory") + .and_then(Value::as_bool) + .unwrap_or(false) + { + lines.push("current directory linked: true".to_owned()); + } + push_nested_string_field(&mut lines, link, "config_file", "current directory config"); + } + if let Some(metadata) = value.get("metadata") { + push_nested_string_field(&mut lines, metadata, "identity", "bundle"); + if let Some(environments) = metadata.get("environments").and_then(Value::as_array) { + let names = environments + .iter() + .filter_map(|env| env.get("name").and_then(Value::as_str)) + .collect::>(); + if !names.is_empty() { + lines.push(format!("environments: {}", names.join(", "))); + } + } + } + if let Some(bundle) = value.get("bundle") { + if let Some(metadata) = bundle.get("metadata") { + push_nested_string_field(&mut lines, metadata, "identity", "bundle"); + } + } + if let Some(environments) = value + .get("discovered_environments") + .and_then(Value::as_array) + { + let names = environments + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !names.is_empty() { + lines.push(format!("environments: {}", names.join(", "))); + } + } + if let Some(attached_nodes) = value.get("attached_nodes") { + if let Some(count) = attached_nodes.get("count").and_then(Value::as_u64) { + let online = attached_nodes + .get("online") + .and_then(Value::as_u64) + .unwrap_or(0); + lines.push(format!("attached nodes: {count} ({online} online)")); + } + } + if let Some(current_usage) = value.pointer("/quota_posture/current_usage") { + lines.push(format!("quota usage: {}", compact_json(current_usage))); + } + if let Some(current_task_count) = value.get("current_task_count").and_then(Value::as_u64) { + lines.push(format!("tasks: {current_task_count}")); + } + if let Some(tasks) = value.get("current_tasks").and_then(Value::as_array) { + push_task_placement_reasons(&mut lines, tasks); + push_task_locality_failures(&mut lines, tasks); + } + if let Some(tasks) = value.get("tasks").and_then(Value::as_array) { + lines.push(format!("tasks: {}", tasks.len())); + push_task_placement_reasons(&mut lines, tasks); + push_task_locality_failures(&mut lines, tasks); + } + if let Some(log_entries) = value.get("log_entries").and_then(Value::as_array) { + lines.push(format!("log entries: {}", log_entries.len())); + } + if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) { + lines.push(format!("artifacts: {}", artifacts.len())); + } + if let Some(statuses) = value + .get("source_provider_statuses") + .and_then(Value::as_array) + { + push_source_provider_statuses(&mut lines, statuses); + } + if let Some(bundle_statuses) = value + .pointer("/bundle/source_provider_statuses") + .and_then(Value::as_array) + { + push_source_provider_statuses(&mut lines, bundle_statuses); + } + if let Some(diagnostics) = value + .get("pre_schedule_diagnostics") + .or_else(|| value.get("diagnostics")) + .and_then(Value::as_array) + { + push_cli_diagnostics(&mut lines, diagnostics); + } + if let Some(bundle_diagnostics) = value + .pointer("/bundle/pre_schedule_diagnostics") + .and_then(Value::as_array) + { + push_cli_diagnostics(&mut lines, bundle_diagnostics); + } + if let Some(current_usage) = value.get("current_usage") { + lines.push(format!("quota usage: {}", compact_json(current_usage))); + } + if let Some(next_blocked_action) = value.get("next_blocked_action") { + if !next_blocked_action.is_null() { + lines.push(format!( + "next blocked action: {}", + compact_json(next_blocked_action) + )); + } + } + push_machine_error_line(&mut lines, value.get("machine_error")); + push_machine_error_line(&mut lines, value.pointer("/run_start/machine_error")); + push_machine_error_line(&mut lines, value.pointer("/download_session/machine_error")); + push_machine_error_line(&mut lines, value.pointer("/export_plan/machine_error")); + push_machine_error_line(&mut lines, value.pointer("/local_export/machine_error")); + push_machine_error_line( + &mut lines, + value.pointer("/local_export/download_session/machine_error"), + ); + push_machine_error_line( + &mut lines, + value.pointer("/local_export/stream/machine_error"), + ); + if let Some(flow) = value.get("human_flow") { + if let Some(browser) = flow.get("Browser") { + lines.push("flow: browser".to_owned()); + push_nested_string_field(&mut lines, browser, "authorization_url", "open"); + } + } + if let Some(session) = value.get("session") { + lines.push(format!("session: {}", compact_json(session))); + } + if let Some(account) = value.get("coordinator_account_status") { + if let Some(checked) = account.get("checked").and_then(Value::as_bool) { + lines.push(format!("account status checked: {checked}")); + } + push_nested_string_field(&mut lines, account, "account_status", "account status"); + if let Some(suspended) = account.get("suspended").and_then(Value::as_bool) { + lines.push(format!("account suspended: {suspended}")); + } + if let Some(disabled) = account.get("disabled").and_then(Value::as_bool) { + lines.push(format!("account disabled: {disabled}")); + } + if let Some(deleted) = account.get("deleted").and_then(Value::as_bool) { + lines.push(format!("account deleted: {deleted}")); + } + if let Some(manual_review) = account.get("manual_review").and_then(Value::as_bool) { + lines.push(format!("account manual review: {manual_review}")); + } + push_nested_string_field(&mut lines, account, "sanitized_reason", "account reason"); + if let Some(exposed) = account + .get("private_moderation_details_exposed") + .and_then(Value::as_bool) + { + lines.push(format!("private moderation details exposed: {exposed}")); + } + } + if let Some(coordinator_selection) = value.get("coordinator") { + if coordinator_selection.is_object() { + lines.push(format!( + "coordinator: {}", + compact_json(coordinator_selection) + )); + } + } + if let Some(reachability) = value.get("coordinator_reachability") { + if let Some(status) = reachability.get("status").and_then(Value::as_str) { + lines.push(format!("coordinator reachability: {status}")); + } + if let Some(error) = reachability.get("error").and_then(Value::as_str) { + lines.push(format!("coordinator error: {error}")); + } + if let Some(response_type) = reachability + .pointer("/response/type") + .and_then(Value::as_str) + { + lines.push(format!("coordinator ping: {response_type}")); + } + } + if let Some(response) = value + .get("response") + .or_else(|| value.get("coordinator_response")) + { + if let Some(response_type) = response.get("type").and_then(Value::as_str) { + lines.push(format!("coordinator response: {response_type}")); + } + } + if let Some(events) = value + .pointer("/events/response/events") + .and_then(Value::as_array) + { + lines.push(format!("events: {}", events.len())); + } + if let Some(events) = value + .pointer("/coordinator_response/response/events") + .and_then(Value::as_array) + { + lines.push(format!("events: {}", events.len())); + } + if let Some(requires_confirmation) = value.get("requires_confirmation").and_then(Value::as_bool) + { + lines.push(format!( + "confirmation: {}", + if requires_confirmation { + "required" + } else { + "confirmed" + } + )); + } + if let Some(flag) = value + .get("private_website_required") + .and_then(Value::as_bool) + { + lines.push(format!("private website required: {flag}")); + } + if let Some(next_actions) = value.get("next_actions").and_then(Value::as_array) { + let actions = next_actions + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !actions.is_empty() { + lines.push(format!("next: {}", actions.join("; "))); + } + } + if let Some(auth) = value.get("auth").or_else(|| value.get("session")) { + if let Some(kind) = auth.get("kind").and_then(Value::as_str) { + lines.push(format!("auth: {kind}")); + } + if let Some(authenticated) = auth.get("authenticated").and_then(Value::as_bool) { + lines.push(format!("authenticated: {authenticated}")); + } + if let Some(expires) = auth.get("expires_at").and_then(Value::as_str) { + lines.push(format!("token expiry: {expires}")); + } else if let Some(posture) = auth.get("token_expiry_posture").and_then(Value::as_str) { + lines.push(format!("token expiry: {posture}")); + } else if auth.get("authenticated").is_some() { + lines.push("token expiry: unavailable".to_owned()); + } + } + if let Some(dependencies) = value.get("dependencies").and_then(Value::as_object) { + let mut entries = dependencies + .iter() + .map(|(name, available)| { + format!( + "{name}={}", + if available.as_bool().unwrap_or(false) { + "ok" + } else { + "missing" + } + ) + }) + .collect::>(); + entries.sort(); + if !entries.is_empty() { + lines.push(format!("dependencies: {}", entries.join(", "))); + } + } + if let Some(readiness) = value.get("node_readiness") { + push_nested_string_field(&mut lines, readiness, "os", "node os"); + push_nested_string_field(&mut lines, readiness, "arch", "node arch"); + if let Some(capabilities) = readiness.get("capabilities").and_then(Value::as_array) { + let caps = capabilities + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !caps.is_empty() { + lines.push(format!("node capabilities: {}", caps.join(", "))); + } + } + } + if let Some(summary) = value.get("node_readiness_summary") { + push_nested_string_field(&mut lines, summary, "status", "node readiness"); + if let Some(missing) = summary + .get("missing_local_dependencies") + .and_then(Value::as_array) + { + let missing = missing.iter().filter_map(Value::as_str).collect::>(); + if !missing.is_empty() { + lines.push(format!("node missing dependencies: {}", missing.join(", "))); + } + } + if let Some(actions) = summary.get("next_actions").and_then(Value::as_array) { + let actions = actions.iter().filter_map(Value::as_str).collect::>(); + if !actions.is_empty() { + lines.push(format!("node next: {}", actions.join("; "))); + } + } + } + if let Some(detection) = value + .pointer("/plan/detection") + .or_else(|| value.get("detection")) + { + push_node_attach_detection(&mut lines, detection); + } + if let Some(disclosures) = value + .get("grant_disclosures") + .and_then(Value::as_array) + .filter(|disclosures| !disclosures.is_empty()) + { + for disclosure in disclosures { + let grant = disclosure + .get("grant") + .and_then(Value::as_str) + .unwrap_or("capability"); + let description = disclosure + .get("description") + .and_then(Value::as_str) + .unwrap_or("capability grant"); + let policy = if disclosure + .get("coordinator_policy_limited") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "policy-limited" + } else { + "unbounded" + }; + lines.push(format!("grant {grant}: {description} ({policy})")); + } + } + + lines.dedup(); + lines.join("\n") +} + +fn push_node_attach_detection(lines: &mut Vec, detection: &Value) { + push_nested_string_field(lines, detection, "os", "node os"); + push_nested_string_field(lines, detection, "arch", "node arch"); + push_nested_string_field(lines, detection, "command_backend", "command backend"); + if let Some(backend) = detection.get("container_backend").and_then(Value::as_str) { + let available = detection + .get("container_backend_available") + .and_then(Value::as_bool) + .unwrap_or(false); + lines.push(format!( + "container backend: {backend} ({})", + if available { "available" } else { "reported" } + )); + } else if detection + .get("container_backend_reported") + .and_then(Value::as_bool) + .unwrap_or(false) + { + lines.push("container backend: reported".to_owned()); + } + if let Some(providers) = detection + .get("source_provider_backends") + .and_then(Value::as_array) + { + let entries = providers + .iter() + .filter_map(|provider| { + let name = provider.get("provider").and_then(Value::as_str)?; + let state = if provider + .get("available") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "available" + } else { + "detected" + }; + Some(format!("{name}={state}")) + }) + .collect::>(); + if !entries.is_empty() { + lines.push(format!("source providers: {}", entries.join(", "))); + } + } + if let Some(overrides) = detection + .get("manual_capability_overrides") + .and_then(Value::as_array) + { + let overrides = overrides + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !overrides.is_empty() { + lines.push(format!("capability overrides: {}", overrides.join(", "))); + } + } +} + +fn push_string_field(lines: &mut Vec, value: &Value, key: &str, label: &str) { + if let Some(text) = value.get(key).and_then(Value::as_str) { + lines.push(format!("{label}: {text}")); + } else if let Some(path) = value.get(key).and_then(|value| { + value + .as_object() + .and_then(|object| object.get("display")) + .and_then(Value::as_str) + }) { + lines.push(format!("{label}: {path}")); + } +} + +fn push_task_placement_reasons(lines: &mut Vec, tasks: &[Value]) { + for task in tasks { + let Some(placement) = task.get("node_placement") else { + continue; + }; + let Some(reasons) = placement.get("reasons").and_then(Value::as_array) else { + continue; + }; + let reasons = reasons.iter().filter_map(Value::as_str).collect::>(); + if reasons.is_empty() { + continue; + } + let task_name = task + .get("task") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let node = placement + .get("node") + .and_then(Value::as_str) + .unwrap_or("unknown"); + lines.push(format!( + "placement {task_name}: {node} ({})", + reasons.join(", ") + )); + } +} + +fn push_task_locality_failures(lines: &mut Vec, tasks: &[Value]) { + for task in tasks { + let Some(locality) = task.get("locality_failure") else { + continue; + }; + if locality.is_null() { + continue; + } + let task_name = task + .get("task") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let affected = locality + .get("affected_data") + .and_then(Value::as_str) + .unwrap_or("direct_transfer"); + let reason = locality + .get("reason") + .and_then(Value::as_str) + .unwrap_or("direct transfer or locality failed"); + lines.push(format!("locality {task_name}: {affected} ({reason})")); + let actions = locality + .get("safe_next_actions") + .and_then(Value::as_array) + .map(|actions| actions.iter().filter_map(Value::as_str).collect::>()) + .unwrap_or_default(); + if !actions.is_empty() { + lines.push(format!("locality next {task_name}: {}", actions.join("; "))); + } + } +} + +fn push_nested_string_field(lines: &mut Vec, value: &Value, key: &str, label: &str) { + if let Some(text) = value.get(key).and_then(Value::as_str) { + lines.push(format!("{label}: {text}")); + } +} + +fn push_machine_error_line(lines: &mut Vec, machine_error: Option<&Value>) { + let Some(machine_error) = machine_error else { + return; + }; + if machine_error.is_null() { + return; + } + let category = machine_error + .get("category") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let exit_code = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .unwrap_or(1); + lines.push(format!("error category: {category} (exit {exit_code})")); + if let Some(tier) = machine_error + .get("community_tier_label") + .and_then(Value::as_str) + { + lines.push(format!("quota tier: {tier}")); + } + if let Some(next_actions) = machine_error.get("next_actions").and_then(Value::as_array) { + let actions = next_actions + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !actions.is_empty() { + lines.push(format!("error next: {}", actions.join("; "))); + } + } +} + +fn push_source_provider_statuses(lines: &mut Vec, statuses: &[Value]) { + let entries = statuses + .iter() + .filter_map(|status| { + let provider = status.get("provider").and_then(Value::as_str)?; + let state = status.get("status").and_then(Value::as_str)?; + let active = status + .get("active") + .and_then(Value::as_bool) + .unwrap_or(false); + Some(format!( + "{provider}={state}{}", + if active { "(active)" } else { "" } + )) + }) + .collect::>(); + if !entries.is_empty() { + lines.push(format!("source providers: {}", entries.join(", "))); + } +} + +fn push_cli_diagnostics(lines: &mut Vec, diagnostics: &[Value]) { + if diagnostics.is_empty() { + return; + } + lines.push(format!("diagnostics: {}", diagnostics.len())); + for diagnostic in diagnostics.iter().take(3) { + let severity = diagnostic + .get("severity") + .and_then(Value::as_str) + .unwrap_or("info"); + let message = diagnostic + .get("message") + .and_then(Value::as_str) + .unwrap_or("diagnostic"); + lines.push(format!("diagnostic {severity}: {message}")); + } +} + +fn compact_json(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "".to_owned()) +} + +pub(crate) fn apply_command_report_exit_code(value: &mut Value) -> Option { + for pointer in [ + "/machine_error", + "/run_start/machine_error", + "/restart_request/machine_error", + "/cancel_request/machine_error", + "/task_restart/machine_error", + "/download_session/machine_error", + "/export_plan/machine_error", + "/local_export/machine_error", + "/local_export/download_session/machine_error", + "/local_export/stream/machine_error", + ] { + let Some(machine_error) = value.pointer_mut(pointer) else { + continue; + }; + let Some(exit_code) = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()) + .filter(|code| *code != 0) + else { + continue; + }; + if let Some(object) = machine_error.as_object_mut() { + object.insert("process_exit_code_applied".to_owned(), json!(true)); + } + return Some(exit_code); + } + None +} diff --git a/crates/disasmer-cli/src/process.rs b/crates/disasmer-cli/src/process.rs new file mode 100644 index 0000000..cfd7670 --- /dev/null +++ b/crates/disasmer-cli/src/process.rs @@ -0,0 +1,333 @@ +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::client::{ + authenticated_or_local_trusted_request, list_task_events_if_available_with_session, + stored_session_for_coordinator, JsonLineSession, +}; +use crate::config::StoredCliSession; +use crate::process_events::{ + process_cancel_request_summary, process_restart_request_summary, process_state_from_tasks, + task_summaries, +}; +use crate::{ + confirmation_required_report, CliScopeArgs, ProcessAbortArgs, ProcessCancelArgs, + ProcessListArgs, ProcessRestartArgs, ProcessStatusArgs, +}; + +fn hydrate_process_scope(scope: &mut CliScopeArgs, stored_session: Option<&StoredCliSession>) { + if scope.coordinator.is_none() { + scope.coordinator = stored_session + .filter(|session| session.session_secret.is_some()) + .map(|session| session.coordinator.clone()); + } + if let Some(bound_session) = scope + .coordinator + .as_deref() + .and_then(|coordinator| stored_session_for_coordinator(coordinator, stored_session)) + { + scope.tenant = bound_session.tenant.clone(); + scope.project = bound_session.project.clone(); + scope.user = bound_session.user.clone(); + } +} + +pub(crate) fn process_list_report_with_session( + mut args: ProcessListArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + hydrate_process_scope(&mut args.scope, stored_session); + let Some(coordinator) = &args.scope.coordinator else { + return Ok(json!({ + "command": "process list", + "status": "requires_coordinator", + "processes": [], + })); + }; + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ "type": "list_processes" }), + json!({ + "type": "list_processes", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + }), + )?)?; + let processes = response + .get("processes") + .cloned() + .unwrap_or_else(|| json!([])); + Ok(json!({ + "command": "process list", + "status": "ok", + "coordinator": coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "user": args.scope.user, + "processes": processes, + "response": response, + "coordinator_session_requests": session.requests(), + })) +} + +#[cfg(test)] +pub(crate) fn process_status_report(args: ProcessStatusArgs) -> Result { + process_status_report_with_session(args, None) +} + +pub(crate) fn process_status_report_with_session( + mut args: ProcessStatusArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + hydrate_process_scope(&mut args.scope, stored_session); + let live = process_list_report_with_session( + ProcessListArgs { + scope: args.scope.clone(), + }, + stored_session, + )?; + let live_process = live + .get("processes") + .and_then(Value::as_array) + .and_then(|processes| { + processes.iter().find(|process| { + process.get("process").and_then(Value::as_str) == Some(args.process.as_str()) + }) + }) + .cloned(); + let events = list_task_events_if_available_with_session( + args.scope.coordinator.as_deref(), + &args.scope, + Some(args.process.clone()), + stored_session, + )?; + let current_tasks = task_summaries(events.as_ref()); + let current_task_count = current_tasks.as_array().map(Vec::len).unwrap_or(0); + let historical_state = process_state_from_tasks(events.as_ref()); + let state = live_process + .as_ref() + .and_then(|process| process.get("state")) + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + if live.get("status").and_then(Value::as_str) == Some("ok") { + "not_active".to_owned() + } else if events.is_some() { + historical_state.to_owned() + } else { + "unknown_without_coordinator".to_owned() + } + }); + Ok(json!({ + "command": "process status", + "process": args.process, + "state": state, + "live_process": live_process, + "started_entrypoint": "unknown_from_task_events", + "current_task_count": current_task_count, + "current_tasks": current_tasks, + "debug_state": { + "frozen": null, + "status": "unknown_from_cli_task_events" + }, + "events": events, + })) +} + +#[cfg(test)] +pub(crate) fn process_restart_report(args: ProcessRestartArgs) -> Result { + process_restart_report_with_session(args, None) +} + +pub(crate) fn process_restart_report_with_session( + mut args: ProcessRestartArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + hydrate_process_scope(&mut args.scope, stored_session); + if !args.yes { + return Ok(confirmation_required_report( + "process restart", + "restart_virtual_process", + json!({ + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "process": args.process, + }), + format!("disasmer process restart --process {} --yes", args.process), + )); + } + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "start_process", + "process": args.process, + "restart": true, + }), + json!({ + "type": "start_process", + "tenant": args.scope.tenant, + "project": args.scope.project, + "process": args.process, + "restart": true, + }), + )?)?; + let restart_request = process_restart_request_summary(&response, !args.yes); + return Ok(json!({ + "command": "process restart", + "coordinator": coordinator, + "process": args.process, + "requires_confirmation": !args.yes, + "restart_request": restart_request, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "process restart", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "process": args.process, + "restart_request": { + "status": "requires_coordinator", + "operation": "restart_virtual_process", + "explicit_user_action": true, + }, + })) +} + +pub(crate) fn process_cancel_report(args: ProcessCancelArgs) -> Result { + process_cancel_report_with_session(args, None) +} + +pub(crate) fn process_cancel_report_with_session( + mut args: ProcessCancelArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + hydrate_process_scope(&mut args.scope, stored_session); + if !args.yes { + return Ok(confirmation_required_report( + "process cancel", + "cancel_virtual_process", + json!({ + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "process": args.process, + "node": args.node, + "task": args.task, + }), + format!("disasmer process cancel --process {} --yes", args.process), + )); + } + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "cancel_process", + "process": args.process, + }), + json!({ + "type": "cancel_process", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "process": args.process, + }), + )?)?; + let cancel_request = process_cancel_request_summary(&response, !args.yes); + return Ok(json!({ + "command": "process cancel", + "coordinator": coordinator, + "requires_confirmation": !args.yes, + "process": args.process, + "node": args.node, + "task": args.task, + "cancel_request": cancel_request, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "process cancel", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "process": args.process, + "node": args.node, + "task": args.task, + "cancel_request": { + "status": "requires_coordinator", + "operation": "cancel_virtual_process", + "whole_process_cancel_available": true, + "explicit_user_action": true, + }, + })) +} + +pub(crate) fn process_abort_report_with_session( + mut args: ProcessAbortArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + hydrate_process_scope(&mut args.scope, stored_session); + if !args.yes { + return Ok(confirmation_required_report( + "process abort", + "abort_virtual_process", + json!({ + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "process": args.process, + }), + format!("disasmer process abort --process {} --yes", args.process), + )); + } + let Some(coordinator) = &args.scope.coordinator else { + return Ok(json!({ + "command": "process abort", + "status": "requires_coordinator", + "process": args.process, + "requires_confirmation": false, + })); + }; + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "abort_process", + "process": args.process, + }), + json!({ + "type": "abort_process", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "process": args.process, + }), + )?)?; + Ok(json!({ + "command": "process abort", + "status": "aborted", + "coordinator": coordinator, + "process": args.process, + "requires_confirmation": false, + "abort_request": { + "accepted": response.get("type").and_then(Value::as_str) == Some("process_aborted"), + "operation": "abort_virtual_process", + "forced": true, + "cooperative": false, + "process_slot_released": true, + }, + "response": response, + "coordinator_session_requests": session.requests(), + })) +} diff --git a/crates/disasmer-cli/src/process_events.rs b/crates/disasmer-cli/src/process_events.rs new file mode 100644 index 0000000..fa577c7 --- /dev/null +++ b/crates/disasmer-cli/src/process_events.rs @@ -0,0 +1,666 @@ +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::errors::{ + cli_error_summary, cli_error_summary_for_category, cli_error_summary_with_default, + message_mentions_locality_failure, +}; + +fn task_event_values(task_events: Option<&Value>) -> Vec<&Value> { + task_events + .and_then(|task_events| task_events.pointer("/response/events")) + .and_then(Value::as_array) + .map(|events| events.iter().collect()) + .unwrap_or_default() +} + +fn event_string(event: &Value, field: &str) -> Option { + event.get(field).and_then(Value::as_str).map(str::to_owned) +} + +fn event_u64(event: &Value, field: &str) -> Option { + event.get(field).and_then(Value::as_u64) +} + +pub(crate) fn task_summaries(task_events: Option<&Value>) -> Value { + Value::Array( + task_event_values(task_events) + .into_iter() + .map(|event| { + let task = event_string(event, "task").unwrap_or_else(|| "unknown".to_owned()); + let terminal_state = + event_string(event, "terminal_state").unwrap_or_else(|| "unknown".to_owned()); + let node = event_string(event, "node"); + let placement = event.get("placement"); + let placement_reasons = placement + .and_then(|placement| placement.get("reasons")) + .cloned() + .unwrap_or_else(|| json!([])); + let placement_score = placement + .and_then(|placement| placement.get("score")) + .cloned() + .unwrap_or(Value::Null); + let failure_reason = task_failure_reason(event); + let locality_failure = task_locality_failure_from_reason(&failure_reason); + let machine_error = task_failure_machine_error_from_reason(event, &failure_reason); + json!({ + "process": event_string(event, "process"), + "task": task, + "state": terminal_state, + "environment": event.get("environment").cloned().unwrap_or_else(|| json!("unknown_from_task_event")), + "environment_digest": event.get("environment_digest").cloned().unwrap_or(Value::Null), + "node_placement": { + "node": node, + "source": "coordinator_task_event", + "score": placement_score, + "reasons": placement_reasons, + "explanation_available": placement.is_some(), + }, + "failure_reason": failure_reason, + "locality_failure": locality_failure, + "machine_error": machine_error, + "stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0), + "stderr_bytes": event_u64(event, "stderr_bytes").unwrap_or(0), + }) + }) + .collect(), + ) +} + +fn task_failure_reason(event: &Value) -> Value { + match event.get("terminal_state").and_then(Value::as_str) { + Some("failed") => { + if let Some(stderr) = event.get("stderr_tail").and_then(Value::as_str) { + if !stderr.is_empty() { + return json!(redact_secret_like_text(stderr).0); + } + } + if let Some(status_code) = event.get("status_code").and_then(Value::as_i64) { + return json!(format!("task exited with status {status_code}")); + } + json!("task failed") + } + Some("cancelled") => json!("task cancelled"), + _ => Value::Null, + } +} + +fn task_failure_machine_error_from_reason(event: &Value, reason: &Value) -> Value { + match event.get("terminal_state").and_then(Value::as_str) { + Some("failed") => { + let reason = reason.as_str().unwrap_or("task failed").to_owned(); + let mut summary = cli_error_summary_with_default(&reason, "program"); + if let Some(object) = summary.as_object_mut() { + if message_mentions_locality_failure(&reason.to_ascii_lowercase()) { + object.insert("locality_failure".to_owned(), json!(true)); + object.insert( + "next_actions".to_owned(), + json!(locality_failure_next_actions(&reason)), + ); + } + } + summary + } + Some("cancelled") => cli_error_summary_for_category("program", "task cancelled"), + _ => Value::Null, + } +} + +fn task_locality_failure_from_reason(reason: &Value) -> Value { + let Some(reason) = reason.as_str() else { + return Value::Null; + }; + let lower = reason.to_ascii_lowercase(); + if !message_mentions_locality_failure(&lower) { + return Value::Null; + } + let affected_data = if lower.contains("source snapshot") { + "source_snapshot" + } else if lower.contains("artifact") { + "artifact" + } else { + "direct_transfer" + }; + json!({ + "category": "connectivity", + "affected_data": affected_data, + "reason": reason, + "coordinator_bulk_relay_used": false, + "safe_failure": true, + "safe_next_actions": locality_failure_next_actions(reason), + }) +} + +fn locality_failure_next_actions(reason: &str) -> Vec<&'static str> { + let lower = reason.to_ascii_lowercase(); + if lower.contains("source snapshot") { + return vec![ + "attach or select a node that already has the required source snapshot", + "rerun source preparation on an attached node", + "restore direct node-to-node connectivity and retry", + "do not rely on coordinator bulk source relay", + ]; + } + if lower.contains("artifact") { + return vec![ + "attach or select a node that already has the required artifact", + "explicitly export or download the artifact before retrying", + "restore direct node-to-node connectivity and retry", + "do not rely on coordinator bulk artifact relay", + ]; + } + vec![ + "check node direct connectivity and NAT traversal", + "attach a node with the needed source/artifact locality", + "retry after node connectivity is restored", + "do not rely on coordinator bulk relay", + ] +} + +pub(crate) fn process_state_from_tasks(task_events: Option<&Value>) -> &'static str { + let events = task_event_values(task_events); + if events.is_empty() { + return "no_tasks_observed"; + } + if events.iter().any(|event| { + event + .get("terminal_state") + .and_then(Value::as_str) + .is_some_and(|state| state == "failed") + }) { + return "has_failed_tasks"; + } + if events.iter().any(|event| { + event + .get("terminal_state") + .and_then(Value::as_str) + .is_some_and(|state| state == "cancelled") + }) { + return "has_cancelled_tasks"; + } + if events.iter().all(|event| { + event + .get("terminal_state") + .and_then(Value::as_str) + .is_some_and(|state| state == "completed") + }) { + return "completed_tasks_observed"; + } + "tasks_observed" +} + +pub(crate) fn log_entries(task_events: Option<&Value>, task_filter: Option<&str>) -> Value { + Value::Array( + task_event_values(task_events) + .into_iter() + .filter(|event| { + task_filter.is_none_or( |task_filter| { + event + .get("task") + .and_then(Value::as_str) + .is_some_and(|task| task == task_filter) + }) + }) + .map(|event| { + let stdout_tail = event + .get("stdout_tail") + .and_then(Value::as_str) + .unwrap_or(""); + let stderr_tail = event + .get("stderr_tail") + .and_then(Value::as_str) + .unwrap_or(""); + let (stdout_tail, stdout_tail_redacted) = redact_secret_like_text(stdout_tail); + let (stderr_tail, stderr_tail_redacted) = redact_secret_like_text(stderr_tail); + json!({ + "process": event_string(event, "process"), + "task": event_string(event, "task"), + "node": event_string(event, "node"), + "stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0), + "stderr_bytes": event_u64(event, "stderr_bytes").unwrap_or(0), + "stdout_tail": stdout_tail, + "stderr_tail": stderr_tail, + "stdout_truncated": event.get("stdout_truncated").and_then(Value::as_bool).unwrap_or(false), + "stderr_truncated": event.get("stderr_truncated").and_then(Value::as_bool).unwrap_or(false), + "capped": true, + "secret_like_values_redacted": stdout_tail_redacted || stderr_tail_redacted, + "redacted_fields": redacted_log_fields(stdout_tail_redacted, stderr_tail_redacted), + }) + }) + .collect(), + ) +} + +fn redacted_log_fields( + stdout_tail_redacted: bool, + stderr_tail_redacted: bool, +) -> Vec<&'static str> { + let mut fields = Vec::new(); + if stdout_tail_redacted { + fields.push("stdout_tail"); + } + if stderr_tail_redacted { + fields.push("stderr_tail"); + } + fields +} + +fn redact_secret_like_text(text: &str) -> (String, bool) { + let markers = [ + "access_token=", + "access_token:", + "refresh_token=", + "refresh_token:", + "id_token=", + "id_token:", + "api_key=", + "api_key:", + "api-key=", + "api-key:", + "token=", + "token:", + "secret=", + "secret:", + "password=", + "password:", + "passwd=", + "passwd:", + "bearer ", + ]; + let mut output = text.to_owned(); + let mut redacted = false; + for marker in markers { + let (updated, changed) = redact_marker_values(output, marker); + output = updated; + redacted |= changed; + } + (output, redacted) +} + +fn redact_marker_values(mut text: String, marker: &str) -> (String, bool) { + let mut changed = false; + let mut search_start = 0; + loop { + let lower = text.to_ascii_lowercase(); + let Some(relative) = lower[search_start..].find(marker) else { + break; + }; + let value_start = search_start + relative + marker.len(); + let value_end = text[value_start..] + .char_indices() + .find_map(|(offset, character)| { + (character.is_whitespace() + || matches!( + character, + '&' | '"' | '\'' | '`' | '<' | '>' | ',' | ';' | ')' | ']' + )) + .then_some(value_start + offset) + }) + .unwrap_or(text.len()); + if value_start == value_end { + search_start = value_end; + if search_start >= text.len() { + break; + } + continue; + } + if text[value_start..value_end].starts_with("[redacted") { + search_start = value_end; + if search_start >= text.len() { + break; + } + continue; + } + text.replace_range(value_start..value_end, "[redacted]"); + changed = true; + search_start = value_start + "[redacted]".len(); + if search_start >= text.len() { + break; + } + } + (text, changed) +} + +pub(crate) fn artifact_summaries(task_events: Option<&Value>) -> Value { + Value::Array( + task_event_values(task_events) + .into_iter() + .filter_map(|event| { + let path = event.get("artifact_path").and_then(Value::as_str)?; + let node = event_string(event, "node"); + Some(json!({ + "artifact": artifact_name_from_path(path), + "path": path, + "producer_task": event_string(event, "task"), + "producer_node": node, + "process": event_string(event, "process"), + "digest": event.get("artifact_digest").cloned().unwrap_or(Value::Null), + "size_bytes": event.get("artifact_size_bytes").cloned().unwrap_or(Value::Null), + "state": if event.get("artifact_digest").is_some() { "metadata_flushed" } else { "metadata_without_digest" }, + "known_locations": node.into_iter().collect::>(), + "durable_storage": false, + })) + }) + .collect(), + ) +} + +fn artifact_name_from_path(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_owned() +} + +fn response_error_message(response: &Value, fallback: &str) -> String { + response + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| fallback.to_owned()) +} + +pub(crate) fn artifact_response_machine_error( + response: &Value, + fallback: &str, + default_category: &'static str, +) -> Value { + let message = response_error_message(response, fallback); + cli_error_summary_with_default(&message, default_category) +} + +pub(crate) fn process_restart_request_summary( + response: &Value, + requires_confirmation: bool, +) -> Value { + if response.get("type").and_then(Value::as_str) != Some("process_started") { + let message = response_error_message(response, "coordinator rejected process restart"); + return json!({ + "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), + "operation": "restart_virtual_process", + "accepted": false, + "requires_confirmation": requires_confirmation, + "explicit_user_action": true, + "error": message, + "machine_error": cli_error_summary(&message), + }); + } + json!({ + "status": "process_started", + "operation": "restart_virtual_process", + "accepted": true, + "process": response.get("process").cloned().unwrap_or(Value::Null), + "coordinator_epoch": response.get("epoch").cloned().unwrap_or(Value::Null), + "requires_confirmation": requires_confirmation, + "explicit_user_action": true, + "website_required": false, + "single_active_process_boundary": true, + }) +} + +pub(crate) fn process_cancel_request_summary( + response: &Value, + requires_confirmation: bool, +) -> Value { + if response.get("type").and_then(Value::as_str) != Some("process_cancellation_requested") { + let message = response_error_message(response, "coordinator rejected process cancel"); + return json!({ + "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), + "operation": "cancel_virtual_process", + "accepted": false, + "requires_confirmation": requires_confirmation, + "explicit_user_action": true, + "whole_process_cancel_available": true, + "error": message, + "machine_error": cli_error_summary(&message), + }); + } + let cancelled_tasks = response + .get("cancelled_tasks") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + json!({ + "status": "process_cancellation_requested", + "operation": "cancel_virtual_process", + "accepted": true, + "process": response.get("process").cloned().unwrap_or(Value::Null), + "cancelled_task_count": cancelled_tasks, + "cancelled_tasks": response.get("cancelled_tasks").cloned().unwrap_or_else(|| json!([])), + "affected_nodes": response.get("affected_nodes").cloned().unwrap_or_else(|| json!([])), + "requires_confirmation": requires_confirmation, + "explicit_user_action": true, + "website_required": false, + "whole_process_cancel_available": true, + "node_must_poll_task_control": true, + "new_task_launches_blocked": true, + "surviving_state_visibility": "task and artifact state remains visible after terminal task events are reported", + }) +} + +pub(crate) fn task_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value { + if response.get("type").and_then(Value::as_str) != Some("task_restart") { + let message = response_error_message(response, "coordinator rejected task restart"); + return json!({ + "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), + "operation": "restart_selected_task", + "accepted": false, + "requires_confirmation": requires_confirmation, + "explicit_user_action": true, + "clean_boundary_required": true, + "error": message, + "machine_error": cli_error_summary(&message), + }); + } + json!({ + "status": "task_restart", + "operation": "restart_selected_task", + "accepted": response.get("accepted").cloned().unwrap_or(json!(false)), + "process": response.get("process").cloned().unwrap_or(Value::Null), + "task": response.get("task").cloned().unwrap_or(Value::Null), + "requires_confirmation": requires_confirmation, + "explicit_user_action": true, + "clean_boundary_required": true, + "clean_boundary_available": response + .get("clean_boundary_available") + .cloned() + .unwrap_or(json!(false)), + "active_task": response + .get("active_task") + .cloned() + .unwrap_or(json!(false)), + "completed_event_observed": response + .get("completed_event_observed") + .cloned() + .unwrap_or(json!(false)), + "requires_whole_process_restart": response + .get("requires_whole_process_restart") + .cloned() + .unwrap_or(json!(true)), + "message": response.get("message").cloned().unwrap_or(Value::Null), + "audit_event": response.get("audit_event").cloned().unwrap_or(Value::Null), + "charged_debug_read_bytes": response + .get("charged_debug_read_bytes") + .cloned() + .unwrap_or_else(|| json!(0)), + "used_debug_read_bytes": response + .get("used_debug_read_bytes") + .cloned() + .unwrap_or_else(|| json!(0)), + "debug_reads_quota_limited": true, + "website_required": false, + }) +} + +pub(crate) fn artifact_download_session_summary(response: &Value) -> Value { + if response.get("type").and_then(Value::as_str) != Some("artifact_download_link") { + let message = response_error_message(response, "coordinator rejected artifact download"); + return json!({ + "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), + "link_issued": false, + "explicit_user_action_required": true, + "error": message.clone(), + "machine_error": cli_error_summary_with_default(&message, "connectivity"), + }); + } + + let link = response.get("link").unwrap_or(&Value::Null); + json!({ + "status": "download_link_issued", + "link_issued": true, + "explicit_user_action_required": true, + "coordinator_preflight": "completed_before_link_issued", + "tenant": link.get("tenant").cloned().unwrap_or(Value::Null), + "project": link.get("project").cloned().unwrap_or(Value::Null), + "process": link.get("process").cloned().unwrap_or(Value::Null), + "artifact": link.get("artifact").cloned().unwrap_or(Value::Null), + "actor": link.get("actor").cloned().unwrap_or(Value::Null), + "source": link.get("source").cloned().unwrap_or(Value::Null), + "url_path": link.get("url_path").cloned().unwrap_or(Value::Null), + "expires_at_epoch_seconds": link.get("expires_at_epoch_seconds").cloned().unwrap_or(Value::Null), + "max_bytes": link.get("max_bytes").cloned().unwrap_or(Value::Null), + "token_material_returned": false, + "scoped_token_digest_present": link.get("scoped_token_digest").is_some(), + "policy_context_digest_present": link.get("policy_context_digest").is_some(), + "authorization_required": true, + "short_lived": link.get("expires_at_epoch_seconds").is_some(), + "guessable_public_url": false, + "cross_tenant_usable": false, + "unauthorized_project_usable": false, + "default_durable_store_assumed": false, + }) +} + +pub(crate) fn artifact_download_grant_disclosures(response: &Value) -> Value { + if response.get("type").and_then(Value::as_str) != Some("artifact_download_link") { + return json!([]); + } + + let link = response.get("link").unwrap_or(&Value::Null); + json!([{ + "grant": "artifact_download", + "description": "download scoped artifact bytes to the requesting machine", + "risk": "authorized artifact bytes leave the retaining node or explicit storage through an explicit download/export operation", + "coordinator_policy_limited": true, + "authorization_required": true, + "explicit_user_action_required": true, + "tenant": link.get("tenant").cloned().unwrap_or(Value::Null), + "project": link.get("project").cloned().unwrap_or(Value::Null), + "process": link.get("process").cloned().unwrap_or(Value::Null), + "artifact": link.get("artifact").cloned().unwrap_or(Value::Null), + "actor": link.get("actor").cloned().unwrap_or(Value::Null), + "source": link.get("source").cloned().unwrap_or(Value::Null), + "max_bytes": link.get("max_bytes").cloned().unwrap_or(Value::Null), + "expires_at_epoch_seconds": link.get("expires_at_epoch_seconds").cloned().unwrap_or(Value::Null), + "short_lived": link.get("expires_at_epoch_seconds").is_some(), + "scoped_token_digest_present": link.get("scoped_token_digest").is_some(), + "token_material_returned": false, + "guessable_public_url": false, + "cross_tenant_reuse_allowed": false, + "unauthorized_project_reuse_allowed": false, + "default_durable_store_assumed": false, + "private_website_required": false, + }]) +} + +pub(crate) fn artifact_export_plan_summary(response: &Value, to: &Path) -> Value { + if response.get("type").and_then(Value::as_str) != Some("artifact_export_plan") { + let message = response_error_message(response, "coordinator rejected artifact export"); + return json!({ + "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), + "explicit_user_action": true, + "local_path": to, + "local_bytes_written_by_cli": false, + "default_durable_store_assumed": false, + "error": message.clone(), + "machine_error": cli_error_summary_with_default(&message, "connectivity"), + }); + } + + let plan = response.get("plan").unwrap_or(&Value::Null); + json!({ + "status": "transfer_plan_created", + "explicit_user_action": true, + "local_path": to, + "local_bytes_written_by_cli": false, + "writes_require_data_plane_followup": true, + "default_durable_store_assumed": false, + "artifact_size_bytes": response.get("artifact_size_bytes").cloned().unwrap_or(Value::Null), + "source_node": response.get("source_node").cloned().unwrap_or(Value::Null), + "receiver_node": response.get("receiver_node").cloned().unwrap_or(Value::Null), + "transport": plan.get("transport").cloned().unwrap_or(Value::Null), + "artifact": plan.pointer("/scope/object/Artifact").cloned().unwrap_or(Value::Null), + "tenant": plan.pointer("/scope/tenant").cloned().unwrap_or(Value::Null), + "project": plan.pointer("/scope/project").cloned().unwrap_or(Value::Null), + "process": plan.pointer("/scope/process").cloned().unwrap_or(Value::Null), + "coordinator_assisted_rendezvous": plan.get("coordinator_assisted_rendezvous").cloned().unwrap_or(Value::Null), + "coordinator_bulk_relay_allowed": plan.get("coordinator_bulk_relay_allowed").cloned().unwrap_or(Value::Null), + "authorization_digest_present": plan.get("authorization_digest").is_some(), + }) +} + +pub(crate) fn task_event_count(task_events: Option<&Value>) -> usize { + task_events + .and_then(|task_events| task_events.pointer("/response/events")) + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0) +} + +pub(crate) fn project_quota_posture(attached_nodes: &Value, task_events: Option<&Value>) -> Value { + let current_usage = quota_current_usage(attached_nodes, task_events); + let next_blocked_action = quota_next_blocked_action(¤t_usage); + json!({ + "source": "cli_project_status_summary", + "current_usage": current_usage, + "limits": quota_limits_value(), + "next_blocked_action": next_blocked_action, + "private_abuse_heuristics_exposed": false, + }) +} + +pub(crate) fn quota_current_usage(attached_nodes: &Value, task_events: Option<&Value>) -> Value { + let attached_node_count = attached_nodes + .get("count") + .and_then(Value::as_u64) + .unwrap_or(0); + let online_node_count = attached_nodes + .get("online") + .and_then(Value::as_u64) + .unwrap_or(0); + json!({ + "attached_nodes": attached_node_count, + "online_nodes": online_node_count, + "observed_task_events": task_event_count(task_events), + "artifact_download_bytes": 0, + "rendezvous_attempts": 0, + "hosted_wasm_processes": 0, + }) +} + +pub(crate) fn quota_limits_value() -> Value { + json!({ + "source": "coordinator", + "configured": false, + "message": "connect to the selected coordinator to read its scoped quota configuration", + }) +} + +pub(crate) fn quota_next_blocked_action(current_usage: &Value) -> Value { + if current_usage + .get("online_nodes") + .and_then(Value::as_u64) + .unwrap_or(0) + == 0 + { + return json!({ + "action": "node_work_requires_online_attached_node", + "category": "capability", + "quota_related": false, + "message": "no online attached node is visible for work that requires a user node", + "machine_error": cli_error_summary_for_category( + "capability", + "no online attached node is visible for work that requires a user node" + ) + }); + } + Value::Null +} diff --git a/crates/disasmer-cli/src/project.rs b/crates/disasmer-cli/src/project.rs new file mode 100644 index 0000000..551077b --- /dev/null +++ b/crates/disasmer-cli/src/project.rs @@ -0,0 +1,381 @@ +use std::path::PathBuf; + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::client::{ + authenticated_or_local_trusted_request, list_attached_nodes_if_available_with_session, + list_task_events_if_available_with_session, stored_session_for_coordinator, JsonLineSession, +}; +use crate::config::{ + effective_project_scope, effective_scope_value, project_config_file, read_cli_session, + read_project_config, write_project_config, ProjectConfig, StoredCliSession, +}; +use crate::process::process_list_report_with_session; +use crate::process_events::project_quota_posture; +use crate::{ + bundle_inspection, discovered_environment_names, BundleInspectArgs, ProcessListArgs, + ProjectInitArgs, ProjectListArgs, ProjectSelectArgs, ProjectStatusArgs, +}; + +pub(crate) fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result { + let stored_session = read_cli_session(&cwd)?; + let tenant = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.tenant, + |session| session.tenant.as_str(), + "tenant", + ); + let project = args.new_project.clone(); + let user = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.user, + |session| session.user.as_str(), + "user", + ); + let coordinator = args.scope.coordinator.clone().or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }); + let name = args.name.clone(); + let config = ProjectConfig { + tenant: tenant.clone(), + project: project.clone(), + user: user.clone(), + coordinator: coordinator.clone(), + }; + let config_file = project_config_file(&cwd); + if config_file.exists() && !args.yes { + anyhow::bail!( + "{} already exists; rerun with --yes to update the project link", + config_file.display() + ); + } + let mut coordinator_session_requests = 0; + let coordinator_response = if let Some(coordinator) = &coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let request = authenticated_or_local_trusted_request( + coordinator, + stored_session.as_ref(), + json!({ + "type": "create_project", + "project": project, + "name": name, + }), + json!({ + "type": "create_project", + "tenant": tenant, + "actor_user": user, + "project": project, + "name": name, + }), + )?; + let response = session.request(request)?; + coordinator_session_requests = session.requests(); + Some(response) + } else { + None + }; + write_project_config(&cwd, &config)?; + let created_or_linked_project = coordinator_response + .as_ref() + .and_then(|response| response.get("project")) + .cloned() + .unwrap_or_else(|| { + json!({ + "id": config.project.clone(), + "tenant": config.tenant.clone(), + "name": args.name.clone(), + }) + }); + Ok(json!({ + "command": "project init", + "source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" }, + "private_website_required": false, + "project_config_written": true, + "project_config_write_after_coordinator_acceptance": coordinator.is_some(), + "coordinator_create_before_local_write": coordinator.is_some(), + "coordinator_session_requests": coordinator_session_requests, + "created_or_linked_project": created_or_linked_project, + "current_directory_link": { + "cwd": cwd, + "config_file": config_file, + "config_format": "disasmer_project_config_v1", + "links_current_directory": true, + "writes_current_directory_only": true, + "private_website_required": false, + }, + "safe_defaults": { + "tenant": config.tenant.clone(), + "project": config.project.clone(), + "user": config.user.clone(), + "coordinator": config.coordinator.clone(), + "project_name": args.name.clone(), + "default_project_id_used": args.new_project == "project", + "default_project_name_used": args.name == "Disasmer Project", + "browser_interaction_required": false, + "private_website_required": false, + }, + "project_config": config, + "config_file": project_config_file(&cwd), + "coordinator_response": coordinator_response, + })) +} + +pub(crate) fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Result { + let config = read_project_config(&cwd)?; + let stored_session = read_cli_session(&cwd)?; + let mut effective_scope = effective_project_scope(&args.scope, config.as_ref()); + if effective_scope.coordinator.is_none() { + effective_scope.coordinator = stored_session + .as_ref() + .filter(|session| session.session_secret.is_some()) + .map(|session| session.coordinator.clone()); + } + if let (Some(config), Some(session)) = (config.as_ref(), stored_session.as_ref()) { + let same_coordinator = effective_scope + .coordinator + .as_deref() + .is_some_and(|coordinator| { + crate::client::control_endpoint_identity(coordinator).ok() + == crate::client::control_endpoint_identity(&session.coordinator).ok() + }); + if same_coordinator + && (session.tenant != config.tenant + || session.project != config.project + || session.user != config.user) + { + anyhow::bail!( + "stored CLI session is for {}/{}/{} but this workspace is configured for {}/{}/{}; run `disasmer login --browser` from this workspace", + session.tenant, + session.project, + session.user, + config.tenant, + config.project, + config.user, + ); + } + } + if let Some(bound_session) = effective_scope + .coordinator + .as_deref() + .and_then(|coordinator| { + stored_session_for_coordinator(coordinator, stored_session.as_ref()) + }) + { + effective_scope.tenant = bound_session.tenant.clone(); + effective_scope.project = bound_session.project.clone(); + effective_scope.user = bound_session.user.clone(); + } + let inspection = bundle_inspection( + BundleInspectArgs { + project: Some(cwd.clone()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: true, + }, + cwd.clone(), + ) + .ok(); + let coordinator = effective_scope.coordinator.clone(); + let attached_nodes = list_attached_nodes_if_available_with_session( + coordinator.as_deref(), + &effective_scope, + stored_session.as_ref(), + )?; + let coordinator_response = list_task_events_if_available_with_session( + coordinator.as_deref(), + &effective_scope, + None, + stored_session.as_ref(), + )?; + let process_report = process_list_report_with_session( + ProcessListArgs { + scope: effective_scope.clone(), + }, + stored_session.as_ref(), + )?; + let discovered_environments = discovered_environment_names(inspection.as_ref()); + let active_process = process_report + .get("processes") + .and_then(Value::as_array) + .and_then(|processes| processes.first()) + .and_then(|process| process.get("process")) + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + if process_report.get("status").and_then(Value::as_str) == Some("ok") { + "none".to_owned() + } else { + "unknown_without_coordinator".to_owned() + } + }); + let quota_posture = project_quota_posture(&attached_nodes, coordinator_response.as_ref()); + Ok(json!({ + "command": "project status", + "cwd": cwd, + "tenant": effective_scope.tenant, + "project": effective_scope.project, + "user": effective_scope.user, + "coordinator": coordinator, + "project_identity": { + "tenant": effective_scope.tenant, + "project": effective_scope.project, + "user": effective_scope.user, + "source": if config.is_some() { "project_config_with_cli_overrides" } else { "cli_scope" } + }, + "project_config": config, + "bundle": inspection, + "discovered_environments": discovered_environments, + "active_process": active_process, + "processes": process_report.get("processes").cloned().unwrap_or_else(|| json!([])), + "attached_nodes": attached_nodes, + "quota_posture": quota_posture, + "coordinator_response": coordinator_response, + })) +} + +pub(crate) fn project_list_report(args: ProjectListArgs, cwd: PathBuf) -> Result { + let stored_session = read_cli_session(&cwd)?; + let coordinator = args.scope.coordinator.clone().or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }); + let tenant = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.tenant, + |session| session.tenant.as_str(), + "tenant", + ); + let user = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.user, + |session| session.user.as_str(), + "user", + ); + if let Some(coordinator) = &coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let request = authenticated_or_local_trusted_request( + coordinator, + stored_session.as_ref(), + json!({ + "type": "list_projects", + }), + json!({ + "type": "list_projects", + "tenant": tenant, + "actor_user": user, + }), + )?; + let response = session.request(request)?; + let projects = response + .get("projects") + .cloned() + .unwrap_or_else(|| json!([])); + let project_count = projects.as_array().map(Vec::len).unwrap_or(0); + return Ok(json!({ + "command": "project list", + "source": "public_coordinator_api", + "coordinator": coordinator, + "tenant": tenant, + "user": user, + "projects": projects, + "project_count": project_count, + "private_website_required": false, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + let projects = read_project_config(&cwd)?.into_iter().collect::>(); + let project_count = projects.len(); + Ok(json!({ + "command": "project list", + "source": "local_project_config", + "projects": projects, + "project_count": project_count, + "private_website_required": false, + })) +} + +pub(crate) fn project_select_report(args: ProjectSelectArgs, cwd: PathBuf) -> Result { + let stored_session = read_cli_session(&cwd)?; + let tenant = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.tenant, + |session| session.tenant.as_str(), + "tenant", + ); + let user = session_or_effective_scope_value( + stored_session.as_ref(), + &args.scope.user, + |session| session.user.as_str(), + "user", + ); + let coordinator = args.scope.coordinator.clone().or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }); + let config = ProjectConfig { + tenant: tenant.clone(), + project: args.selected_project.clone(), + user: user.clone(), + coordinator: coordinator.clone(), + }; + let coordinator_response = if let Some(coordinator) = &coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let request = authenticated_or_local_trusted_request( + coordinator, + stored_session.as_ref(), + json!({ + "type": "select_project", + "project": args.selected_project, + }), + json!({ + "type": "select_project", + "tenant": tenant, + "actor_user": user, + "project": args.selected_project, + }), + )?; + Some(session.request(request)?) + } else { + None + }; + write_project_config(&cwd, &config)?; + let selected_project = coordinator_response + .as_ref() + .and_then(|response| response.get("project")) + .cloned() + .unwrap_or_else(|| { + json!({ + "id": config.project.clone(), + "tenant": config.tenant.clone(), + "name": config.project.clone(), + }) + }); + Ok(json!({ + "command": "project select", + "source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" }, + "selected_project": selected_project, + "project_config_written": true, + "private_website_required": false, + "project_config": config, + "coordinator_response": coordinator_response, + })) +} + +fn session_or_effective_scope_value( + stored_session: Option<&StoredCliSession>, + cli_value: &str, + session_value: impl FnOnce(&StoredCliSession) -> &str, + default_value: &str, +) -> String { + if let Some(session) = stored_session.filter(|session| session.session_secret.is_some()) { + session_value(session).to_owned() + } else { + effective_scope_value(cli_value, stored_session.map(session_value), default_value) + } +} diff --git a/crates/disasmer-cli/src/quota.rs b/crates/disasmer-cli/src/quota.rs new file mode 100644 index 0000000..a37977f --- /dev/null +++ b/crates/disasmer-cli/src/quota.rs @@ -0,0 +1,111 @@ +use std::path::PathBuf; + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::client::{ + authenticated_or_local_trusted_request, list_attached_nodes_if_available_with_session, + list_task_events_if_available_with_session, stored_session_for_coordinator, JsonLineSession, +}; +use crate::config::{effective_project_scope, read_cli_session, read_project_config}; +use crate::process_events::{quota_current_usage, quota_limits_value, quota_next_blocked_action}; +use crate::QuotaStatusArgs; + +pub(crate) fn quota_status_report(args: QuotaStatusArgs, cwd: PathBuf) -> Result { + let config = read_project_config(&cwd)?; + let stored_session = read_cli_session(&cwd)?; + let mut effective_scope = effective_project_scope(&args.scope, config.as_ref()); + if effective_scope.coordinator.is_none() { + effective_scope.coordinator = stored_session + .as_ref() + .filter(|session| session.session_secret.is_some()) + .map(|session| session.coordinator.clone()); + } + if let Some(bound_session) = effective_scope + .coordinator + .as_deref() + .and_then(|coordinator| { + stored_session_for_coordinator(coordinator, stored_session.as_ref()) + }) + { + effective_scope.tenant = bound_session.tenant.clone(); + effective_scope.project = bound_session.project.clone(); + effective_scope.user = bound_session.user.clone(); + } + let coordinator = effective_scope.coordinator.clone(); + let attached_nodes = list_attached_nodes_if_available_with_session( + coordinator.as_deref(), + &effective_scope, + stored_session.as_ref(), + )?; + let task_events = list_task_events_if_available_with_session( + coordinator.as_deref(), + &effective_scope, + None, + stored_session.as_ref(), + )?; + let quota_status = if let Some(coordinator) = coordinator.as_deref() { + let mut session = JsonLineSession::connect(coordinator)?; + Some(session.request(authenticated_or_local_trusted_request( + coordinator, + stored_session.as_ref(), + json!({ "type": "quota_status" }), + json!({ + "type": "quota_status", + "tenant": effective_scope.tenant, + "project": effective_scope.project, + "actor_user": effective_scope.user, + }), + )?)?) + } else { + None + }; + let mut current_usage = quota_current_usage(&attached_nodes, task_events.as_ref()); + if let (Some(object), Some(status)) = (current_usage.as_object_mut(), quota_status.as_ref()) { + object.insert( + "scoped_resource_usage".to_owned(), + status.get("usage").cloned().unwrap_or(Value::Null), + ); + object.insert( + "window_started_epoch_seconds".to_owned(), + status + .get("window_started_epoch_seconds") + .cloned() + .unwrap_or(Value::Null), + ); + } + let limits = quota_status + .as_ref() + .and_then(|status| status.pointer("/limits/limits")) + .cloned() + .unwrap_or_else(quota_limits_value); + let window_seconds = quota_status + .as_ref() + .and_then(|status| status.get("window_seconds")) + .cloned() + .unwrap_or(Value::Null); + let quota_tier = quota_status + .as_ref() + .and_then(|status| status.get("policy_label")) + .cloned() + .unwrap_or(Value::Null); + Ok(json!({ + "command": "quota status", + "tenant": effective_scope.tenant, + "project": effective_scope.project, + "user": effective_scope.user, + "coordinator": coordinator, + "project_config": config, + "policy_surface": "generic public quota categories; hosted tuning remains private policy", + "limits": limits, + "window_seconds": window_seconds, + "current_usage": current_usage, + "attached_nodes": attached_nodes, + "task_events": task_events, + "next_blocked_action": quota_next_blocked_action(¤t_usage), + "quota_configuration_source": if quota_status.is_some() { "coordinator" } else { "unavailable_offline" }, + "quota_tier": quota_tier, + "private_abuse_heuristics_exposed": false, + "quota_response": quota_status, + })) +} diff --git a/crates/disasmer-cli/src/run.rs b/crates/disasmer-cli/src/run.rs new file mode 100644 index 0000000..048b01f --- /dev/null +++ b/crates/disasmer-cli/src/run.rs @@ -0,0 +1,943 @@ +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_control::MAX_CONTROL_FRAME_BYTES; +use disasmer_core::{ + agent_ed25519_public_key_from_private_key, sign_agent_workflow_request, + signed_request_payload_digest, AgentWorkflowScope, Digest, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::client::{stored_session_for_coordinator, JsonLineSession}; +use crate::config::{ + default_hosted_coordinator_endpoint, read_cli_session, read_project_config, StoredCliSession, +}; +use crate::errors::{cli_error_summary, cli_error_summary_for_category}; +use crate::{BuildArgs, RunArgs}; + +mod local_services; +use local_services::{LocalCoordinator, LocalNodeWorker}; + +struct RunBundle { + build_report: Value, + digest: Digest, + module_base64: String, + module_size_bytes: usize, + entry_export: String, + entry_stable_id: String, +} + +// The control request contains base64 (4/3 expansion), the authenticated +// envelope, TaskSpec metadata, and user/project identifiers. Reserve a fixed +// worst-case metadata budget so every module at or below this limit fits the +// existing 1 MiB control frame without creating a process first. +const INLINE_BUNDLE_REQUEST_OVERHEAD_BYTES: usize = 96 * 1024; +pub(crate) const MAX_INLINE_WASM_MODULE_BYTES: usize = + ((MAX_CONTROL_FRAME_BYTES - INLINE_BUNDLE_REQUEST_OVERHEAD_BYTES) / 4) * 3; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct RunPlan { + pub(crate) project: PathBuf, + pub(crate) entry: String, + pub(crate) coordinator: CoordinatorSelection, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) hosted_coordinator_endpoint: Option, + pub(crate) session: CliSession, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct RunExecutionReport { + pub(crate) plan: RunPlan, + pub(crate) boundary: RunBoundaryEvidence, + pub(crate) node_report: serde_json::Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct RunBoundaryEvidence { + pub(crate) cli_process_started_node_process: bool, + pub(crate) cli_process_started_coordinator_process: bool, + pub(crate) coordinator_address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) coordinator_process_id: Option, + pub(crate) spawned_node_process_id: u32, + pub(crate) node_session_requests: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) enum CoordinatorSelection { + Hosted, + LocalOverride(String), + LocalOnly, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) enum CliSession { + Anonymous, + HumanSession, + AgentPublicKey { + agent: String, + public_key: String, + public_key_fingerprint: Digest, + #[serde(skip)] + private_key: Option, + browser_interaction_required: bool, + }, +} + +impl CliSession { + pub(crate) fn is_authenticated(&self) -> bool { + !matches!(self, Self::Anonymous) + } +} + +pub(crate) fn run_plan(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result { + let (coordinator, hosted_coordinator_endpoint) = if let Some(url) = args.coordinator { + (CoordinatorSelection::LocalOverride(url), None) + } else if args.local { + (CoordinatorSelection::LocalOnly, None) + } else if session.is_authenticated() { + ( + CoordinatorSelection::Hosted, + Some(default_hosted_coordinator_endpoint()), + ) + } else { + (CoordinatorSelection::LocalOnly, None) + }; + + Ok(RunPlan { + project: args.project.unwrap_or(cwd), + entry: args.entry.unwrap_or_else(|| "build".to_owned()), + coordinator, + hosted_coordinator_endpoint, + session, + }) +} + +fn non_interactive_run_requires_auth_report(args: RunArgs, cwd: PathBuf) -> Value { + let project = args.project.unwrap_or(cwd); + let entry = args.entry.unwrap_or_else(|| "build".to_owned()); + let message = "non-interactive run requires an authenticated human or agent session unless --local or --coordinator is explicit"; + let next_actions = vec![ + "disasmer login --browser", + "set DISASMER_AGENT_PRIVATE_KEY for automation", + "pass --local to run against local services", + "pass --coordinator for an explicit self-hosted coordinator", + ]; + let mut machine_error = cli_error_summary_for_category("authentication", message); + if let Some(object) = machine_error.as_object_mut() { + object.insert("next_actions".to_owned(), json!(next_actions.clone())); + object.insert("browser_opened".to_owned(), json!(false)); + } + json!({ + "command": "run", + "status": "authentication_required", + "project_root": project, + "entry": entry, + "non_interactive": true, + "browser_opened": false, + "safe_failure": true, + "message": message, + "next_actions": next_actions, + "private_website_required": false, + "machine_error": machine_error, + }) +} + +pub(crate) fn run_report(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result { + if args.non_interactive + && !args.local + && args.coordinator.is_none() + && !session.is_authenticated() + { + return Ok(non_interactive_run_requires_auth_report(args, cwd)); + } + let plan = run_plan(args, cwd, session)?; + if should_execute_local_node(&plan) { + return Ok(serde_json::to_value(execute_local_node_run(plan)?)?); + } + coordinator_run_report(plan) +} + +fn coordinator_run_report(plan: RunPlan) -> Result { + // Build, resolve the requested entrypoint, verify the module digest, and + // enforce the accepted inline transport boundary before mutating the + // coordinator. A failure here therefore cannot leave an active process. + let bundle = build_bundle_for_run(&plan.project, &plan.entry)?; + validate_inline_bundle_size(bundle.module_size_bytes)?; + let config = read_project_config(&plan.project)?; + let stored_session = read_cli_session(&plan.project)?; + let coordinator = run_coordinator_endpoint(&plan, stored_session.as_ref())?; + let bound_session = stored_session_for_coordinator(&coordinator, stored_session.as_ref()); + let tenant = bound_session + .map(|session| session.tenant.clone()) + .or_else(|| config.as_ref().map(|config| config.tenant.clone())) + .unwrap_or_else(|| "tenant".to_owned()); + let project = bound_session + .map(|session| session.project.clone()) + .or_else(|| config.as_ref().map(|config| config.project.clone())) + .unwrap_or_else(|| "project".to_owned()); + let user = bound_session + .map(|session| session.user.clone()) + .or_else(|| config.as_ref().map(|config| config.user.clone())) + .unwrap_or_else(|| "user".to_owned()); + let human_session_secret = + human_run_session_secret(&plan, &coordinator, stored_session.as_ref())?; + if matches!(plan.session, CliSession::HumanSession) + && human_session_secret.is_none() + && !crate::client::is_loopback_coordinator(&coordinator) + { + anyhow::bail!( + "no authenticated CLI session matches coordinator {coordinator}; run `disasmer login --browser` from the current project" + ); + } + let process = "vp-current".to_owned(); + let mut session = JsonLineSession::connect(&coordinator)?; + let mut request = json!({ + "type": "start_process", + "tenant": tenant.clone(), + "project": project.clone(), + "process": process.clone(), + "restart": false, + }); + request = authenticated_human_or_local_trusted_workflow( + request, + &plan.session, + &user, + human_session_secret.as_deref(), + ); + let response = session.request_allow_error(request)?; + let run_start = run_start_summary(&response); + let status = run_start + .get("status") + .and_then(Value::as_str) + .unwrap_or("coordinator_response"); + let task_definition = bundle.entry_stable_id.clone(); + let task_instance = format!("ti:{process}:main"); + let artifact_path = format!("/vfs/artifacts/{task_instance}-output.txt"); + let launch_task_response = if status == "started" { + let task_spec = json!({ + "tenant": tenant, + "project": project, + "process": process, + "task_definition": task_definition, + "task_instance": task_instance, + "dispatch": { + "kind": "coordinator_node_wasm", + "export": bundle.entry_export, + "abi": "entrypoint_v1" + }, + "environment_id": null, + "environment": null, + "environment_digest": null, + "required_capabilities": [], + "dependency_cache": null, + "source_snapshot": null, + "required_artifacts": [], + "args": [], + "vfs_epoch": response.get("epoch").and_then(Value::as_u64).unwrap_or_default(), + "bundle_digest": bundle.digest, + }); + let mut launch_task_request = json!({ + "type": "launch_task", + "tenant": tenant.clone(), + "project": project.clone(), + "task_spec": task_spec, + "wait_for_node": true, + "artifact_path": artifact_path.clone(), + "wasm_module_base64": bundle.module_base64, + }); + launch_task_request = authenticated_human_or_local_trusted_workflow( + launch_task_request, + &plan.session, + &user, + human_session_secret.as_deref(), + ); + let launch = session.request_allow_error(launch_task_request)?; + if !matches!( + launch.get("type").and_then(Value::as_str), + Some("main_launched") + ) { + rollback_failed_process_launch( + &mut session, + &plan.session, + &user, + human_session_secret.as_deref(), + &tenant, + &project, + &process, + &launch, + )?; + } + launch + } else { + Value::Null + }; + Ok(json!({ + "command": "run", + "status": if launch_task_response.get("type").and_then(Value::as_str) == Some("main_launched") { "main_launched" } else { status }, + "project_root": plan.project, + "entry": plan.entry, + "tenant": tenant, + "project": project, + "user": user, + "workflow_actor": response.get("actor").cloned().unwrap_or(Value::Null), + "coordinator": coordinator, + "process": process, + "run_start": run_start, + "task_definition": task_definition, + "task_instance": task_instance, + "bundle_build": bundle.build_report, + "bundle_digest": bundle.digest, + "bundle_module_size_bytes": bundle.module_size_bytes, + "entry_export": bundle.entry_export, + "entry_stable_id": bundle.entry_stable_id, + "entry_abi_version": 1, + "worker_placement_requested": launch_task_response.is_object(), + "task_launch": launch_task_response, + "coordinator_response": response, + "coordinator_session_requests": session.requests(), + "private_website_required": false, + })) +} + +fn validate_inline_bundle_size(module_size_bytes: usize) -> Result<()> { + if module_size_bytes <= MAX_INLINE_WASM_MODULE_BYTES { + return Ok(()); + } + anyhow::bail!( + "built Wasm module is {module_size_bytes} bytes, but the current {}-byte inline control frame supports at most about {} KiB ({} raw bytes); reduce the MVP bundle's dependencies or release optimization footprint. Larger bundle transport is post-MVP. No virtual process was created", + MAX_CONTROL_FRAME_BYTES, + MAX_INLINE_WASM_MODULE_BYTES / 1024, + MAX_INLINE_WASM_MODULE_BYTES, + ) +} + +fn rollback_failed_process_launch( + session: &mut JsonLineSession, + cli_session: &CliSession, + fallback_user: &str, + human_session_secret: Option<&str>, + tenant: &str, + project: &str, + process: &str, + launch_response: &Value, +) -> Result<()> { + let rollback = authenticated_human_or_local_trusted_workflow( + json!({ + "type": "abort_process", + "tenant": tenant, + "project": project, + "process": process, + }), + cli_session, + fallback_user, + human_session_secret, + ); + let rollback_response = session.request_allow_error(rollback)?; + if rollback_response.get("type").and_then(Value::as_str) != Some("process_aborted") { + anyhow::bail!( + "coordinator main launch failed ({launch_response}) and rollback was not acknowledged ({rollback_response}); inspect virtual process {process} before retrying" + ); + } + Ok(()) +} + +fn build_bundle_for_run(project: &Path, entry: &str) -> Result { + let build_report = crate::build::build_report( + BuildArgs { + project: Some(project.to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + output: None, + json: true, + }, + project.to_path_buf(), + )?; + if build_report.get("status").and_then(Value::as_str) != Some("built") { + let diagnostics = build_report + .get("diagnostics") + .cloned() + .unwrap_or(Value::Null); + anyhow::bail!("Disasmer bundle build was blocked before run: {diagnostics}"); + } + let artifact = build_report + .get("bundle_artifact") + .context("bundle build response omitted bundle_artifact")?; + let module_path = artifact + .get("module") + .and_then(Value::as_str) + .context("bundle build response omitted module path")?; + let directory = artifact + .get("directory") + .and_then(Value::as_str) + .context("bundle build response omitted bundle directory")?; + let digest: Digest = serde_json::from_value( + artifact + .get("bundle_digest") + .cloned() + .context("bundle build response omitted bundle digest")?, + )?; + let module = std::fs::read(module_path) + .with_context(|| format!("failed to read built Wasm module {module_path}"))?; + let actual_digest = Digest::sha256(&module); + if actual_digest != digest { + anyhow::bail!( + "built Wasm module digest changed before run: expected {digest}, actual {actual_digest}" + ); + } + let descriptors_path = Path::new(directory).join("entrypoints.json"); + let descriptors: Vec = serde_json::from_slice( + &std::fs::read(&descriptors_path) + .with_context(|| format!("failed to read {}", descriptors_path.display()))?, + )?; + let descriptor = descriptors + .iter() + .find(|descriptor| descriptor.get("name").and_then(Value::as_str) == Some(entry)) + .with_context(|| { + let available = descriptors + .iter() + .filter_map(|descriptor| descriptor.get("name").and_then(Value::as_str)) + .collect::>(); + format!( + "bundle has no Disasmer entrypoint `{entry}`; available entrypoints: {available:?}" + ) + })?; + if descriptor.get("abi_version").and_then(Value::as_u64) != Some(1) { + anyhow::bail!("entrypoint `{entry}` does not use supported Disasmer ABI version 1"); + } + let entry_export = descriptor + .get("export") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .with_context(|| format!("entrypoint `{entry}` descriptor omitted its Wasm export"))? + .to_owned(); + let entry_stable_id = descriptor + .get("stable_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .with_context(|| format!("entrypoint `{entry}` descriptor omitted its stable id"))? + .to_owned(); + Ok(RunBundle { + build_report, + digest, + module_size_bytes: module.len(), + module_base64: BASE64_STANDARD.encode(module), + entry_export, + entry_stable_id, + }) +} + +fn human_run_session_secret( + plan: &RunPlan, + coordinator: &str, + stored_session: Option<&StoredCliSession>, +) -> Result> { + if !matches!(plan.session, CliSession::HumanSession) { + return Ok(None); + } + if let Ok(token) = std::env::var("DISASMER_TOKEN") { + if !token.trim().is_empty() { + return Ok(Some(token)); + } + } + Ok(stored_session_for_coordinator(coordinator, stored_session) + .and_then(|session| session.session_secret.clone())) +} + +fn authenticated_human_or_local_trusted_workflow( + mut request: Value, + session: &CliSession, + fallback_user: &str, + human_session_secret: Option<&str>, +) -> Value { + if matches!(session, CliSession::HumanSession) { + if let Some(session_secret) = human_session_secret { + if let Value::Object(request) = &mut request { + request.remove("tenant"); + request.remove("project"); + request.remove("actor_user"); + request.remove("actor_agent"); + request.remove("agent_public_key_fingerprint"); + request.remove("agent_signature"); + } + return json!({ + "type": "authenticated", + "session_secret": session_secret, + "request": request, + }); + } + } + add_workflow_actor_fields(&mut request, session, fallback_user); + request +} + +fn run_coordinator_endpoint( + plan: &RunPlan, + stored_session: Option<&StoredCliSession>, +) -> Result { + match &plan.coordinator { + CoordinatorSelection::Hosted => Ok(stored_session + .filter(|session| { + matches!(plan.session, CliSession::HumanSession) && session.session_secret.is_some() + }) + .map(|session| session.coordinator.clone()) + .or_else(|| plan.hosted_coordinator_endpoint.clone()) + .unwrap_or_else(default_hosted_coordinator_endpoint)), + CoordinatorSelection::LocalOverride(coordinator) => Ok(coordinator.clone()), + CoordinatorSelection::LocalOnly => { + anyhow::bail!("local-only run should execute through local services") + } + } +} + +pub(crate) fn run_start_summary(response: &Value) -> Value { + if response.get("type").and_then(Value::as_str) == Some("process_started") { + return json!({ + "status": "started", + "accepted": true, + "process": response.get("process").cloned().unwrap_or(Value::Null), + "coordinator_epoch": response.get("epoch").cloned().unwrap_or(Value::Null), + "actor": response.get("actor").cloned().unwrap_or(Value::Null), + "restart": false, + "single_active_process_boundary": true, + "next_actions": [ + "disasmer process status", + "disasmer logs", + "disasmer process cancel" + ], + }); + } + + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("coordinator rejected run"); + let active_conflict = message.contains("already has active virtual process"); + let machine_error = if active_conflict { + cli_error_summary_for_category("active_process", message) + } else { + cli_error_summary(message) + }; + let error_category = machine_error + .get("category") + .cloned() + .unwrap_or_else(|| json!("unknown")); + let stable_exit_code = machine_error + .get("stable_exit_code") + .cloned() + .unwrap_or_else(|| json!(1)); + json!({ + "status": if active_conflict { "blocked_active_process" } else { "coordinator_rejected" }, + "accepted": false, + "category": if active_conflict { "active_process_already_running" } else { "coordinator" }, + "error_category": error_category, + "stable_exit_code": stable_exit_code, + "machine_error": machine_error, + "message": message, + "restart": false, + "single_active_process_boundary": true, + "safe_failure": true, + "next_actions": if active_conflict { + json!([ + "disasmer process list", + "disasmer process status", + "disasmer debug attach", + "disasmer process restart --yes", + "disasmer process cancel --yes", + "disasmer process abort --yes", + "use another Coordinator Project" + ]) + } else { + json!(["disasmer doctor", "check coordinator status"]) + }, + }) +} + +pub(crate) fn should_execute_local_node(plan: &RunPlan) -> bool { + match &plan.coordinator { + CoordinatorSelection::LocalOnly => true, + CoordinatorSelection::LocalOverride(coordinator) => !coordinator.contains("://"), + CoordinatorSelection::Hosted => false, + } +} + +fn execute_local_node_run(plan: RunPlan) -> Result { + let environments = disasmer_core::discover_environments(&plan.project)?; + let detected = disasmer_core::NodeCapabilities::detect_current(); + if environments.iter().any(|environment| { + environment + .requirements + .capabilities + .contains(&disasmer_core::Capability::RootlessPodman) + }) && !detected + .capabilities + .contains(&disasmer_core::Capability::RootlessPodman) + { + anyhow::bail!( + "local project declares a Linux container environment, but rootless Podman is not available; configure rootless Podman or attach a capable user node" + ); + } + let local_coordinator = match &plan.coordinator { + CoordinatorSelection::LocalOverride(coordinator) => LocalCoordinator::external(coordinator), + CoordinatorSelection::LocalOnly => LocalCoordinator::start_ephemeral()?, + CoordinatorSelection::Hosted => anyhow::bail!("local node execution requires local mode"), + }; + let coordinator_address = local_coordinator.address.clone(); + let mut coordinator_plan = plan.clone(); + coordinator_plan.coordinator = + CoordinatorSelection::LocalOverride(format!("disasmer+tcp://{coordinator_address}")); + let run = coordinator_run_report(coordinator_plan)?; + let launch_type = run.pointer("/task_launch/type").and_then(Value::as_str); + if launch_type != Some("main_launched") { + anyhow::bail!("local coordinator refused the Wasm entrypoint launch: {run}"); + } + let process = run + .get("process") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("coordinator run report omitted virtual process id"))?; + let task = run + .pointer("/task_launch/task_instance") + .or_else(|| run.get("task_instance")) + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("coordinator run report omitted entrypoint task id"))?; + let pre_node_process_status = wait_for_local_main_placement(&coordinator_address, process)?; + let enrollment_grant = create_local_node_enrollment_grant(&coordinator_address)?; + let mut worker = + LocalNodeWorker::start(&coordinator_address, &plan.project, &enrollment_grant)?; + let spawned_node_process_id = worker.process_id; + let join = wait_for_local_main_completion(&coordinator_address, process, task)?; + worker.stop(); + let node_report = json!({ + "node_status": "completed", + "execution_substrate": "wasm", + "task_spawn_host_import": true, + "pre_node_process_status": pre_node_process_status, + "run": run, + "join": join, + }); + + Ok(RunExecutionReport { + plan, + boundary: RunBoundaryEvidence { + cli_process_started_node_process: true, + cli_process_started_coordinator_process: local_coordinator.process_id.is_some(), + coordinator_address, + coordinator_process_id: local_coordinator.process_id, + spawned_node_process_id, + node_session_requests: 0, + }, + node_report, + }) +} + +fn local_process_status(coordinator: &str) -> Result { + let mut session = JsonLineSession::connect(coordinator)?; + session.request_allow_error(json!({ + "type": "list_processes", + "tenant": "tenant", + "project": "project", + "actor_user": "user", + })) +} + +fn create_local_node_enrollment_grant(coordinator: &str) -> Result { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request_allow_error(json!({ + "type": "create_node_enrollment_grant", + "tenant": "tenant", + "project": "project", + "actor_user": "user", + "ttl_seconds": 60, + }))?; + if response.get("type").and_then(Value::as_str) != Some("node_enrollment_grant_created") { + anyhow::bail!("local coordinator refused node enrollment: {response}"); + } + response + .get("grant") + .and_then(Value::as_str) + .map(str::to_owned) + .context("local coordinator omitted the node enrollment grant") +} + +fn wait_for_local_main_placement(coordinator: &str, process: &str) -> Result { + let started = Instant::now(); + loop { + let status = local_process_status(coordinator)?; + let process_status = + status + .get("processes") + .and_then(Value::as_array) + .and_then(|processes| { + processes.iter().find(|candidate| { + candidate.get("process").and_then(Value::as_str) == Some(process) + }) + }); + match process_status.and_then(|status| status.get("main_wait_state")) { + Some(Value::String(wait_state)) if wait_state == "waiting_for_node" => { + return Ok(status); + } + _ if started.elapsed() > Duration::from_secs(30) => anyhow::bail!( + "local coordinator main `{process}` did not become observably parked on node placement before the local node launch: {status}" + ), + _ => thread::sleep(Duration::from_millis(10)), + } + } +} + +fn wait_for_local_main_completion(coordinator: &str, process: &str, task: &str) -> Result { + let started = Instant::now(); + loop { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request_allow_error(json!({ + "type": "join_task", + "tenant": "tenant", + "project": "project", + "actor_user": "user", + "process": process, + "task": task, + }))?; + match response.pointer("/join/state").and_then(Value::as_str) { + Some("Completed") | Some("completed") => return Ok(response), + Some("Failed") | Some("failed") | Some("Cancelled") | Some("cancelled") => { + let events = session.request_allow_error(json!({ + "type": "list_task_events", + "tenant": "tenant", + "project": "project", + "actor_user": "user", + "process": process, + }))?; + anyhow::bail!("local Wasm entrypoint failed: {response}; task events: {events}") + } + _ if started.elapsed() > Duration::from_secs(120) => { + anyhow::bail!("timed out waiting for local Wasm entrypoint completion") + } + _ => thread::sleep(Duration::from_millis(20)), + } + } +} + +pub(crate) fn session_from_env() -> Result { + if let Some(session) = agent_session_from_keys( + std::env::var("DISASMER_AGENT_ID").unwrap_or_else(|_| "agent".to_owned()), + std::env::var("DISASMER_AGENT_PUBLIC_KEY").ok(), + std::env::var("DISASMER_AGENT_PRIVATE_KEY").ok(), + )? { + return Ok(session); + } + if std::env::var_os("DISASMER_TOKEN").is_some() { + return Ok(CliSession::HumanSession); + } + Ok(CliSession::Anonymous) +} + +pub(crate) fn agent_session_from_keys( + agent: String, + configured_public_key: Option, + private_key: Option, +) -> Result> { + if let Some(private_key) = private_key { + let derived_public_key = agent_ed25519_public_key_from_private_key(&private_key) + .map_err(anyhow::Error::msg) + .context("DISASMER_AGENT_PRIVATE_KEY is not a valid Ed25519 private key")?; + if let Some(configured_public_key) = configured_public_key.as_deref() { + if configured_public_key != derived_public_key { + anyhow::bail!( + "DISASMER_AGENT_PUBLIC_KEY does not match DISASMER_AGENT_PRIVATE_KEY" + ); + } + } + return Ok(Some(CliSession::AgentPublicKey { + agent, + public_key_fingerprint: Digest::sha256(derived_public_key.as_bytes()), + public_key: derived_public_key, + private_key: Some(private_key), + browser_interaction_required: false, + })); + } + if configured_public_key.is_some() { + anyhow::bail!( + "DISASMER_AGENT_PUBLIC_KEY identifies a registered key but cannot authenticate; set DISASMER_AGENT_PRIVATE_KEY to prove key possession" + ); + } + Ok(None) +} + +pub(crate) fn session_from_sources(project: &Path) -> Result { + let session = session_from_env()?; + if session.is_authenticated() { + return Ok(session); + } + if read_cli_session(project)?.is_some() { + return Ok(CliSession::HumanSession); + } + Ok(CliSession::Anonymous) +} + +fn add_workflow_actor_fields(request: &mut Value, session: &CliSession, fallback_user: &str) { + let Value::Object(map) = request else { + return; + }; + match session { + CliSession::AgentPublicKey { + agent, + public_key: _, + public_key_fingerprint, + private_key, + .. + } => { + map.insert("actor_agent".to_owned(), json!(agent)); + map.insert( + "agent_public_key_fingerprint".to_owned(), + json!(public_key_fingerprint), + ); + if let Some(signature) = agent_signature_for_request(map, agent, private_key.as_deref()) + { + map.insert("agent_signature".to_owned(), json!(signature)); + } + } + CliSession::HumanSession | CliSession::Anonymous => { + map.insert("actor_user".to_owned(), json!(fallback_user)); + } + } +} + +fn agent_signature_for_request( + request: &serde_json::Map, + agent: &str, + private_key: Option<&str>, +) -> Option { + let private_key = private_key?; + let request_kind = request.get("type")?.as_str()?; + let tenant = request.get("tenant")?.as_str()?; + let project = request.get("project")?.as_str()?; + let task_spec = request.get("task_spec"); + let process = request + .get("process") + .or_else(|| task_spec.and_then(|spec| spec.get("process")))? + .as_str()?; + let task = request + .get("task") + .or_else(|| task_spec.and_then(|spec| spec.get("task"))) + .and_then(Value::as_str) + .map(disasmer_core::TaskInstanceId::from); + let payload_digest = signed_request_payload_digest(&Value::Object(request.clone())); + sign_agent_workflow_request( + private_key, + AgentWorkflowScope { + tenant: &disasmer_core::TenantId::from(tenant), + project: &disasmer_core::ProjectId::from(project), + agent: &disasmer_core::AgentId::from(agent), + request_kind, + process: &disasmer_core::ProcessId::from(process), + task: task.as_ref(), + }, + &payload_digest, + crate::tools::command_nonce("agent-signature"), + crate::tools::unix_timestamp_seconds(), + ) + .ok() +} + +#[cfg(test)] +mod transactional_launch_tests { + use std::io::{BufRead as _, ErrorKind, Write as _}; + use std::net::TcpListener; + + use super::*; + + #[test] + fn inline_module_limit_is_derived_from_the_control_frame() { + assert_eq!(MAX_CONTROL_FRAME_BYTES, 1024 * 1024); + assert_eq!(MAX_INLINE_WASM_MODULE_BYTES % 3, 0); + assert!(MAX_INLINE_WASM_MODULE_BYTES >= 690 * 1024); + assert!(MAX_INLINE_WASM_MODULE_BYTES <= 700 * 1024); + validate_inline_bundle_size(MAX_INLINE_WASM_MODULE_BYTES).unwrap(); + let error = validate_inline_bundle_size(MAX_INLINE_WASM_MODULE_BYTES + 1) + .unwrap_err() + .to_string(); + assert!(error.contains("No virtual process was created")); + assert!(error.contains("post-MVP")); + } + + #[test] + fn build_failure_occurs_before_any_coordinator_connection() { + let project = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let plan = RunPlan { + project: project.path().to_path_buf(), + entry: "build".to_owned(), + coordinator: CoordinatorSelection::LocalOverride( + listener.local_addr().unwrap().to_string(), + ), + hosted_coordinator_endpoint: None, + session: CliSession::Anonymous, + }; + + let error = coordinator_run_report(plan).unwrap_err().to_string(); + assert!( + error.contains("Cargo.toml") || error.contains("project"), + "unexpected build error: {error}" + ); + assert_eq!(listener.accept().unwrap_err().kind(), ErrorKind::WouldBlock); + } + + #[test] + fn failed_main_launch_uses_explicit_abort_rollback() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + std::io::BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + let wire: Value = serde_json::from_str(&line).unwrap(); + let payload = wire.get("payload").unwrap(); + assert_eq!( + payload.get("type").and_then(Value::as_str), + Some("abort_process") + ); + assert_eq!( + payload.get("tenant").and_then(Value::as_str), + Some("tenant-a") + ); + assert_eq!( + payload.get("project").and_then(Value::as_str), + Some("project-a") + ); + assert_eq!( + payload.get("process").and_then(Value::as_str), + Some("vp-current") + ); + writeln!( + stream, + "{}", + json!({ + "type": "process_aborted", + "process": "vp-current", + "aborted_tasks": [], + "affected_nodes": [] + }) + ) + .unwrap(); + }); + let mut session = JsonLineSession::connect(&address.to_string()).unwrap(); + rollback_failed_process_launch( + &mut session, + &CliSession::Anonymous, + "user-a", + None, + "tenant-a", + "project-a", + "vp-current", + &json!({"type": "error", "message": "main launch failed"}), + ) + .unwrap(); + server.join().unwrap(); + } +} diff --git a/crates/disasmer-cli/src/run/local_services.rs b/crates/disasmer-cli/src/run/local_services.rs new file mode 100644 index 0000000..d2ca337 --- /dev/null +++ b/crates/disasmer-cli/src/run/local_services.rs @@ -0,0 +1,177 @@ +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; + +use anyhow::{Context, Result}; +use serde_json::Value; + +pub(super) struct LocalNodeWorker { + pub(super) process_id: u32, + child: Option, +} + +impl LocalNodeWorker { + pub(super) fn start(coordinator: &str, project: &Path, enrollment_grant: &str) -> Result { + let mut command = node_command()?; + command.args([ + "--coordinator", + coordinator, + "--tenant", + "tenant", + "--project-id", + "project", + "--node", + "node-cli-local", + "--enrollment-grant", + enrollment_grant, + "--worker", + "--project-root", + ]); + command.arg(project); + command.args(["--assignment-poll-ms", "20"]); + command.stdout(Stdio::null()); + command.stderr(Stdio::inherit()); + let child = command.spawn().context("failed to spawn node process")?; + let process_id = child.id(); + Ok(Self { + process_id, + child: Some(child), + }) + } + + pub(super) fn stop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for LocalNodeWorker { + fn drop(&mut self) { + self.stop(); + } +} + +pub(super) struct LocalCoordinator { + pub(super) address: String, + pub(super) process_id: Option, + child: Option, +} + +impl LocalCoordinator { + pub(super) fn external(address: &str) -> Self { + Self { + address: address.to_owned(), + process_id: None, + child: None, + } + } + + pub(super) fn start_ephemeral() -> Result { + let mut command = coordinator_command()?; + command.args(["--listen", "127.0.0.1:0", "--allow-local-trusted-loopback"]); + command.stdout(Stdio::piped()); + command.stderr(Stdio::inherit()); + + let mut child = command + .spawn() + .context("failed to spawn local coordinator process")?; + let process_id = child.id(); + let address = match read_coordinator_ready_address(&mut child) { + Ok(address) => address, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + + Ok(Self { + address, + process_id: Some(process_id), + child: Some(child), + }) + } +} + +impl Drop for LocalCoordinator { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn read_coordinator_ready_address(child: &mut Child) -> Result { + let stdout = child + .stdout + .take() + .context("local coordinator stdout was not captured")?; + let mut ready_line = String::new(); + BufReader::new(stdout) + .read_line(&mut ready_line) + .context("failed to read local coordinator ready line")?; + if ready_line.trim().is_empty() { + anyhow::bail!("local coordinator exited before reporting its listen address"); + } + let ready: Value = + serde_json::from_str(&ready_line).context("local coordinator ready line was not JSON")?; + ready + .get("listen") + .and_then(Value::as_str) + .map(str::to_owned) + .context("local coordinator did not report a listen address") +} + +fn node_command() -> Result { + if let Some(path) = std::env::var_os("DISASMER_NODE_BIN") { + return Ok(Command::new(path)); + } + + let mut sibling = std::env::current_exe().context("cannot locate current executable")?; + sibling.set_file_name(format!("disasmer-node{}", std::env::consts::EXE_SUFFIX)); + if sibling.is_file() { + return Ok(Command::new(sibling)); + } + + let mut command = Command::new("cargo"); + command.args([ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-node", + "--", + ]); + Ok(command) +} + +fn coordinator_command() -> Result { + if let Some(path) = std::env::var_os("DISASMER_COORDINATOR_BIN") { + return Ok(Command::new(path)); + } + + let mut sibling = std::env::current_exe().context("cannot locate current executable")?; + sibling.set_file_name(format!( + "disasmer-coordinator{}", + std::env::consts::EXE_SUFFIX + )); + if sibling.is_file() { + return Ok(Command::new(sibling)); + } + + let mut command = Command::new("cargo"); + command.args([ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + ]); + Ok(command) +} diff --git a/crates/disasmer-cli/src/task.rs b/crates/disasmer-cli/src/task.rs new file mode 100644 index 0000000..63913f1 --- /dev/null +++ b/crates/disasmer-cli/src/task.rs @@ -0,0 +1,106 @@ +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::client::{ + authenticated_or_local_trusted_request, list_task_events_if_available_with_session, + JsonLineSession, +}; +use crate::config::StoredCliSession; +use crate::process_events::{task_restart_request_summary, task_summaries}; +use crate::{confirmation_required_report, TaskListArgs, TaskRestartArgs}; + +#[cfg(test)] +pub(crate) fn task_list_report(args: TaskListArgs) -> Result { + task_list_report_with_session(args, None) +} + +pub(crate) fn task_list_report_with_session( + args: TaskListArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + let events = list_task_events_if_available_with_session( + args.scope.coordinator.as_deref(), + &args.scope, + args.process.clone(), + stored_session, + )?; + let tasks = task_summaries(events.as_ref()); + Ok(json!({ + "command": "task list", + "process": args.process, + "tasks": tasks, + "events": events, + })) +} + +#[cfg(test)] +pub(crate) fn task_restart_report(args: TaskRestartArgs) -> Result { + task_restart_report_with_session(args, None) +} + +pub(crate) fn task_restart_report_with_session( + args: TaskRestartArgs, + stored_session: Option<&StoredCliSession>, +) -> Result { + if !args.yes { + return Ok(confirmation_required_report( + "task restart", + "restart_selected_task", + json!({ + "coordinator": args.scope.coordinator, + "tenant": args.scope.tenant, + "project": args.scope.project, + "process": args.process, + "task": args.task, + }), + format!( + "disasmer task restart {} --process {} --yes", + args.task, args.process + ), + )); + } + if let Some(coordinator) = &args.scope.coordinator { + let mut session = JsonLineSession::connect(coordinator)?; + let response = session.request_allow_error(authenticated_or_local_trusted_request( + coordinator, + stored_session, + json!({ + "type": "restart_task", + "process": args.process, + "task": args.task, + }), + json!({ + "type": "restart_task", + "tenant": args.scope.tenant, + "project": args.scope.project, + "actor_user": args.scope.user, + "process": args.process, + "task": args.task, + }), + )?)?; + let restart_request = task_restart_request_summary(&response, !args.yes); + return Ok(json!({ + "command": "task restart", + "coordinator": coordinator, + "process": args.process, + "task": args.task, + "requires_confirmation": !args.yes, + "restart_request": restart_request, + "response": response, + "coordinator_session_requests": session.requests(), + })); + } + Ok(json!({ + "command": "task restart", + "status": "requires_coordinator", + "requires_confirmation": !args.yes, + "process": args.process, + "task": args.task, + "restart_request": { + "status": "requires_coordinator", + "operation": "restart_selected_task", + "explicit_user_action": true, + "clean_boundary_required": true, + }, + })) +} diff --git a/crates/disasmer-cli/src/tests.rs b/crates/disasmer-cli/src/tests.rs new file mode 100644 index 0000000..b5e3d9d --- /dev/null +++ b/crates/disasmer-cli/src/tests.rs @@ -0,0 +1,4738 @@ +#![allow(clippy::useless_concat)] + +use std::fs; + +use clap::CommandFactory; + +use super::*; + +fn parse(args: &[&str]) -> Cli { + Cli::parse_from(args) +} + +fn write_runnable_wasm_project(project: &Path) { + let cli_manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let sdk = cli_manifest_dir.parent().unwrap().join("disasmer-sdk"); + let shared_target = cli_manifest_dir + .parent() + .and_then(Path::parent) + .unwrap() + .join("target/cli-run-test-fixture"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::create_dir_all(project.join(".cargo")).unwrap(); + fs::write( + project.join("Cargo.toml"), + format!( + "[package]\nname = \"cli-run-fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\ndisasmer = {{ package = \"disasmer-sdk\", path = {} }}\n", + serde_json::to_string(&sdk.to_string_lossy()).unwrap() + ), + ) + .unwrap(); + fs::write( + project.join(".cargo/config.toml"), + format!( + "[build]\ntarget-dir = {}\n", + serde_json::to_string(&shared_target.to_string_lossy()).unwrap() + ), + ) + .unwrap(); + fs::write( + project.join("src/lib.rs"), + r#" +#[disasmer::task] +#[unsafe(no_mangle)] +pub extern "C" fn task_add_one(value: i32) -> i32 { value + 1 } + +#[disasmer::main] +pub fn build_main() -> i32 { 7 } + +#[disasmer::main(name = "test")] +pub fn test_main() -> i32 { 8 } +"#, + ) + .unwrap(); +} + +#[test] +fn cli_error_classifier_distinguishes_mvp_failure_categories() { + for (message, category, exit_code) in [ + ( + "login required before running this command", + "authentication", + 20, + ), + ( + "CLI session credential has expired; run disasmer login --browser again", + "authentication", + 20, + ), + ( + "CLI session credential has been revoked", + "authentication", + 20, + ), + ("unauthorized tenant action", "authorization", 21), + ( + "quota unavailable: resource limit exceeded for api_calls", + "quota", + 22, + ), + ("policy denied native command execution", "policy", 23), + ( + "scheduler placement failed: no capable node for placement: project mismatch", + "capability", + 24, + ), + ( + "scheduler placement failed: no capable node for placement: source snapshot unavailable and direct connectivity unavailable", + "connectivity", + 25, + ), + ( + "failed to connect to coordinator: connection refused", + "connectivity", + 25, + ), + ( + "missing environment envs/linux/Containerfile", + "environment", + 26, + ), + ("task exited with status 101 after panic", "program", 27), + ( + "project already has active virtual process vp-current", + "active_process", + 28, + ), + ] { + let summary = cli_error_summary(message); + assert_eq!(summary["category"], category, "{message}"); + assert_eq!(summary["stable_exit_code"], exit_code, "{message}"); + assert_eq!(summary["safe_failure"], true); + assert_eq!(summary["process_exit_code_applied"], false); + assert!(summary["next_actions"].as_array().unwrap().len() >= 2); + if category == "quota" { + assert_eq!(summary["resource_category"], "api_calls"); + assert_eq!(summary["community_tier_language"], true); + assert_eq!(summary["community_tier_label"], "community tier"); + assert_eq!(summary["private_abuse_heuristics_exposed"], false); + let rendered = human_report(&json!({ + "command": "run", + "machine_error": summary, + })); + assert!(rendered.contains("quota tier: community tier")); + let forbidden_tier = ["free", "tier"].join(" "); + assert!(!rendered.to_ascii_lowercase().contains(&forbidden_tier)); + } + } +} + +#[test] +fn command_report_exit_code_marks_command_failures_only() { + let run_start = run_start_summary(&json!({ + "type": "error", + "message": "quota unavailable: resource limit exceeded for api_calls", + })); + let mut report = json!({ + "command": "run", + "status": run_start["status"].clone(), + "run_start": run_start, + }); + + assert_eq!(apply_command_report_exit_code(&mut report), Some(22)); + assert_eq!( + report["run_start"]["machine_error"]["process_exit_code_applied"], + true + ); + + let mut task_list = json!({ + "command": "task list", + "tasks": [{ + "task": "compile", + "state": "failed", + "machine_error": cli_error_summary_for_category("program", "task exited with status 1"), + }], + }); + assert_eq!(apply_command_report_exit_code(&mut task_list), None); + assert_eq!( + task_list["tasks"][0]["machine_error"]["process_exit_code_applied"], + false + ); +} + +#[test] +fn top_level_version_is_available() { + let command = Cli::command(); + assert_eq!(command.get_name(), "disasmer"); + assert!(command.get_version().is_some()); +} + +#[test] +fn run_defaults_to_current_project_and_build_entry() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); + + assert_eq!(plan.project, PathBuf::from("/repo")); + assert_eq!(plan.entry, "build"); + assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly); + assert_eq!(plan.hosted_coordinator_endpoint, None); + assert_eq!(plan.session, CliSession::Anonymous); +} + +#[test] +fn non_interactive_run_without_session_requires_explicit_auth_or_local() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "--non-interactive", "--json"]) + else { + panic!("wrong command"); + }; + let report = run_report(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); + + assert_eq!(report["command"], "run"); + assert_eq!(report["status"], "authentication_required"); + assert_eq!(report["non_interactive"], true); + assert_eq!(report["browser_opened"], false); + assert_eq!(report["private_website_required"], false); + assert_eq!(report["machine_error"]["category"], "authentication"); + assert_eq!(report["machine_error"]["stable_exit_code"], 20); + assert_eq!(report["machine_error"]["browser_opened"], false); + let next_actions = report["machine_error"]["next_actions"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(next_actions.contains(&"disasmer login --browser")); + assert!(next_actions.contains(&"set DISASMER_AGENT_PRIVATE_KEY for automation")); + assert!(next_actions.contains(&"pass --local to run against local services")); +} + +#[test] +fn run_project_and_named_entry_are_respected() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "test", "--project", "/other"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap(); + + assert_eq!(plan.project, PathBuf::from("/other")); + assert_eq!(plan.entry, "test"); + assert_eq!(plan.coordinator, CoordinatorSelection::Hosted); + assert_eq!( + plan.hosted_coordinator_endpoint.as_deref(), + Some(DEFAULT_HOSTED_COORDINATOR_ENDPOINT) + ); + assert_eq!(plan.session, CliSession::HumanSession); +} + +#[test] +fn node_attach_detects_and_accepts_capability_overrides() { + let Cli { + command: Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&["disasmer", "node", "attach", "--cap", "quic-direct"]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + + assert!(plan + .capabilities + .capabilities + .contains(&Capability::QuicDirect)); + assert!(!plan.capabilities.arch.is_empty()); + assert!(plan.detection.auto_detected); + assert_eq!(plan.detection.os, plan.capabilities.os); + assert_eq!(plan.detection.arch, plan.capabilities.arch); + assert_eq!(plan.detection.command_backend, "native-command"); + assert!(plan.detection.command_backend_available); + assert!(plan.detection.manual_capability_overrides_allowed); + assert_eq!( + plan.detection.manual_capability_overrides, + vec!["quic-direct".to_owned()] + ); + assert!(plan + .detection + .recognized_capability_overrides + .contains(&Capability::QuicDirect)); + assert!(plan.detection.unrecognized_capability_overrides.is_empty()); + assert!(!plan.detection.os_arch_capabilities_require_manual_flags); + assert!(plan + .detection + .source_provider_backends + .iter() + .any(|provider| provider.provider == "filesystem" && provider.detected)); +} + +#[test] +fn node_attach_discloses_dangerous_capability_grants() { + let Cli { + command: Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&[ + "disasmer", + "node", + "attach", + "--cap", + "network", + "--cap", + "host-filesystem", + "--cap", + "secrets", + ]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + let grants = plan + .grant_disclosures + .iter() + .map(|disclosure| disclosure.grant.as_str()) + .collect::>(); + + assert!(grants.contains(&"native_command_execution")); + assert!(grants.contains(&"source_access")); + assert!(grants.contains(&"network_access")); + assert!(grants.contains(&"host_filesystem_access")); + assert!(grants.contains(&"secret_access")); + assert!(plan + .grant_disclosures + .iter() + .all(|disclosure| disclosure.coordinator_policy_limited)); + + let rendered = human_report(&json!({ + "command": "node attach", + "node": plan.node, + "grant_disclosures": plan.grant_disclosures, + })); + assert!(rendered.contains("grant native_command_execution")); + assert!(rendered.contains("grant network_access")); + assert!(rendered.contains("policy-limited")); +} + +#[test] +fn agents_can_select_hosted_with_public_key_identity() { + let args = RunArgs { + entry: None, + project: None, + coordinator: None, + local: false, + non_interactive: true, + json: false, + }; + let plan = run_plan( + args, + PathBuf::from("/repo"), + CliSession::AgentPublicKey { + agent: "agent-ci".to_owned(), + public_key: "agent-key".to_owned(), + public_key_fingerprint: Digest::sha256("agent-key"), + private_key: None, + browser_interaction_required: false, + }, + ) + .unwrap(); + + assert_eq!(plan.coordinator, CoordinatorSelection::Hosted); + assert_eq!( + plan.hosted_coordinator_endpoint.as_deref(), + Some(DEFAULT_HOSTED_COORDINATOR_ENDPOINT) + ); + assert_eq!( + plan.session, + CliSession::AgentPublicKey { + agent: "agent-ci".to_owned(), + public_key: "agent-key".to_owned(), + public_key_fingerprint: Digest::sha256("agent-key"), + private_key: None, + browser_interaction_required: false, + } + ); +} + +#[test] +fn agent_environment_auth_requires_matching_private_key_possession() { + let public_only = agent_session_from_keys( + "agent-ci".to_owned(), + Some("ed25519:claimed-public-key".to_owned()), + None, + ) + .unwrap_err(); + assert!(public_only.to_string().contains("cannot authenticate")); + + let private_key = "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; + let public_key = disasmer_core::agent_ed25519_public_key_from_private_key(private_key).unwrap(); + let session = agent_session_from_keys( + "agent-ci".to_owned(), + Some(public_key.clone()), + Some(private_key.to_owned()), + ) + .unwrap() + .unwrap(); + assert_eq!( + session, + CliSession::AgentPublicKey { + agent: "agent-ci".to_owned(), + public_key: public_key.clone(), + public_key_fingerprint: Digest::sha256(public_key), + private_key: Some(private_key.to_owned()), + browser_interaction_required: false, + } + ); + + let mismatched = agent_session_from_keys( + "agent-ci".to_owned(), + Some("ed25519:different-public-key".to_owned()), + Some(private_key.to_owned()), + ) + .unwrap_err(); + assert!(mismatched.to_string().contains("does not match")); +} + +#[test] +fn run_with_agent_public_key_sends_attributable_workflow_actor() { + let temp = tempfile::tempdir().unwrap(); + write_runnable_wasm_project(temp.path()); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-live".to_owned(), + project: "project-live".to_owned(), + user: "user-live".to_owned(), + coordinator: None, + }, + ) + .unwrap(); + + let private_key = "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; + let public_key = disasmer_core::agent_ed25519_public_key_from_private_key(private_key).unwrap(); + let fingerprint = Digest::sha256(&public_key); + let expected_fingerprint = fingerprint.to_string(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"start_process""#)); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""project":"project-live""#)); + assert!(line.contains(r#""actor_agent":"agent-ci""#)); + assert!(line.contains(&format!( + r#""agent_public_key_fingerprint":"{expected_fingerprint}""# + ))); + assert!(line.contains(r#""agent_signature""#)); + assert!(line.contains(r#""nonce":"agent-signature-"#)); + assert!(line.contains(r#""signature":"ed25519:"#)); + assert!(!line.contains(r#""actor_user""#)); + stream + .write_all( + format!( + r#"{{"type":"process_started","process":"vp-current","epoch":7,"actor":{{"kind":"agent","user":"user-live","agent":"agent-ci","credential_kind":"public_key","public_key_fingerprint":"{expected_fingerprint}","authenticated_without_browser":true,"scopes":["project:read","project:run"]}}}}"# + ) + .as_bytes(), + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + let mut launch_line = String::new(); + reader.read_line(&mut launch_line).unwrap(); + assert!(launch_line.contains(r#""type":"launch_task""#)); + assert!(launch_line.contains(r#""tenant":"tenant-live""#)); + assert!(launch_line.contains(r#""project":"project-live""#)); + assert!(launch_line.contains(r#""process":"vp-current""#)); + let launch: Value = serde_json::from_str(&launch_line).unwrap(); + let task_definition = launch["payload"]["task_spec"]["task_definition"] + .as_str() + .expect("launch must use the selected entrypoint stable id"); + assert!(task_definition.starts_with("sha256:")); + assert_eq!( + launch["payload"]["task_spec"]["task_instance"], + "ti:vp-current:main" + ); + assert!(launch_line.contains(r#""required_capabilities":[]"#)); + assert!(launch_line.contains(r#""wasm_module_base64":""#)); + assert!(launch_line.contains(r#""kind":"coordinator_node_wasm""#)); + assert!(launch_line.contains(r#""export":"disasmer_entry_v1_"#)); + assert!(!launch_line.contains(r#""command":""#)); + assert!(launch_line.contains(r#""actor_agent":"agent-ci""#)); + assert!(launch_line.contains(&format!( + r#""agent_public_key_fingerprint":"{expected_fingerprint}""# + ))); + assert!(launch_line.contains(r#""agent_signature""#)); + assert!(launch_line.contains(r#""nonce":"agent-signature-"#)); + assert!(launch_line.contains(r#""signature":"ed25519:"#)); + assert!(!launch_line.contains(r#""actor_user""#)); + stream + .write_all( + serde_json::to_string(&json!({ + "type": "main_launched", + "process": "vp-current", + "task_definition": task_definition, + "task_instance": "ti:vp-current:main", + "placement": {"node": "agent-worker", "capabilities": []}, + })) + .unwrap() + .as_bytes(), + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = run_report( + RunArgs { + entry: Some("build".to_owned()), + project: Some(temp.path().to_path_buf()), + coordinator: Some(format!("disasmer+tcp://{addr}")), + local: false, + non_interactive: true, + json: false, + }, + PathBuf::from("/unused"), + CliSession::AgentPublicKey { + agent: "agent-ci".to_owned(), + public_key, + public_key_fingerprint: fingerprint, + private_key: Some(private_key.to_owned()), + browser_interaction_required: false, + }, + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["status"], "main_launched"); + assert_eq!(report["workflow_actor"]["kind"], "agent"); + assert_eq!(report["workflow_actor"]["agent"], "agent-ci"); + assert_eq!( + report["workflow_actor"]["authenticated_without_browser"], + true + ); + assert_eq!(report["run_start"]["actor"]["kind"], "agent"); + assert_eq!(report["task_launch"]["type"], "main_launched"); + assert_eq!(report["task_launch"]["placement"]["node"], "agent-worker"); +} + +#[test] +fn run_with_human_session_derives_workflow_scope_from_authenticated_envelope() { + let temp = tempfile::tempdir().unwrap(); + write_runnable_wasm_project(temp.path()); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "spoofed-tenant".to_owned(), + project: "spoofed-project".to_owned(), + user: "spoofed-user".to_owned(), + coordinator: None, + }, + ) + .unwrap(); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("run-session-secret".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + for (expected_operation, response) in [ + ( + "start_process", + br#"{"type":"process_started","process":"vp-current","epoch":7,"actor":{"kind":"user","user":"user-session","agent":null,"credential_kind":"cli_device_session","public_key_fingerprint":null,"authenticated_without_browser":false,"scopes":["project:read","project:run"]}}"#.as_slice(), + ), + ( + "launch_task", + br#"{"type":"main_launched","process":"vp-current","task_definition":"entry-build","task_instance":"ti:vp-current:main","placement":{"node":"human-worker","capabilities":[]}}"#.as_slice(), + ), + ] { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let wire: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(wire["type"], "coordinator_request"); + assert_eq!(wire["operation"], "authenticated"); + assert_eq!(wire["authentication"]["kind"], "cli_session"); + assert_eq!(wire["payload"]["type"], "authenticated"); + assert_eq!(wire["payload"]["session_secret"], "run-session-secret"); + assert_eq!(wire["payload"]["request"]["type"], expected_operation); + assert!(wire["payload"]["request"].get("tenant").is_none()); + assert!(wire["payload"]["request"].get("project").is_none()); + assert!(wire["payload"]["request"].get("actor_user").is_none()); + stream.write_all(response).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + + let report = run_report( + RunArgs { + entry: Some("build".to_owned()), + project: Some(temp.path().to_path_buf()), + coordinator: None, + local: false, + non_interactive: true, + json: false, + }, + PathBuf::from("/unused"), + CliSession::HumanSession, + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["status"], "main_launched"); + assert_eq!(report["workflow_actor"]["user"], "user-session"); + assert_eq!(report["task_launch"]["placement"]["node"], "human-worker"); +} + +#[test] +fn run_local_flag_overrides_logged_in_hosted_selection() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "--local"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap(); + + assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly); + assert_eq!(plan.hosted_coordinator_endpoint, None); +} + +#[test] +fn run_contacts_configured_coordinator_and_reports_active_process_conflicts() { + let temp = tempfile::tempdir().unwrap(); + write_runnable_wasm_project(temp.path()); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-live".to_owned(), + project: "project-live".to_owned(), + user: "user-live".to_owned(), + coordinator: None, + }, + ) + .unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for (index, response) in [ + r#"{"type":"process_started","process":"vp-current","epoch":7}"#, + r#"{"type":"error","message":"coordinator request failed: unauthorized coordinator action: project already has active virtual process vp-current; attach to or restart it, request cooperative cancellation, abort it, or use another Coordinator Project"}"#, + ] + .into_iter() + .enumerate() + { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"start_process""#)); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""project":"project-live""#)); + assert!(line.contains(r#""process":"vp-current""#)); + assert!(line.contains(r#""restart":false"#)); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + if index == 0 { + let mut launch_line = String::new(); + reader.read_line(&mut launch_line).unwrap(); + assert!(launch_line.contains(r#""type":"launch_task""#)); + assert!(launch_line.contains(r#""tenant":"tenant-live""#)); + assert!(launch_line.contains(r#""project":"project-live""#)); + assert!(launch_line.contains(r#""process":"vp-current""#)); + let launch: Value = serde_json::from_str(&launch_line).unwrap(); + let task_definition = launch["payload"]["task_spec"]["task_definition"] + .as_str() + .expect("launch must use the selected entrypoint stable id"); + assert!(task_definition.starts_with("sha256:")); + assert_eq!( + launch["payload"]["task_spec"]["task_instance"], + "ti:vp-current:main" + ); + assert!(launch_line.contains(r#""actor_user":"user-live""#)); + assert!(launch_line.contains(r#""required_capabilities":[]"#)); + assert!(launch_line.contains(r#""wasm_module_base64":""#)); + assert!(launch_line.contains(r#""kind":"coordinator_node_wasm""#)); + assert!(launch_line.contains(r#""export":"disasmer_entry_v1_"#)); + assert!(!launch_line.contains(r#""command":""#)); + assert!(!launch_line.contains("--manifest-path")); + stream + .write_all( + serde_json::to_string(&json!({ + "type": "main_launched", + "process": "vp-current", + "task_definition": task_definition, + "task_instance": "ti:vp-current:main", + "placement": {"node": "worker-live", "capabilities": []}, + })) + .unwrap() + .as_bytes(), + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + } + } + }); + + let started = run_report( + RunArgs { + entry: Some("build".to_owned()), + project: Some(temp.path().to_path_buf()), + coordinator: Some(format!("disasmer+tcp://{addr}")), + local: false, + non_interactive: false, + json: false, + }, + PathBuf::from("/unused"), + CliSession::Anonymous, + ) + .unwrap(); + let blocked = run_report( + RunArgs { + entry: Some("test".to_owned()), + project: Some(temp.path().to_path_buf()), + coordinator: Some(format!("disasmer+tcp://{addr}")), + local: false, + non_interactive: false, + json: false, + }, + PathBuf::from("/unused"), + CliSession::Anonymous, + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(started["status"], "main_launched"); + assert_eq!(started["entry"], "build"); + assert_eq!(started["tenant"], "tenant-live"); + assert_eq!(started["project"], "project-live"); + assert_eq!(started["process"], "vp-current"); + assert_eq!(started["run_start"]["restart"], false); + assert_eq!(started["run_start"]["single_active_process_boundary"], true); + assert_eq!(started["task_launch"]["type"], "main_launched"); + assert_eq!(started["task_launch"]["placement"]["node"], "worker-live"); + + assert_eq!(blocked["status"], "blocked_active_process"); + assert_eq!(blocked["entry"], "test"); + assert_eq!( + blocked["run_start"]["category"], + "active_process_already_running" + ); + assert_eq!(blocked["run_start"]["error_category"], "active_process"); + assert_eq!(blocked["run_start"]["stable_exit_code"], 28); + assert_eq!( + blocked["run_start"]["machine_error"]["category"], + "active_process" + ); + assert_eq!( + blocked["run_start"]["machine_error"]["stable_exit_code"], + 28 + ); + assert_eq!(blocked["run_start"]["safe_failure"], true); + assert!(blocked["run_start"]["next_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action == "disasmer process restart --yes")); +} + +#[test] +fn run_rejection_reports_machine_readable_error_category() { + let rejected = run_start_summary(&json!({ + "type": "error", + "message": "quota unavailable: resource limit exceeded for api_calls" + })); + + assert_eq!(rejected["status"], "coordinator_rejected"); + assert_eq!(rejected["error_category"], "quota"); + assert_eq!(rejected["stable_exit_code"], 22); + assert_eq!(rejected["machine_error"]["category"], "quota"); + assert_eq!(rejected["machine_error"]["resource_category"], "api_calls"); + assert_eq!(rejected["machine_error"]["community_tier_language"], true); + assert_eq!( + rejected["machine_error"]["private_abuse_heuristics_exposed"], + false + ); + assert!(rejected["machine_error"]["next_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action == "disasmer quota status")); +} + +#[test] +fn local_only_run_executes_ephemeral_local_services() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "--local"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); + + assert!(should_execute_local_node(&plan)); +} + +#[test] +fn login_defaults_to_the_server_owned_browser_flow() { + let Cli { + command: Commands::Login(args), + } = parse(&["disasmer", "login"]) + else { + panic!("wrong command"); + }; + let plan = login_plan(args); + + assert_eq!(plan.coordinator, DEFAULT_HOSTED_COORDINATOR_ENDPOINT); + let LoginFlowPlan::Browser(flow) = plan.human_flow; + assert_eq!(flow.authorization_url, None); + assert!(flow.server_owns_state); + assert!(flow.server_owns_nonce); + assert!(flow.pkce_required); + assert!(flow.hosted_callback); + assert!(!flow.cli_receives_provider_authorization_code); + assert!(!flow.cli_submits_identity_claims); +} + +#[test] +fn browser_login_flow_is_available_for_humans() { + let Cli { + command: Commands::Login(args), + } = parse(&["disasmer", "login", "--browser"]) + else { + panic!("wrong command"); + }; + let plan = login_plan(args); + + let LoginFlowPlan::Browser(flow) = plan.human_flow; + assert_eq!(flow.authorization_url, None); + assert!(flow.server_owns_state); + assert!(flow.server_owns_nonce); + assert!(flow.pkce_required); + assert!(flow.hosted_callback); + assert!(!flow.cli_receives_provider_authorization_code); + assert!(!flow.cli_submits_identity_claims); +} + +#[test] +fn login_inherits_the_current_project_scope() { + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "workspace-tenant".to_owned(), + project: "workspace-project".to_owned(), + user: "workspace-user".to_owned(), + coordinator: Some("https://workspace.example:9443".to_owned()), + }, + ) + .unwrap(); + let Cli { + command: Commands::Login(args), + } = parse(&["disasmer", "login", "--browser"]) + else { + panic!("wrong command"); + }; + + let args = login_args_for_project(args, temp.path()).unwrap(); + + assert_eq!(args.project, "workspace-project"); + assert_eq!(args.coordinator, "https://workspace.example:9443"); +} + +#[test] +fn auth_status_reports_a_session_that_does_not_match_the_current_project() { + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "workspace-tenant".to_owned(), + project: "workspace-project".to_owned(), + user: "workspace-user".to_owned(), + coordinator: Some("https://workspace.example:9443".to_owned()), + }, + ) + .unwrap(); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: "https://workspace.example:9443".to_owned(), + tenant: "other-tenant".to_owned(), + project: "other-project".to_owned(), + user: "other-user".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("other-session".to_owned()), + token_expiry_posture: "expires_at".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + let Cli { + command: Commands::Auth { + command: AuthCommands::Status(args), + }, + } = parse(&["disasmer", "auth", "status"]) + else { + panic!("wrong command"); + }; + + let report = auth_status_report(args, temp.path().to_path_buf()).unwrap(); + + assert_eq!(report["session_matches_current_project"], false); + assert_eq!( + report["coordinator_account_status"]["reason"], + "stored CLI session does not match the current project" + ); + assert_eq!( + report["coordinator_account_status"]["authenticated_for_current_project"], + false + ); +} + +#[test] +fn browser_login_non_interactive_fails_before_opening_browser() { + let Cli { + command: Commands::Login(args), + } = parse(&["disasmer", "login", "--browser", "--non-interactive"]) + else { + panic!("wrong command"); + }; + let report = non_interactive_browser_login_report(&args); + + assert_eq!(report["command"], "login"); + assert_eq!(report["status"], "authentication_required"); + assert_eq!(report["non_interactive"], true); + assert_eq!(report["browser_requested"], true); + assert_eq!(report["browser_opened"], false); + assert_eq!(report["machine_error"]["category"], "authentication"); + assert_eq!(report["machine_error"]["stable_exit_code"], 20); + assert_eq!(report["machine_error"]["browser_opened"], false); + let next_actions = report["machine_error"]["next_actions"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(next_actions.contains(&"rerun without --non-interactive to open the browser")); + assert!(next_actions.contains(&"use DISASMER_AGENT_PRIVATE_KEY for automation")); +} + +#[test] +fn browser_login_completion_detects_raw_provider_token_fields() { + assert!(contains_provider_token_field(&json!({ + "session": { + "access_token": "secret" + } + }))); + assert!(!contains_provider_token_field(&json!({ + "session": { + "cli_session_credential_kind": "CliDeviceSession", + "oidc_token_exchange": { + "received_access_token": true, + "received_id_token": true + } + } + }))); +} + +#[test] +fn stored_browser_login_session_omits_provider_token_values() { + let stored = stored_cli_session_from_login_response( + "https://coord.example.test", + &json!({ + "session": { + "tenant": "tenant-live", + "project": "project-live", + "user": "user-live", + "cli_session_credential_kind": "CliDeviceSession", + "cli_session_secret": "disasmer-cli-session-secret", + "expires_at": "2026-07-04T00:00:00Z", + "access_token": "provider-secret", + "id_token": "provider-id-token", + "provider_tokens_sent_to_nodes": false + } + }), + true, + false, + ) + .unwrap(); + let serialized = serde_json::to_string(&stored).unwrap(); + + assert_eq!(stored.kind, "human"); + assert_eq!(stored.coordinator, "https://coord.example.test"); + assert_eq!(stored.tenant, "tenant-live"); + assert_eq!(stored.project, "project-live"); + assert_eq!(stored.user, "user-live"); + assert_eq!( + stored.session_secret.as_deref(), + Some("disasmer-cli-session-secret") + ); + assert_eq!(stored.token_expiry_posture, "expires_at"); + assert!(stored.provider_tokens_exposed_to_cli); + assert!(!stored.provider_tokens_sent_to_nodes); + assert!(!serialized.contains("provider-secret")); + assert!(!serialized.contains("provider-id-token")); + assert!(!serialized.contains("access_token")); + assert!(!serialized.contains("id_token")); +} + +#[test] +fn stored_browser_login_session_accepts_hosted_epoch_expiry() { + let stored = stored_cli_session_from_login_response( + "https://hosted.example.test", + &json!({ + "session": { + "tenant": "tenant-live", + "project": "project-live", + "user": "user-live", + "cli_session_credential_kind": "CliDeviceSession", + "cli_session_secret": "disasmer-cli-session-secret", + "expires_at_epoch_seconds": 1_800_000_000_u64, + "provider_tokens_sent_to_nodes": false + } + }), + false, + false, + ) + .unwrap(); + + assert_eq!(stored.token_expiry_posture, "expires_at"); + assert_eq!(stored.expires_at.as_deref(), Some("1800000000")); +} + +#[test] +fn agent_enroll_uses_public_key_without_browser_each_run() { + let Cli { + command: Commands::Agent { + command: AgentCommands::Enroll(args), + }, + } = parse(&["disasmer", "agent", "enroll", "--public-key", "agent-key"]) + else { + panic!("wrong command"); + }; + let plan = agent_enrollment_plan(args); + + assert!(!plan.browser_interaction_required_each_run); + assert!(plan.public_key_fingerprint.as_str().starts_with("sha256:")); +} + +#[test] +fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("envs/linux")).unwrap(); + fs::create_dir_all(temp.path().join("envs/docker")).unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write( + temp.path().join("envs/linux/Containerfile"), + "FROM alpine\n", + ) + .unwrap(); + fs::write( + temp.path().join("envs/docker/Dockerfile"), + "FROM debian:stable-slim\n", + ) + .unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "fn main() {}\n\n#[disasmer::task]\nfn compile_linux() {}\n\n#[disasmer::main]\nfn build_main() {}\n", + ) + .unwrap(); + + let Cli { + command: Commands::Bundle { + command: BundleCommands::Inspect(args), + }, + } = parse(&[ + "disasmer", + "bundle", + "inspect", + "--project", + temp.path().to_str().unwrap(), + ]) + else { + panic!("wrong command"); + }; + let inspection = bundle_inspection(args, PathBuf::from("/unused")).unwrap(); + + assert_eq!(inspection.project, temp.path()); + assert!(inspection + .default_source_providers + .contains(&SourceProviderKind::Git)); + assert!(inspection.source_provider_statuses.iter().any(|status| { + status.provider == "filesystem" && status.status == "enabled" && status.active + })); + assert!(inspection + .source_provider_statuses + .iter() + .any(|status| status.provider == "git" && status.status == "missing")); + assert!(inspection + .source_provider_manifest + .description + .contains("node task")); + assert!( + !inspection + .source_provider_manifest + .coordinator_requires_checkout_access + ); + assert!( + !inspection + .source_provider_manifest + .transfer_policy + .default_full_repo_tarball + ); + assert!(inspection + .metadata + .environments + .iter() + .any(|environment| environment.name == "linux")); + assert!(inspection + .metadata + .environments + .iter() + .any(|environment| environment.name == "docker")); + assert_eq!( + inspection.metadata.source_metadata.source_provider_manifest, + inspection.source_provider_manifest.digest + ); + assert!( + inspection + .metadata + .source_metadata + .transfer_policy + .local_source_bytes_remain_node_local + ); + assert!( + !inspection + .metadata + .source_metadata + .transfer_policy + .coordinator_receives_source_bytes_by_default + ); + assert!( + !inspection + .metadata + .source_metadata + .transfer_policy + .default_full_repo_tarball + ); + assert!(inspection + .metadata + .task_metadata + .entrypoints + .contains(&"build".to_owned())); + assert_eq!( + inspection.metadata.task_metadata.default_entrypoint, + "build" + ); + assert_eq!( + inspection.metadata.task_metadata.task_abi, + task_abi_digest(&ProjectModel::discover_without_config(temp.path()).unwrap()) + ); + assert!(inspection + .metadata + .wasm_code + .as_str() + .starts_with("sha256:")); + assert!(inspection.metadata.debug_metadata.available); + assert!(inspection.metadata.debug_metadata.source_level_breakpoints); + assert!(inspection.metadata.debug_metadata.variables_pane_supported); + assert!(inspection + .metadata + .debug_metadata + .probes + .iter() + .any(|probe| probe.source_path == "src/main.rs" + && probe.function == "compile_linux" + && probe.task.as_str() == "compile_linux")); + assert!( + inspection + .metadata + .large_input_policy + .selected_inputs_are_content_digests + ); + assert!( + !inspection + .metadata + .large_input_policy + .selected_input_bytes_included + ); + assert!( + !inspection + .metadata + .large_input_policy + .full_repository_bytes_included + ); + assert!( + !inspection + .metadata + .large_input_policy + .silent_task_argument_serialization + ); + assert!(inspection + .metadata + .large_input_policy + .supported_handle_types + .contains(&"SourceSnapshot".to_owned())); + assert!( + inspection + .metadata + .restart_compatibility + .source_edits_can_restart_from_clean_task_boundary + ); + assert!( + inspection + .metadata + .restart_compatibility + .requires_clean_checkpoint_boundary + ); + assert_eq!( + inspection.metadata.restart_compatibility.compares_task_abi, + inspection.metadata.task_metadata.task_abi + ); + assert!( + inspection + .metadata + .restart_compatibility + .incompatible_changes_require_whole_process_restart + ); + assert!(!inspection.metadata.embeds_full_container_images); + assert!(inspection + .metadata + .selected_inputs + .iter() + .any(|input| input.path == "Cargo.toml")); + assert!(inspection + .metadata + .selected_inputs + .iter() + .any(|input| input.path == "src/main.rs")); + assert!(inspection.environment_diagnostics.is_empty()); + assert!(inspection + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| diagnostic.category == "capability" + && diagnostic.message.contains("environment `linux`"))); +} + +#[test] +fn bundle_inspect_reports_missing_environment_references_before_schedule() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { let _target = env!(\"linux\"); }\n", + ) + .unwrap(); + + let inspection = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!(inspection.environment_diagnostics.len(), 1); + assert_eq!(inspection.environment_diagnostics[0].path, "src/main.rs"); + assert_eq!( + inspection.environment_diagnostics[0].reference.name, + "linux" + ); + assert!(inspection.environment_diagnostics[0] + .message + .contains("missing Disasmer environment `linux`")); + assert!(inspection + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| { + diagnostic.severity == "error" + && diagnostic.category == "environment" + && diagnostic.code == "missing_environment" + })); +} + +#[test] +fn bundle_inspect_reports_source_provider_overrides_before_schedule() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + + let missing_git = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: Some("git".to_owned()), + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + assert!(missing_git + .source_provider_statuses + .iter() + .any(|status| { status.provider == "git" && status.status == "missing" && status.active })); + assert!(missing_git + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| { + diagnostic.category == "source_provider" && diagnostic.code == "source_provider_missing" + })); + + let unsupported = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: Some("custom-lfs".to_owned()), + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + assert!(unsupported.source_provider_statuses.iter().any(|status| { + status.provider == "custom-lfs" && status.status == "unsupported" && status.active + })); + assert!(unsupported + .pre_schedule_diagnostics + .iter() + .any(|diagnostic| { + diagnostic.category == "source_provider" + && diagnostic.code == "source_provider_unsupported" + })); +} + +#[test] +fn bundle_identity_changes_when_selected_input_file_changes() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + + let first = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname='changed'\n", + ) + .unwrap(); + let second = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_ne!(first.metadata.identity, second.metadata.identity); +} + +#[test] +fn bundle_rebuild_after_source_edit_keeps_restart_compatibility_contract() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { println!(\"first\"); }\n", + ) + .unwrap(); + + let inspect = |project: &Path| { + bundle_inspection( + BundleInspectArgs { + project: Some(project.to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: true, + }, + PathBuf::from("/unused"), + ) + .unwrap() + }; + + let first = inspect(temp.path()); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { println!(\"second\"); }\n", + ) + .unwrap(); + let rebuilt = inspect(temp.path()); + + assert_ne!(first.metadata.identity, rebuilt.metadata.identity); + assert_ne!( + first + .metadata + .source_metadata + .selected_inputs + .iter() + .find(|input| input.path == "src/main.rs") + .unwrap() + .digest, + rebuilt + .metadata + .source_metadata + .selected_inputs + .iter() + .find(|input| input.path == "src/main.rs") + .unwrap() + .digest + ); + assert_eq!( + first.metadata.task_metadata.task_abi, + rebuilt.metadata.task_metadata.task_abi + ); + assert_eq!( + first.metadata.restart_compatibility.compares_task_abi, + rebuilt.metadata.restart_compatibility.compares_task_abi + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .source_edits_can_restart_from_clean_task_boundary + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .requires_clean_checkpoint_boundary + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .discards_unflushed_task_local_changes + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .incompatible_changes_require_whole_process_restart + ); +} + +#[test] +fn source_provider_manifest_digest_does_not_include_local_project_path() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + fs::write(first.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write(second.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + + let first = bundle_inspection( + BundleInspectArgs { + project: Some(first.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + let second = bundle_inspection( + BundleInspectArgs { + project: Some(second.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!( + first.source_provider_manifest.digest, + second.source_provider_manifest.digest + ); +} + +#[test] +fn node_attach_can_exchange_enrollment_grant() { + let Cli { + command: Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&[ + "disasmer", + "node", + "attach", + "--enrollment-grant", + "grant", + "--public-key", + "node-key", + ]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + + assert!( + plan.enrollment + .unwrap() + .exchanges_short_lived_grant_for_long_lived_node_identity + ); +} + +#[test] +fn node_attach_enrollment_uses_default_public_key_when_not_explicit() { + let Cli { + command: Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&[ + "disasmer", + "node", + "attach", + "--node", + "node-default-key", + "--enrollment-grant", + "grant", + ]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + + let enrollment = plan.enrollment.unwrap(); + assert_eq!(enrollment.grant, "grant"); + assert!(enrollment + .public_key_fingerprint + .as_str() + .starts_with("sha256:")); +} + +#[test] +fn node_attach_local_credential_is_durable_and_project_scoped() { + let temp = tempfile::tempdir().unwrap(); + let node = "node/durable"; + + let first = node::load_or_create_local_node_credential(temp.path(), node).unwrap(); + let second = node::load_or_create_local_node_credential(temp.path(), node).unwrap(); + let credential_file = node::local_node_credential_file(temp.path(), node); + + assert_eq!(first, second); + assert!(credential_file.exists()); + assert!( + credential_file + .strip_prefix(temp.path().join(".disasmer").join("nodes")) + .unwrap() + .components() + .count() + == 1 + ); + let public_key = disasmer_core::node_ed25519_public_key_from_private_key(&first).unwrap(); + let bytes = fs::read(credential_file).unwrap(); + let stored: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(stored["kind"], "disasmer_node_credential"); + assert_eq!(stored["node"], node); + assert_eq!(stored["public_key"], public_key); + assert_eq!(stored["credential_scope"], "local_project_node_identity"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let file_mode = fs::metadata(node::local_node_credential_file(temp.path(), node)) + .unwrap() + .permissions() + .mode() + & 0o777; + let directory_mode = fs::metadata(temp.path().join(".disasmer").join("nodes")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(file_mode, 0o600); + assert_eq!(directory_mode, 0o700); + } +} + +#[cfg(unix)] +#[test] +fn node_attach_refuses_a_symlink_credential_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let node = "node/symlink"; + let file = node::local_node_credential_file(temp.path(), node); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + let target = temp.path().join("attacker-controlled.json"); + fs::write(&target, b"{}").unwrap(); + symlink(&target, &file).unwrap(); + + let error = node::load_or_create_local_node_credential(temp.path(), node).unwrap_err(); + assert!(error.to_string().contains("symbolic link")); +} + +#[test] +fn hosted_coordinator_remains_a_real_https_control_endpoint() { + assert_eq!( + control_endpoint_identity(DEFAULT_HOSTED_COORDINATOR_ENDPOINT).unwrap(), + "https://disasmer.michelpaulissen.com/api/v1/control" + ); + assert_eq!( + control_endpoint_identity("https://disasmer.michelpaulissen.com/api/v1/control").unwrap(), + "https://disasmer.michelpaulissen.com/api/v1/control" + ); + assert!(control_endpoint_identity("http://operator.example.test").is_err()); + assert_eq!( + control_endpoint_identity("127.0.0.1:7999").unwrap(), + "disasmer+tcp://127.0.0.1:7999" + ); +} + +#[test] +fn doctor_reports_unchecked_coordinator_reachability_without_config() { + let temp = tempfile::tempdir().unwrap(); + let report = doctor_report( + DoctorArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + + assert_eq!(report["command"], "doctor"); + assert!(report["coordinator"].is_null()); + assert_eq!(report["coordinator_reachability"]["checked"], false); + assert_eq!( + report["coordinator_reachability"]["status"], + "not_configured" + ); + assert!(matches!( + report["node_readiness_summary"]["status"].as_str(), + Some("ready_to_attach") | Some("local_dependencies_missing") | Some("limited_capabilities") + )); + assert_eq!( + report["node_readiness_summary"]["explicit_attach_required"], + true + ); + assert_eq!( + report["node_readiness_summary"]["command_execution_capability"], + true + ); + assert!(report["node_readiness_summary"]["missing_local_dependencies"].is_array()); + assert!( + report["node_readiness_summary"]["next_actions"] + .as_array() + .unwrap() + .len() + >= 2 + ); +} + +#[test] +fn doctor_pings_configured_coordinator() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["type"], "coordinator_request"); + assert_eq!(request["protocol_version"], 1); + assert_eq!(request["request_id"], "doctor-1"); + assert_eq!(request["operation"], "ping"); + assert_eq!(request["authentication"]["kind"], "none"); + assert_eq!(request["payload"]["type"], "ping"); + stream + .write_all(b"{\"type\":\"pong\",\"epoch\":42}\n") + .unwrap(); + }); + + let temp = tempfile::tempdir().unwrap(); + let report = doctor_report( + DoctorArgs { + scope: CliScopeArgs { + coordinator: Some(addr.clone()), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["coordinator"], addr); + assert_eq!(report["coordinator_reachability"]["checked"], true); + assert_eq!(report["coordinator_reachability"]["status"], "reachable"); + assert_eq!( + report["coordinator_reachability"]["response"]["type"], + "pong" + ); + assert_eq!(report["coordinator_reachability"]["response"]["epoch"], 42); +} + +#[test] +fn auth_status_reads_stored_cli_session_without_provider_tokens() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"authenticated""#)); + assert!(line.contains(r#""session_secret":"disasmer-cli-session-secret""#)); + assert!(line.contains(r#""type":"auth_status""#)); + assert!(!line.contains(r#""actor_user":"user-session""#)); + stream + .write_all( + br#"{"type":"auth_status","tenant":"tenant-session","project":"project-session","actor":"user-session","authenticated":true,"account_status":"active","suspended":false,"disabled":false,"sanitized_reason":null,"next_actions":[],"private_moderation_details_exposed":false,"signup_failure_details_exposed":false}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("disasmer-cli-session-secret".to_owned()), + token_expiry_posture: "expires_at".to_owned(), + expires_at: Some("2026-07-04T00:00:00Z".to_owned()), + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + + let report = auth_status_report( + AuthStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["active_coordinator"], addr); + assert_eq!(report["principal"], "user-session"); + assert_eq!(report["tenant"], "tenant-session"); + assert_eq!(report["project"], "project-session"); + assert_eq!(report["session"]["kind"], "human"); + assert_eq!(report["session"]["source"], "session_file"); + assert_eq!(report["session"]["authenticated"], true); + assert_eq!( + report["session"]["cli_session_credential_kind"], + "CliDeviceSession" + ); + assert_eq!( + report["coordinator_account_status"]["used_cli_session_credential"], + true + ); + assert_eq!(report["session"]["token_expiry_posture"], "expires_at"); + assert_eq!(report["session"]["provider_tokens_exposed_to_cli"], false); + assert_eq!(report["session"]["provider_tokens_exposed_to_nodes"], false); + assert_eq!(report["coordinator_account_status"]["checked"], true); + assert_eq!( + report["coordinator_account_status"]["account_status"], + "active" + ); + assert_eq!( + report["coordinator_account_status"]["private_moderation_details_exposed"], + false + ); +} + +#[test] +fn auth_status_reports_expired_or_revoked_cli_session_as_login_required() { + for message in [ + "unauthorized coordinator action: CLI session credential has expired; run disasmer login --browser again", + "unauthorized coordinator action: CLI session credential has been revoked", + ] { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server_message = message.to_owned(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"authenticated""#)); + assert!(line.contains(r#""session_secret":"stale-cli-session-secret""#)); + assert!(line.contains(r#""type":"auth_status""#)); + stream + .write_all( + format!(r#"{{"type":"error","message":{}}}"#, json!(server_message)) + .as_bytes(), + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("stale-cli-session-secret".to_owned()), + token_expiry_posture: "expires_at".to_owned(), + expires_at: Some("2026-07-04T00:00:00Z".to_owned()), + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + + let report = auth_status_report( + AuthStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["coordinator_account_status"]["checked"], true); + assert_eq!(report["coordinator_account_status"]["reachable"], true); + assert_eq!( + report["coordinator_account_status"]["machine_error"]["category"], + "authentication" + ); + assert!( + report["coordinator_account_status"]["next_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action == "disasmer login --browser") + ); + } +} + +#[test] +fn auth_status_queries_coordinator_account_state_without_private_moderation_details() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"auth_status""#)); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""project":"project-live""#)); + assert!(line.contains(r#""actor_user":"user-live""#)); + stream + .write_all( + br#"{"type":"auth_status","tenant":"tenant-live","project":"project-live","actor":"user-live","authenticated":true,"account_status":"suspended","suspended":true,"disabled":false,"sanitized_reason":"account or tenant is suspended by hosted policy","next_actions":["contact the hosted operator"],"private_moderation_details_exposed":false,"signup_failure_details_exposed":false,"abuse_score":99,"moderation_notes":"private moderation note"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let temp = tempfile::tempdir().unwrap(); + let report = auth_status_report( + AuthStatusArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant-live".to_owned(), + project: "project-live".to_owned(), + user: "user-live".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["command"], "auth status"); + assert_eq!(report["coordinator_account_status"]["checked"], true); + assert_eq!(report["coordinator_account_status"]["reachable"], true); + assert_eq!( + report["coordinator_account_status"]["source"], + "public_coordinator_api" + ); + assert_eq!( + report["coordinator_account_status"]["account_status"], + "suspended" + ); + assert_eq!(report["coordinator_account_status"]["suspended"], true); + assert_eq!(report["coordinator_account_status"]["disabled"], false); + assert_eq!( + report["coordinator_account_status"]["sanitized_reason"], + "account or tenant is suspended by hosted policy" + ); + assert_eq!( + report["coordinator_account_status"]["private_moderation_details_exposed"], + false + ); + assert_eq!( + report["coordinator_account_status"]["signup_failure_details_exposed"], + false + ); + assert_eq!( + report["coordinator_account_status"]["coordinator_response_type"], + "auth_status" + ); + assert_eq!( + report["coordinator_account_status"]["coordinator_session_requests"], + 1 + ); + let serialized = serde_json::to_string(&report).unwrap(); + assert!(!serialized.contains("abuse_score")); + assert!(!serialized.contains("moderation_notes")); + assert!(!serialized.contains("private moderation note")); + + let rendered = human_report(&report); + assert!(rendered.contains("account status: suspended")); + assert!(rendered.contains("account suspended: true")); + assert!(rendered.contains("private moderation details exposed: false")); + assert!(!rendered.contains("private moderation note")); +} + +#[test] +fn auth_status_reports_disabled_deleted_and_manual_review_safely() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for (tenant, status, reason, flags) in [ + ( + "tenant-disabled", + "disabled", + "account or tenant is disabled by hosted policy", + (false, true, false, false), + ), + ( + "tenant-deleted", + "deleted", + "account or tenant is no longer active", + (false, false, true, false), + ), + ( + "tenant-review", + "manual_review", + "account or tenant is pending hosted review", + (false, false, false, true), + ), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"auth_status""#)); + assert!(line.contains(&format!(r#""tenant":"{tenant}""#))); + let (suspended, disabled, deleted, manual_review) = flags; + writeln!( + stream, + "{}", + json!({ + "type": "auth_status", + "tenant": tenant, + "project": "project-live", + "actor": "user-live", + "authenticated": true, + "account_status": status, + "suspended": suspended, + "disabled": disabled, + "deleted": deleted, + "manual_review": manual_review, + "sanitized_reason": reason, + "next_actions": ["contact the hosted operator"], + "private_moderation_details_exposed": false, + "signup_failure_details_exposed": false, + "abuse_score": 99, + "moderation_notes": "private moderation note", + "signup_policy_trace": "private signup trace", + }) + ) + .unwrap(); + } + }); + + for (tenant, status, rendered_marker) in [ + ("tenant-disabled", "disabled", "account disabled: true"), + ("tenant-deleted", "deleted", "account deleted: true"), + ( + "tenant-review", + "manual_review", + "account manual review: true", + ), + ] { + let temp = tempfile::tempdir().unwrap(); + let report = auth_status_report( + AuthStatusArgs { + scope: CliScopeArgs { + coordinator: Some(addr.clone()), + tenant: tenant.to_owned(), + project: "project-live".to_owned(), + user: "user-live".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!( + report["coordinator_account_status"]["account_status"], + status + ); + assert_eq!( + report["coordinator_account_status"]["account_state_known"], + true + ); + assert_eq!( + report["coordinator_account_status"]["private_moderation_details_exposed"], + false + ); + assert_eq!( + report["coordinator_account_status"]["signup_failure_details_exposed"], + false + ); + let serialized = serde_json::to_string(&report).unwrap(); + assert!(!serialized.contains("abuse_score")); + assert!(!serialized.contains("moderation_notes")); + assert!(!serialized.contains("signup_policy_trace")); + assert!(!serialized.contains("private moderation note")); + let rendered = human_report(&report); + assert!(rendered.contains(&format!("account status: {status}"))); + assert!(rendered.contains(rendered_marker)); + assert!(!rendered.contains("private moderation note")); + } + server.join().unwrap(); +} + +#[test] +fn cli_first_mvp_command_surface_parses() { + for args in [ + &["disasmer", "doctor"][..], + &["disasmer", "auth", "status"], + &["disasmer", "logout", "--yes"], + &["disasmer", "auth", "logout", "--yes"], + &["disasmer", "login", "--browser", "--non-interactive"], + &[ + "disasmer", + "key", + "add", + "--agent", + "agent", + "--public-key", + "key", + ], + &["disasmer", "key", "list"], + &["disasmer", "key", "revoke", "--agent", "agent", "--yes"], + &["disasmer", "project", "init", "--yes"], + &["disasmer", "project", "status"], + &["disasmer", "project", "list"], + &["disasmer", "project", "select", "project"], + &["disasmer", "inspect"], + &["disasmer", "build"], + &["disasmer", "run", "--non-interactive"], + &["disasmer", "node", "enroll"], + &["disasmer", "node", "list"], + &["disasmer", "node", "status"], + &["disasmer", "node", "revoke", "--node", "node", "--yes"], + &["disasmer", "process", "list"], + &["disasmer", "process", "status"], + &["disasmer", "process", "restart", "--yes"], + &["disasmer", "process", "cancel", "--yes"], + &["disasmer", "process", "abort", "--yes"], + &["disasmer", "task", "list"], + &["disasmer", "task", "restart", "compile-linux", "--yes"], + &["disasmer", "logs"], + &["disasmer", "artifact", "list"], + &["disasmer", "artifact", "download", "artifact"], + &[ + "disasmer", "artifact", "export", "artifact", "--to", "/tmp/out", + ], + &["disasmer", "dap", "--plan"], + &["disasmer", "debug", "attach"], + &["disasmer", "quota", "status"], + &["disasmer", "admin", "status"], + &["disasmer", "admin", "bootstrap", "--yes"], + &[ + "disasmer", + "admin", + "revoke-node", + "--node", + "node", + "--yes", + ], + &["disasmer", "admin", "stop-process", "--yes"], + &["disasmer", "admin", "suspend-tenant", "--yes"], + ] { + let _ = parse(args); + } +} + +#[test] +fn cli_has_no_direct_hosted_account_creation_command() { + for args in [ + &["disasmer", "signup"][..], + &["disasmer", "account", "create"], + &["disasmer", "login", "--create-account"], + ] { + let error = Cli::try_parse_from(args).unwrap_err().to_string(); + assert!( + error.contains("unrecognized subcommand") || error.contains("unexpected argument"), + "expected no direct account creation command for {args:?}, got {error}" + ); + } + + let mut command = Cli::command(); + let help = command.render_help().to_string(); + assert!(help.contains("Hosted account creation happens in the browser login flow.")); + assert!(!help.contains("disasmer signup")); + assert!(!help.contains("account create")); +} + +#[test] +fn admin_bootstrap_reports_self_hosted_cli_only_path() { + let temp = tempfile::tempdir().unwrap(); + let report = admin_bootstrap_report( + AdminBootstrapArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "team".to_owned(), + project: "self-hosted".to_owned(), + user: "admin".to_owned(), + json: false, + }, + name: "Self Hosted".to_owned(), + yes: true, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + + assert_eq!(report["command"], "admin bootstrap"); + assert_eq!(report["mode"], "self_hosted_local"); + assert_eq!(report["private_website_required"], false); + assert_eq!(report["self_hosted_cli_only"], true); + assert_eq!(report["project_config_written"], true); + assert_eq!(report["project_init"]["command"], "project init"); + assert_eq!(report["project_init"]["private_website_required"], false); + assert_eq!( + report["project_init"]["project_config"]["project"], + "self-hosted" + ); + assert_eq!( + report["admin_surfaces"]["node"], + "disasmer node enroll/list/status/revoke" + ); + let steps = report["bootstrap_sequence"].as_array().unwrap(); + for expected in [ + "start_self_hosted_coordinator", + "create_or_link_project", + "create_node_enrollment_grant", + "attach_worker_node", + "run_process", + "inspect_status_logs_artifacts", + "revoke_access", + ] { + assert!( + steps.iter().any(|step| step["step"] == expected), + "missing bootstrap step {expected}" + ); + } + assert!(steps.iter().all(|step| { + !step + .get("private_website_required") + .and_then(Value::as_bool) + .unwrap_or(false) + })); + assert!(steps.iter().any(|step| step + .get("command") + .and_then(Value::as_str) + .unwrap_or("") + .contains("disasmer node enroll"))); + assert!(steps.iter().any(|step| step + .get("command") + .and_then(Value::as_str) + .unwrap_or("") + .contains("disasmer admin revoke-node"))); +} + +#[test] +fn top_level_help_exposes_primary_workflow_without_auth() { + let mut command = Cli::command(); + let help = command.render_help().to_string(); + + for expected in [ + "Primary workflow:", + "disasmer login --browser", + "disasmer project init", + "disasmer node enroll", + "disasmer node attach", + "disasmer-node --worker", + "disasmer run [entry] --project ", + "Disasmer: Launch Virtual Process", + "disasmer dap", + "disasmer process status", + "task list", + "logs", + "artifact list", + "--json", + "Hosted account creation happens in the browser login flow.", + ] { + assert!(help.contains(expected), "help output missing {expected}"); + } +} + +#[test] +fn top_level_logout_alias_removes_only_cli_session_state() { + let temp = tempfile::tempdir().unwrap(); + let session_file = session_config_file(temp.path()); + fs::create_dir_all(session_file.parent().unwrap()).unwrap(); + fs::write(&session_file, br#"{"kind":"human","token":"local"}"#).unwrap(); + + let unconfirmed = logout_report( + AuthLogoutArgs { + yes: false, + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + "logout", + ) + .unwrap(); + + assert_eq!(unconfirmed["status"], "confirmation_required"); + assert_eq!(unconfirmed["coordinator_request_sent"], false); + assert_eq!(unconfirmed["machine_error"]["category"], "policy"); + assert!(session_file.exists()); + + let report = logout_report( + AuthLogoutArgs { + yes: true, + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + "logout", + ) + .unwrap(); + + assert_eq!(report["command"], "logout"); + assert_eq!(report["requires_confirmation"], false); + assert_eq!(report["removed_cli_session_file"], true); + assert_eq!(report["node_credentials_untouched"], true); + assert!(!session_file.exists()); +} + +#[test] +fn logout_revokes_stored_cli_session_on_coordinator_before_local_removal() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("logout-cli-session-secret".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + let session_file = session_config_file(temp.path()); + assert!(session_file.exists()); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"authenticated""#)); + assert!(line.contains(r#""session_secret":"logout-cli-session-secret""#)); + assert!(line.contains(r#""type":"revoke_cli_session""#)); + assert!(!line.contains(r#""actor_user":"user-session""#)); + stream + .write_all( + br#"{"type":"cli_session_revoked","tenant":"tenant-session","project":"project-session","actor":"user-session"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = logout_report( + AuthLogoutArgs { + yes: true, + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + "logout", + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["removed_cli_session_file"], true); + assert_eq!(report["server_session_revocation"]["attempted"], true); + assert_eq!(report["server_session_revocation"]["revoked"], true); + assert_eq!( + report["server_session_revocation"]["coordinator_response_type"], + "cli_session_revoked" + ); + assert!(!session_file.exists()); +} + +#[test] +fn mutating_commands_require_yes_before_side_effects() { + let temp = tempfile::tempdir().unwrap(); + let scope = CliScopeArgs { + coordinator: Some("127.0.0.1:9".to_owned()), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + let reports = [ + key_revoke_report(KeyRevokeArgs { + scope: scope.clone(), + agent: "agent-ci".to_owned(), + yes: false, + }) + .unwrap(), + node_revoke_report( + NodeRevokeArgs { + scope: scope.clone(), + node: "node-a".to_owned(), + yes: false, + }, + temp.path().to_path_buf(), + ) + .unwrap(), + process_restart_report(ProcessRestartArgs { + scope: scope.clone(), + process: "vp".to_owned(), + yes: false, + }) + .unwrap(), + process_cancel_report(ProcessCancelArgs { + scope: scope.clone(), + process: "vp".to_owned(), + node: None, + task: None, + yes: false, + }) + .unwrap(), + task_restart_report(TaskRestartArgs { + scope: scope.clone(), + task: "compile-linux".to_owned(), + process: "vp".to_owned(), + yes: false, + }) + .unwrap(), + admin_suspend_tenant_report(AdminSuspendTenantArgs { + scope, + target_tenant: Some("tenant".to_owned()), + admin_token: None, + yes: false, + }) + .unwrap(), + ]; + + for report in reports { + assert_eq!(report["status"], "confirmation_required"); + assert_eq!(report["requires_confirmation"], true); + assert_eq!(report["coordinator_request_sent"], false); + assert_eq!(report["safe_failure"], true); + assert_eq!(report["machine_error"]["category"], "policy"); + assert_eq!(report["machine_error"]["confirmation_required"], true); + assert!(report["next_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action.as_str().unwrap_or_default().contains("--yes"))); + } +} + +#[test] +fn cli_first_json_mode_parses_for_primary_commands() { + for args in [ + &["disasmer", "doctor", "--json"][..], + &["disasmer", "login", "--json"], + &[ + "disasmer", + "login", + "--browser", + "--non-interactive", + "--json", + ], + &["disasmer", "logout", "--yes", "--json"], + &["disasmer", "auth", "status", "--json"], + &[ + "disasmer", + "agent", + "enroll", + "--public-key", + "key", + "--json", + ], + &[ + "disasmer", + "key", + "add", + "--agent", + "agent", + "--public-key", + "key", + "--json", + ], + &["disasmer", "project", "init", "--yes", "--json"], + &["disasmer", "inspect", "--json"], + &["disasmer", "build", "--json"], + &["disasmer", "bundle", "inspect", "--json"], + &["disasmer", "run", "--json"], + &["disasmer", "run", "--non-interactive", "--json"], + &["disasmer", "node", "attach", "--json"], + &["disasmer", "node", "enroll", "--json"], + &["disasmer", "process", "status", "--json"], + &["disasmer", "task", "list", "--json"], + &["disasmer", "logs", "--json"], + &["disasmer", "artifact", "list", "--json"], + &["disasmer", "artifact", "download", "artifact", "--json"], + &[ + "disasmer", "artifact", "export", "artifact", "--to", "/tmp/out", "--json", + ], + &["disasmer", "dap", "--plan", "--json"], + &["disasmer", "debug", "attach", "--json"], + &["disasmer", "quota", "status", "--json"], + &["disasmer", "admin", "status", "--json"], + ] { + let _ = parse(args); + } +} + +#[test] +fn key_lifecycle_reports_project_scoped_agent_credentials() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let add_response = concat!( + r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"# + ); + let list_response = concat!( + r#"{"type":"agent_public_keys","actor":"user","records":[{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":false,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}]}"# + ); + let revoke_response = concat!( + r#"{"type":"agent_public_key","actor":"user","record":{"tenant":"tenant","project":"project","user":"user","agent":"agent-ci","public_key":"agent-key-v1","public_key_fingerprint":"sha256:agent-v1","version":1,"revoked":true,"scopes":["project:read","project:run"],"human_account_creation_privilege":false,"browser_interaction_required_each_run":false}}"# + ); + for (expected, response) in [ + ("register_agent_public_key", add_response), + ("list_agent_public_keys", list_response), + ("revoke_agent_public_key", revoke_response), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(&format!(r#""type":"{expected}""#))); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""user":"user""#)); + if expected != "list_agent_public_keys" { + assert!(line.contains(r#""agent":"agent-ci""#)); + } + if expected == "register_agent_public_key" { + assert!(line.contains(r#""public_key":"agent-key-v1""#)); + } + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + + let added = key_add_report(KeyAddArgs { + scope: scope.clone(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v1".to_owned(), + }) + .unwrap(); + let listed = key_list_report(KeyListArgs { + scope: scope.clone(), + }) + .unwrap(); + let revoked = key_revoke_report(KeyRevokeArgs { + scope, + agent: "agent-ci".to_owned(), + yes: true, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(added["command"], "key add"); + assert_eq!(added["agent"], "agent-ci"); + assert_eq!(added["credential_scope"]["actions"][0], "project:read"); + assert_eq!( + added["credential_scope"]["human_account_creation_privilege"], + false + ); + assert_eq!(added["browser_interaction_required_each_run"], false); + assert_eq!(added["attribution"]["registered_by_user"], "user"); + assert_eq!(listed["records"].as_array().unwrap().len(), 1); + assert_eq!(listed["credential_scope"]["listed_for_user"], "user"); + assert_eq!(revoked["revoked"], true); + assert_eq!(revoked["attribution"]["revoked_by_user"], "user"); +} + +#[test] +fn key_lifecycle_uses_coordinator_bound_client_session_without_claimed_scope() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let stored_session = StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("key-session-secret".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }; + let server = std::thread::spawn(move || { + for (expected, response) in [ + ( + "register_agent_public_key", + br#"{"type":"agent_public_key","actor":"user-session","record":{"public_key_fingerprint":"sha256:agent-v1","scopes":["project:read","project:run"],"revoked":false}}"#.as_slice(), + ), + ( + "list_agent_public_keys", + br#"{"type":"agent_public_keys","actor":"user-session","records":[]}"#.as_slice(), + ), + ( + "revoke_agent_public_key", + br#"{"type":"agent_public_key","actor":"user-session","record":{"revoked":true}}"#.as_slice(), + ), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let wire: Value = serde_json::from_str(&line).unwrap(); + let payload = &wire["payload"]; + assert_eq!(payload["type"], "authenticated"); + assert_eq!(payload["session_secret"], "key-session-secret"); + assert_eq!(payload["request"]["type"], expected); + assert!(payload["request"].get("tenant").is_none()); + assert!(payload["request"].get("project").is_none()); + assert!(payload["request"].get("user").is_none()); + stream.write_all(response).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + let ignored_scope = CliScopeArgs { + coordinator: None, + tenant: "ignored-tenant".to_owned(), + project: "ignored-project".to_owned(), + user: "ignored-user".to_owned(), + json: false, + }; + + let added = key_add_report_with_session( + KeyAddArgs { + scope: ignored_scope.clone(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v1".to_owned(), + }, + Some(&stored_session), + ) + .unwrap(); + let listed = key_list_report_with_session( + KeyListArgs { + scope: ignored_scope.clone(), + }, + Some(&stored_session), + ) + .unwrap(); + let revoked = key_revoke_report_with_session( + KeyRevokeArgs { + scope: ignored_scope, + agent: "agent-ci".to_owned(), + yes: true, + }, + Some(&stored_session), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(added["tenant"], "tenant-session"); + assert_eq!(added["project"], "project-session"); + assert_eq!(added["user"], "user-session"); + assert_eq!(listed["tenant"], "tenant-session"); + assert_eq!(revoked["user"], "user-session"); +} + +#[test] +fn client_session_secret_is_never_sent_to_a_different_coordinator() { + let stored_session = StoredCliSession { + kind: "human".to_owned(), + coordinator: "https://trusted.example:9443".to_owned(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("must-not-leak".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }; + let authenticated = crate::client::authenticated_or_local_trusted_request( + "https://trusted.example:9443", + Some(&stored_session), + json!({"type": "list_projects"}), + json!({"type": "local_trusted"}), + ) + .unwrap(); + let untrusted = crate::client::authenticated_or_local_trusted_request( + "https://attacker.example:9443", + Some(&stored_session), + json!({"type": "list_projects"}), + json!({"type": "local_trusted"}), + ); + + assert_eq!(authenticated["type"], "authenticated"); + assert_eq!(authenticated["session_secret"], "must-not-leak"); + let error = untrusted.unwrap_err().to_string(); + assert!(error.contains("no authenticated CLI session matches coordinator")); + assert!(!error.contains("must-not-leak")); +} + +#[test] +fn node_revoke_reports_scoped_credential_revocation() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"revoke_node_credential""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""actor_user":"user""#)); + assert!(line.contains(r#""node":"node-a""#)); + stream + .write_all( + br#"{"type":"node_credential_revoked","node":"node-a","tenant":"tenant","project":"project","actor":"user","descriptor_removed":true,"queued_assignments_removed":2}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let temp = tempfile::tempdir().unwrap(); + let revoked = node_revoke_report( + NodeRevokeArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + node: "node-a".to_owned(), + yes: true, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(revoked["command"], "node revoke"); + assert_eq!(revoked["node"], "node-a"); + assert_eq!(revoked["credential_revoked"], true); + assert_eq!(revoked["descriptor_removed"], true); + assert_eq!(revoked["queued_assignments_removed"], 2); + assert_eq!(revoked["node_credentials_separate_from_user_session"], true); +} + +#[test] +fn admin_status_and_suspend_use_public_coordinator_api() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for (expected, response) in [ + ( + "admin_status", + r#"{"type":"admin_status","tenant":"tenant","actor":"admin","suspended":false,"safe_default":"read_only"}"#, + ), + ( + "suspend_tenant", + r#"{"type":"tenant_suspended","tenant":"tenant","actor":"admin","policy":{"tenant":"tenant","name":"tenant:suspended","digest":"sha256:suspension"}}"#, + ), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(&format!(r#""type":"{expected}""#))); + assert!( + line.contains(r#""tenant":"admin-tenant""#) + || line.contains(r#""tenant":"tenant""#) + ); + assert!(line.contains(r#""actor_user":"admin""#)); + assert!(!line.contains(r#""admin_token""#)); + let wire: Value = serde_json::from_str(&line).unwrap(); + let payload = &wire["payload"]; + let tenant = payload["tenant"].as_str().unwrap(); + let actor_user = payload["actor_user"].as_str().unwrap(); + let target_tenant = payload["target_tenant"].as_str().unwrap_or(tenant); + let admin_nonce = payload["admin_nonce"].as_str().unwrap(); + let issued_at_epoch_seconds = payload["issued_at_epoch_seconds"].as_u64().unwrap(); + assert_eq!( + payload["admin_proof"], + disasmer_core::admin_request_proof( + "admin-token", + expected, + tenant, + actor_user, + target_tenant, + admin_nonce, + issued_at_epoch_seconds, + ) + .to_string() + ); + if expected == "suspend_tenant" { + assert!(line.contains(r#""target_tenant":"tenant""#)); + } + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "admin-tenant".to_owned(), + project: "project".to_owned(), + user: "admin".to_owned(), + json: false, + }; + + let status = admin_status_report(AdminStatusArgs { + scope: scope.clone(), + admin_token: Some("admin-token".to_owned()), + }) + .unwrap(); + let suspended = admin_suspend_tenant_report(AdminSuspendTenantArgs { + scope, + target_tenant: Some("tenant".to_owned()), + admin_token: Some("admin-token".to_owned()), + yes: true, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(status["command"], "admin status"); + assert_eq!(status["safe_default"], "read_only"); + assert_eq!(status["private_website_required"], false); + assert_eq!(status["suspended"], false); + assert_eq!(suspended["command"], "admin suspend-tenant"); + assert_eq!(suspended["tenant"], "tenant"); + assert_eq!(suspended["actor_tenant"], "admin-tenant"); + assert_eq!(suspended["suspended"], true); + assert_eq!(suspended["private_website_required"], false); +} + +#[test] +fn admin_commands_require_explicit_admin_token_for_coordinator_requests() { + let error = admin_status_report(AdminStatusArgs { + scope: CliScopeArgs { + coordinator: Some("127.0.0.1:9".to_owned()), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "admin".to_owned(), + json: false, + }, + admin_token: None, + }) + .unwrap_err(); + + assert!(error.to_string().contains("--admin-token")); +} + +#[test] +fn debug_attach_reports_public_authorization() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"debug_attach""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""actor_user":"user""#)); + assert!(line.contains(r#""process":"vp""#)); + stream + .write_all( + br#"{"type":"debug_attach","process":"vp","actor":"user","authorization":{"allowed":true,"reason":"debug attach authorized for project"},"audit_event":{"tenant":"tenant","project":"project","process":"vp","task":null,"actor":"user","operation":"debug_attach","allowed":true,"reason":"debug attach authorized for project","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024},"charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = debug_attach_report_with_dap( + DebugAttachArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + process: "vp".to_owned(), + }, + "/tmp/disasmer-debug-dap-test".to_owned(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["command"], "debug attach"); + assert_eq!(report["process"], "vp"); + assert_eq!(report["authorized"], true); + assert_eq!( + report["authorization"]["reason"], + "debug attach authorized for project" + ); + assert_eq!(report["audit_event"]["operation"], "debug_attach"); + assert_eq!(report["charged_debug_read_bytes"], 1024); + assert_eq!(report["used_debug_read_bytes"], 1024); + assert_eq!(report["debug_reads_quota_limited"], true); + assert_eq!(report["private_website_required"], false); +} + +#[test] +fn user_control_commands_use_authenticated_envelope_with_stored_cli_session() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for (expected, response) in [ + ( + "list_processes", + br#"{"type":"process_statuses","processes":[{"process":"vp","state":"running","connected_nodes":[],"coordinator_epoch":42}],"actor":"user-session"}"#.as_slice(), + ), + ( + "list_task_events", + br#"{"type":"task_events","events":[]}"#.as_slice(), + ), + ( + "list_processes", + br#"{"type":"process_statuses","processes":[{"process":"vp","state":"running","connected_nodes":[],"coordinator_epoch":42}],"actor":"user-session"}"#.as_slice(), + ), + ( + "start_process", + br#"{"type":"process_started","process":"vp","epoch":42}"#.as_slice(), + ), + ( + "cancel_process", + br#"{"type":"process_cancellation_requested","process":"vp","cancelled_tasks":[],"affected_nodes":[]}"#.as_slice(), + ), + ( + "abort_process", + br#"{"type":"process_aborted","process":"vp","aborted_tasks":[],"affected_nodes":[]}"#.as_slice(), + ), + ( + "restart_task", + br#"{"type":"task_restart","process":"vp","task":"task-a","actor":"user-session","accepted":false,"clean_boundary_available":false,"active_task":false,"completed_event_observed":false,"requires_whole_process_restart":true,"message":"restart requires checkpoint","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024,"audit_event":{"tenant":"tenant-session","project":"project-session","process":"vp","task":"task-a","actor":"user-session","operation":"restart_task","allowed":true,"reason":"restart requires checkpoint","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}}"#.as_slice(), + ), + ( + "create_artifact_download_link", + br#"{"type":"artifact_download_link","link":{"artifact":"app.txt","source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant-session/project-session/vp/app.txt","scoped_token_digest":"sha256:token","expires_at_epoch_seconds":60,"tenant":"tenant-session","project":"project-session","process":"vp","actor":{"User":"user-session"},"max_bytes":2048,"policy_context_digest":"sha256:policy"}}"#.as_slice(), + ), + ( + "debug_attach", + br#"{"type":"debug_attach","process":"vp","actor":"user-session","authorization":{"allowed":true,"reason":"debug attach authorized for project"},"audit_event":{"tenant":"tenant-session","project":"project-session","process":"vp","task":null,"actor":"user-session","operation":"debug_attach","allowed":true,"reason":"debug attach authorized for project","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024},"charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}"#.as_slice(), + ), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"coordinator_request""#)); + assert!(line.contains(r#""type":"authenticated""#)); + assert!(line.contains(r#""session_secret":"control-cli-session-secret""#)); + assert!(line.contains(&format!(r#""type":"{expected}""#))); + assert!(!line.contains("ignored-tenant")); + assert!(!line.contains("ignored-project")); + assert!(!line.contains("ignored-user")); + stream.write_all(response).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + let session = StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("control-cli-session-secret".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }; + let scope = CliScopeArgs { + coordinator: None, + tenant: "ignored-tenant".to_owned(), + project: "ignored-project".to_owned(), + user: "ignored-user".to_owned(), + json: false, + }; + let coordinator_scope = CliScopeArgs { + coordinator: Some(addr), + ..scope.clone() + }; + + process_status_report_with_session( + ProcessStatusArgs { + scope: scope.clone(), + process: "vp".to_owned(), + }, + Some(&session), + ) + .unwrap(); + process_list_report_with_session( + ProcessListArgs { + scope: scope.clone(), + }, + Some(&session), + ) + .unwrap(); + process_restart_report_with_session( + ProcessRestartArgs { + scope: scope.clone(), + process: "vp".to_owned(), + yes: true, + }, + Some(&session), + ) + .unwrap(); + process_cancel_report_with_session( + ProcessCancelArgs { + scope: scope.clone(), + process: "vp".to_owned(), + node: None, + task: None, + yes: true, + }, + Some(&session), + ) + .unwrap(); + process_abort_report_with_session( + ProcessAbortArgs { + scope: scope.clone(), + process: "vp".to_owned(), + yes: true, + }, + Some(&session), + ) + .unwrap(); + task_restart_report_with_session( + TaskRestartArgs { + scope: coordinator_scope.clone(), + task: "task-a".to_owned(), + process: "vp".to_owned(), + yes: true, + }, + Some(&session), + ) + .unwrap(); + artifact_download_report_with_session( + ArtifactDownloadArgs { + scope: coordinator_scope.clone(), + artifact: "app.txt".to_owned(), + to: None, + max_bytes: 2048, + }, + Some(&session), + ) + .unwrap(); + debug_attach_report_with_dap_and_session( + DebugAttachArgs { + scope: coordinator_scope, + process: "vp".to_owned(), + }, + "/tmp/disasmer-debug-dap-test".to_owned(), + Some(&session), + ) + .unwrap(); + server.join().unwrap(); +} + +#[test] +fn human_report_is_text_not_json() { + let report = json!({ + "command": "doctor", + "status": "ok", + "coordinator": "127.0.0.1:9443", + "next_actions": ["disasmer login --browser", "disasmer project init"], + }); + let human = human_report(&report); + + assert!(!human.trim_start().starts_with('{')); + assert!(human.contains("Disasmer doctor")); + assert!(human.contains("status: ok")); + assert!(human.contains("coordinator: 127.0.0.1:9443")); + assert!(human.contains("disasmer login --browser")); +} + +#[test] +fn project_init_select_and_status_use_local_project_config() { + let temp = tempfile::tempdir().unwrap(); + + let init = project_init_report( + ProjectInitArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "ignored".to_owned(), + user: "user".to_owned(), + json: false, + }, + new_project: "project-a".to_owned(), + name: "Project A".to_owned(), + yes: true, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(init["command"], "project init"); + assert_eq!(init["source"], "local_project_config"); + assert_eq!(init["project_config_written"], true); + assert_eq!( + init["current_directory_link"]["config_format"], + "disasmer_project_config_v1" + ); + assert_eq!( + init["current_directory_link"]["links_current_directory"], + true + ); + assert_eq!( + init["current_directory_link"]["writes_current_directory_only"], + true + ); + assert_eq!(init["safe_defaults"]["project"], "project-a"); + assert_eq!(init["safe_defaults"]["tenant"], "tenant"); + assert_eq!(init["safe_defaults"]["browser_interaction_required"], false); + assert_eq!(init["coordinator_create_before_local_write"], false); + let rendered = human_report(&init); + assert!(rendered.contains("current directory linked: true")); + assert!(rendered.contains("current directory config:")); + + let config = read_project_config(temp.path()).unwrap().unwrap(); + assert_eq!(config.project, "project-a"); + assert_eq!(config.tenant, "tenant"); + + let selected = project_select_report( + ProjectSelectArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "ignored".to_owned(), + user: "user".to_owned(), + json: false, + }, + selected_project: "project-b".to_owned(), + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(selected["command"], "project select"); + assert_eq!( + read_project_config(temp.path()).unwrap().unwrap().project, + "project-b" + ); + + let status = project_status_report( + ProjectStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(status["command"], "project status"); + assert_eq!(status["project_identity"]["project"], "project-b"); + assert_eq!(status["project_identity"]["tenant"], "tenant"); + assert_eq!(status["active_process"], "unknown_without_coordinator"); + assert_eq!(status["attached_nodes"]["checked"], false); +} + +#[test] +fn project_init_uses_public_create_before_writing_local_config() { + let temp_success = tempfile::tempdir().unwrap(); + let temp_rejected = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for index in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"create_project""#)); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""actor_user":"user-live""#)); + match index { + 0 => { + assert!(line.contains(r#""project":"project-created""#)); + stream + .write_all( + br#"{"type":"project_created","project":{"id":"project-created","tenant":"tenant-live","name":"Created Project"},"actor":"user-live"}"#, + ) + .unwrap(); + } + 1 => { + assert!(line.contains(r#""project":"foreign-project""#)); + stream + .write_all( + br#"{"type":"error","message":"project id is outside the signed-in tenant scope"}"#, + ) + .unwrap(); + } + _ => unreachable!(), + } + stream.write_all(b"\n").unwrap(); + } + }); + + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant-live".to_owned(), + project: "ignored".to_owned(), + user: "user-live".to_owned(), + json: false, + }; + let created = project_init_report( + ProjectInitArgs { + scope: scope.clone(), + new_project: "project-created".to_owned(), + name: "Created Project".to_owned(), + yes: true, + }, + temp_success.path().to_path_buf(), + ) + .unwrap(); + + assert_eq!(created["command"], "project init"); + assert_eq!(created["source"], "public_coordinator_api"); + assert_eq!(created["coordinator_create_before_local_write"], true); + assert_eq!( + created["project_config_write_after_coordinator_acceptance"], + true + ); + assert_eq!(created["coordinator_session_requests"], 1); + assert_eq!( + created["created_or_linked_project"]["id"], + "project-created" + ); + assert_eq!( + created["current_directory_link"]["links_current_directory"], + true + ); + assert_eq!( + created["safe_defaults"]["browser_interaction_required"], + false + ); + assert_eq!(created["private_website_required"], false); + assert_eq!( + read_project_config(temp_success.path()) + .unwrap() + .unwrap() + .project, + "project-created" + ); + + let rejected = project_init_report( + ProjectInitArgs { + scope, + new_project: "foreign-project".to_owned(), + name: "Foreign Project".to_owned(), + yes: true, + }, + temp_rejected.path().to_path_buf(), + ) + .unwrap_err(); + server.join().unwrap(); + + assert!(rejected.to_string().contains("tenant scope")); + assert!(read_project_config(temp_rejected.path()).unwrap().is_none()); +} + +#[test] +fn project_list_and_select_use_public_api_without_website() { + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-live".to_owned(), + project: "project-original".to_owned(), + user: "user-live".to_owned(), + coordinator: None, + }, + ) + .unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for index in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""actor_user":"user-live""#)); + match index { + 0 => { + assert!(line.contains(r#""type":"list_projects""#)); + stream + .write_all( + br#"{"type":"projects","projects":[{"id":"project-a","tenant":"tenant-live","name":"Project A"}],"actor":"user-live"}"#, + ) + .unwrap(); + } + 1 => { + assert!(line.contains(r#""type":"select_project""#)); + assert!(line.contains(r#""project":"project-a""#)); + stream + .write_all( + br#"{"type":"project_selected","project":{"id":"project-a","tenant":"tenant-live","name":"Project A"},"actor":"user-live"}"#, + ) + .unwrap(); + } + 2 => { + assert!(line.contains(r#""type":"select_project""#)); + assert!(line.contains(r#""project":"project-b""#)); + stream + .write_all( + br#"{"type":"error","message":"project is outside the signed-in tenant scope"}"#, + ) + .unwrap(); + } + _ => unreachable!(), + } + stream.write_all(b"\n").unwrap(); + } + }); + + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant-live".to_owned(), + project: "project".to_owned(), + user: "user-live".to_owned(), + json: false, + }; + let list = project_list_report( + ProjectListArgs { + scope: scope.clone(), + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(list["command"], "project list"); + assert_eq!(list["source"], "public_coordinator_api"); + assert_eq!(list["project_count"], 1); + assert_eq!(list["projects"][0]["id"], "project-a"); + assert_eq!(list["private_website_required"], false); + assert_eq!(list["coordinator_session_requests"], 1); + + let selected = project_select_report( + ProjectSelectArgs { + scope: scope.clone(), + selected_project: "project-a".to_owned(), + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(selected["command"], "project select"); + assert_eq!(selected["source"], "public_coordinator_api"); + assert_eq!(selected["selected_project"]["id"], "project-a"); + assert_eq!(selected["project_config_written"], true); + assert_eq!(selected["private_website_required"], false); + assert_eq!( + read_project_config(temp.path()).unwrap().unwrap().project, + "project-a" + ); + + let rejected = project_select_report( + ProjectSelectArgs { + scope, + selected_project: "project-b".to_owned(), + }, + temp.path().to_path_buf(), + ) + .unwrap_err(); + server.join().unwrap(); + + assert!(rejected.to_string().contains("tenant scope")); + assert_eq!( + read_project_config(temp.path()).unwrap().unwrap().project, + "project-a" + ); +} + +#[test] +fn project_list_uses_authenticated_envelope_with_stored_cli_session() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("project-list-session-secret".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"authenticated""#)); + assert!(line.contains(r#""session_secret":"project-list-session-secret""#)); + assert!(line.contains(r#""type":"list_projects""#)); + assert!(!line.contains(r#""actor_user":"user-session""#)); + stream + .write_all( + br#"{"type":"projects","projects":[{"id":"project-session","tenant":"tenant-session","name":"Session Project"}],"actor":"user-session"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = project_list_report( + ProjectListArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "ignored-tenant".to_owned(), + project: "ignored-project".to_owned(), + user: "ignored-user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["source"], "public_coordinator_api"); + assert_eq!(report["tenant"], "tenant-session"); + assert_eq!(report["user"], "user-session"); + assert_eq!(report["projects"][0]["id"], "project-session"); +} + +#[test] +fn project_status_queries_public_coordinator_state() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line.contains("\"type\":\"list_node_descriptors\"") { + assert!(line.contains("\"project\":\"project-live\"")); + stream + .write_all( + br#"{"type":"node_descriptors","descriptors":[{"id":"node-a","online":true}],"actor":"user"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + } else if line.contains("\"type\":\"list_task_events\"") { + assert!(line.contains("\"project\":\"project-live\"")); + stream + .write_all( + br#"{"type":"task_events","events":[{"process":"vp-live","task":"task-a"}]}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + } else if line.contains("\"type\":\"list_processes\"") { + assert!(line.contains("\"project\":\"project-live\"")); + stream + .write_all( + br#"{"type":"process_statuses","processes":[{"process":"vp-live","state":"running","connected_nodes":[],"coordinator_epoch":42}],"actor":"user"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + } else { + panic!("unexpected coordinator request: {line}"); + } + } + }); + + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-live".to_owned(), + project: "project-live".to_owned(), + user: "user".to_owned(), + coordinator: Some(addr.clone()), + }, + ) + .unwrap(); + + let status = project_status_report( + ProjectStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(status["coordinator"], addr); + assert_eq!(status["project_identity"]["project"], "project-live"); + assert_eq!(status["attached_nodes"]["checked"], true); + assert_eq!(status["attached_nodes"]["count"], 1); + assert_eq!(status["attached_nodes"]["online"], 1); + assert_eq!(status["active_process"], "vp-live"); + assert_eq!( + status["quota_posture"]["current_usage"]["observed_task_events"], + 1 + ); +} + +#[test] +fn project_status_uses_authenticated_client_session_for_nodes_and_events() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("status-session-secret".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + let server = std::thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let wire: Value = serde_json::from_str(&line).unwrap(); + let payload = &wire["payload"]; + assert_eq!(payload["type"], "authenticated"); + assert_eq!(payload["session_secret"], "status-session-secret"); + assert!(payload["request"].get("tenant").is_none()); + assert!(payload["request"].get("project").is_none()); + assert!(payload["request"].get("actor_user").is_none()); + match payload["request"]["type"].as_str().unwrap() { + "list_node_descriptors" => stream + .write_all( + br#"{"type":"node_descriptors","descriptors":[{"id":"node-session","online":true}],"actor":"user-session"}"#, + ) + .unwrap(), + "list_task_events" => stream + .write_all( + br#"{"type":"task_events","events":[{"process":"vp-session","task":"task-session"}]}"#, + ) + .unwrap(), + "list_processes" => stream + .write_all( + br#"{"type":"process_statuses","processes":[{"process":"vp-session","state":"running","connected_nodes":[],"coordinator_epoch":42}],"actor":"user-session"}"#, + ) + .unwrap(), + operation => panic!("unexpected coordinator operation: {operation}"), + } + stream.write_all(b"\n").unwrap(); + } + }); + + let status = project_status_report( + ProjectStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "ignored-tenant".to_owned(), + project: "ignored-project".to_owned(), + user: "ignored-user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(status["coordinator"], addr); + assert_eq!(status["project_identity"]["tenant"], "tenant-session"); + assert_eq!(status["project_identity"]["project"], "project-session"); + assert_eq!(status["project_identity"]["user"], "user-session"); + assert_eq!(status["attached_nodes"]["count"], 1); + assert_eq!(status["active_process"], "vp-session"); +} + +#[test] +fn quota_status_uses_project_config_and_generic_public_limits() { + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-quota".to_owned(), + project: "project-quota".to_owned(), + user: "user".to_owned(), + coordinator: None, + }, + ) + .unwrap(); + + let status = quota_status_report( + QuotaStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + + assert_eq!(status["command"], "quota status"); + assert_eq!(status["tenant"], "tenant-quota"); + assert_eq!(status["project"], "project-quota"); + assert_eq!(status["current_usage"]["attached_nodes"], 0); + assert_eq!(status["limits"]["configured"], false); + assert_eq!(status["quota_configuration_source"], "unavailable_offline"); + assert!(status["quota_tier"].is_null()); + let rendered = human_report(&status); + assert!(!rendered.contains("quota tier:")); + let forbidden_tier = ["free", "tier"].join(" "); + assert!(!rendered.to_ascii_lowercase().contains(&forbidden_tier)); + assert_eq!( + status["next_blocked_action"]["action"], + "node_work_requires_online_attached_node" + ); + assert_eq!( + status["next_blocked_action"]["machine_error"]["category"], + "capability" + ); + assert_eq!( + status["next_blocked_action"]["machine_error"]["stable_exit_code"], + 24 + ); +} + +#[test] +fn quota_status_queries_public_coordinator_usage() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line.contains("\"type\":\"list_node_descriptors\"") { + assert!(line.contains("\"tenant\":\"tenant-live\"")); + stream + .write_all( + br#"{"type":"node_descriptors","descriptors":[{"id":"node-a","online":true},{"id":"node-b","online":false}],"actor":"user"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + } else if line.contains("\"type\":\"list_task_events\"") { + assert!(line.contains("\"project\":\"project-live\"")); + stream + .write_all( + br#"{"type":"task_events","events":[{"process":"vp-live","task":"task-a"},{"process":"vp-live","task":"task-b"}]}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + } else if line.contains("\"type\":\"quota_status\"") { + stream + .write_all( + br#"{"type":"quota_status","tenant":"tenant-live","project":"project-live","actor":"user","policy_label":"community tier","limits":{"limits":{"Spawn":64,"ArtifactDownloadBytes":268435456}},"window_seconds":{"Spawn":60,"ArtifactDownloadBytes":86400},"usage":{"Spawn":2,"ArtifactDownloadBytes":123},"window_started_epoch_seconds":{"Spawn":120,"ArtifactDownloadBytes":0}}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + } else { + panic!("unexpected coordinator request: {line}"); + } + } + }); + + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-live".to_owned(), + project: "project-live".to_owned(), + user: "user".to_owned(), + coordinator: Some(addr.clone()), + }, + ) + .unwrap(); + + let status = quota_status_report( + QuotaStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(status["coordinator"], addr); + assert_eq!(status["current_usage"]["attached_nodes"], 2); + assert_eq!(status["current_usage"]["online_nodes"], 1); + assert_eq!(status["current_usage"]["observed_task_events"], 2); + assert_eq!(status["current_usage"]["scoped_resource_usage"]["Spawn"], 2); + assert_eq!(status["limits"]["Spawn"], 64); + assert_eq!(status["limits"]["ArtifactDownloadBytes"], 268_435_456); + assert_eq!(status["window_seconds"]["Spawn"], 60); + assert_eq!(status["quota_configuration_source"], "coordinator"); + assert_eq!(status["quota_tier"], "community tier"); + assert!(status["next_blocked_action"].is_null()); + assert_eq!(status["task_events"]["response"]["type"], "task_events"); +} + +#[test] +fn process_task_log_and_artifact_reports_summarize_task_events() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let response = concat!( + r#"{"type":"task_events","events":["#, + r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-a","task":"task-a","placement":{"node":"node-a","score":120,"reasons":["warm environment cache","source snapshot already local"]},"terminal_state":"completed","status_code":0,"stdout_bytes":12,"stderr_bytes":0,"stdout_tail":"ok","stderr_tail":"","stdout_truncated":false,"stderr_truncated":false,"artifact_path":"/vfs/artifacts/app.txt","artifact_digest":"sha256:artifact","artifact_size_bytes":12},"#, + r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-b","task":"task-b","terminal_state":"failed","status_code":1,"stdout_bytes":0,"stderr_bytes":7,"stdout_tail":"","stderr_tail":"boom","stdout_truncated":false,"stderr_truncated":false,"artifact_path":null,"artifact_digest":null,"artifact_size_bytes":null},"#, + r#"{"tenant":"tenant","project":"project","process":"vp","node":"node-c","task":"task-c","terminal_state":"failed","status_code":1,"stdout_bytes":0,"stderr_bytes":71,"stdout_tail":"","stderr_tail":"source snapshot unavailable and direct connectivity unavailable","stdout_truncated":false,"stderr_truncated":false,"artifact_path":null,"artifact_digest":null,"artifact_size_bytes":null}"#, + r#"]}"# + ); + for _ in 0..5 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line.contains("\"type\":\"list_processes\"") { + stream + .write_all( + br#"{"type":"process_statuses","processes":[{"process":"vp","state":"running","connected_nodes":[],"coordinator_epoch":42}],"actor":"user"}"#, + ) + .unwrap(); + } else { + assert!(line.contains("\"type\":\"list_task_events\"")); + assert!(line.contains("\"process\":\"vp\"")); + stream.write_all(response.as_bytes()).unwrap(); + } + stream.write_all(b"\n").unwrap(); + } + }); + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + + let process = process_status_report(ProcessStatusArgs { + scope: scope.clone(), + process: "vp".to_owned(), + }) + .unwrap(); + let tasks = task_list_report(TaskListArgs { + scope: scope.clone(), + process: Some("vp".to_owned()), + }) + .unwrap(); + let logs = logs_report(LogsArgs { + scope: scope.clone(), + process: Some("vp".to_owned()), + task: Some("task-a".to_owned()), + }) + .unwrap(); + let artifacts = artifact_list_report(ArtifactListArgs { + scope, + process: Some("vp".to_owned()), + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(process["state"], "running"); + assert_eq!(process["current_task_count"], 3); + assert_eq!( + process["current_tasks"][0]["node_placement"]["node"], + "node-a" + ); + assert_eq!( + process["current_tasks"][0]["node_placement"]["reasons"][0], + "warm environment cache" + ); + assert_eq!(tasks["tasks"][0]["node_placement"]["score"], 120); + let rendered_tasks = human_report(&tasks); + assert!(rendered_tasks.contains("placement task-a: node-a")); + assert!(rendered_tasks.contains("source snapshot already local")); + assert_eq!(tasks["tasks"][1]["failure_reason"], "boom"); + assert_eq!(tasks["tasks"][1]["machine_error"]["category"], "program"); + assert_eq!(tasks["tasks"][1]["machine_error"]["stable_exit_code"], 27); + assert_eq!( + tasks["tasks"][2]["locality_failure"]["affected_data"], + "source_snapshot" + ); + assert_eq!( + tasks["tasks"][2]["locality_failure"]["coordinator_bulk_relay_used"], + false + ); + assert_eq!( + tasks["tasks"][2]["machine_error"]["category"], + "connectivity" + ); + assert_eq!(tasks["tasks"][2]["machine_error"]["stable_exit_code"], 25); + assert_eq!(tasks["tasks"][2]["machine_error"]["locality_failure"], true); + assert!(tasks["tasks"][2]["machine_error"]["next_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action == "rerun source preparation on an attached node")); + assert!(rendered_tasks.contains("locality task-c: source_snapshot")); + assert!(rendered_tasks.contains("do not rely on coordinator bulk source relay")); + assert_eq!(logs["log_entries"].as_array().unwrap().len(), 1); + assert_eq!(logs["log_entries"][0]["task"], "task-a"); + assert_eq!(logs["log_entries"][0]["stdout_tail"], "ok"); + assert_eq!(artifacts["artifacts"].as_array().unwrap().len(), 1); + assert_eq!(artifacts["artifacts"][0]["artifact"], "app.txt"); + assert_eq!(artifacts["artifacts"][0]["size_bytes"], 12); + assert_eq!(artifacts["artifacts"][0]["known_locations"][0], "node-a"); + assert_eq!(artifacts["default_durable_store_assumed"], false); +} + +#[test] +fn log_and_task_reports_redact_secret_like_values() { + let events = json!({ + "response": { + "type": "task_events", + "events": [{ + "tenant": "tenant", + "project": "project", + "process": "vp", + "node": "node-a", + "task": "task-secret", + "terminal_state": "failed", + "status_code": 1, + "stdout_bytes": 128, + "stderr_bytes": 64, + "stdout_tail": "upload token=abc123 Authorization: Bearer bearer-secret", + "stderr_tail": "failed password=hunter2 access_token=provider-secret", + "stdout_truncated": true, + "stderr_truncated": false + }] + } + }); + + let entries = log_entries(Some(&events), Some("task-secret")); + let entry = &entries.as_array().unwrap()[0]; + assert_eq!( + entry["stdout_tail"], + "upload token=[redacted] Authorization: Bearer [redacted]" + ); + assert_eq!( + entry["stderr_tail"], + "failed password=[redacted] access_token=[redacted]" + ); + assert_eq!(entry["stdout_bytes"], 128); + assert_eq!(entry["stdout_truncated"], true); + assert_eq!(entry["secret_like_values_redacted"], true); + assert_eq!(entry["redacted_fields"][0], "stdout_tail"); + assert_eq!(entry["redacted_fields"][1], "stderr_tail"); + + let tasks = task_summaries(Some(&events)); + let task = &tasks.as_array().unwrap()[0]; + assert_eq!( + task["failure_reason"], + "failed password=[redacted] access_token=[redacted]" + ); + assert_eq!( + task["machine_error"]["message"], + "failed password=[redacted] access_token=[redacted]" + ); + assert!(!serde_json::to_string(&entries) + .unwrap() + .contains("provider-secret")); + assert!(!serde_json::to_string(&tasks).unwrap().contains("hunter2")); +} + +#[test] +fn artifact_download_and_export_reports_expose_safe_session_boundaries() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let download_response = concat!( + r#"{"type":"artifact_download_link","link":{"artifact":"app.txt","artifact_digest":"sha256:8e4c2e339a4a879ce6b1e89da60d5bc8a32d3c84dec737b56eb0e79d45e4432c","artifact_size_bytes":9,"source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":2048,"policy_context_digest":"sha256:policy"}}"# + ); + let export_response = concat!( + r#"{"type":"artifact_export_plan","source_node":"node-a","receiver_node":"node-b","artifact_size_bytes":9,"plan":{"transport":"NativeQuic","scope":{"tenant":"tenant","project":"project","process":"vp","object":{"Artifact":"app.txt"},"authorization_subject":"artifact-export:node-a-to-node-b"},"source":{"node":"node-a","advertised_addr":"quic://node-a","public_key_fingerprint":"sha256:source"},"destination":{"node":"node-b","advertised_addr":"quic://node-b","public_key_fingerprint":"sha256:destination"},"authorization_digest":"sha256:auth","coordinator_assisted_rendezvous":true,"coordinator_bulk_relay_allowed":false}}"# + ); + let export_download_response = concat!( + r#"{"type":"artifact_download_link","link":{"artifact":"app.txt","artifact_digest":"sha256:8e4c2e339a4a879ce6b1e89da60d5bc8a32d3c84dec737b56eb0e79d45e4432c","artifact_size_bytes":9,"source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:export-token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":67108864,"policy_context_digest":"sha256:policy"}}"# + ); + let export_stream_response = concat!( + r#"{"type":"artifact_download_stream","link":{"artifact":"app.txt","artifact_digest":"sha256:8e4c2e339a4a879ce6b1e89da60d5bc8a32d3c84dec737b56eb0e79d45e4432c","artifact_size_bytes":9,"source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:export-token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":67108864,"policy_context_digest":"sha256:policy"},"streamed_bytes":9,"charged_download_bytes":9,"content_bytes_available":true,"content_offset":0,"content_eof":true,"content_base64":"YXBwLWJ5dGVz","content_source":"retaining_node_reverse_stream"}"# + ); + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"create_artifact_download_link""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""artifact":"app.txt""#)); + assert!(line.contains(r#""max_bytes":2048"#)); + stream.write_all(download_response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + for (expected, phase) in [ + ("export_artifact_to_node", "export-plan"), + ("create_artifact_download_link", "export-download"), + ("open_artifact_download_stream", "export-stream"), + ] { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(&format!(r#""type":"{expected}""#))); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""artifact":"app.txt""#)); + if expected == "create_artifact_download_link" { + assert_eq!(phase, "export-download"); + assert!(line.contains(&format!( + r#""max_bytes":{}"#, + DEFAULT_ARTIFACT_EXPORT_MAX_BYTES + ))); + stream + .write_all(export_download_response.as_bytes()) + .unwrap(); + } else if expected == "export_artifact_to_node" { + assert!(line.contains(r#""receiver_node":"node-b""#)); + stream.write_all(export_response.as_bytes()).unwrap(); + } else { + assert!(line.contains(r#""chunk_bytes":9"#)); + assert!(line.contains(r#""token_digest":"sha256:export-token""#)); + stream.write_all(export_stream_response.as_bytes()).unwrap(); + } + stream.write_all(b"\n").unwrap(); + } + }); + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + + let temp = tempfile::tempdir().unwrap(); + let export_path = temp.path().join("exports/app.txt"); + let download = artifact_download_report(ArtifactDownloadArgs { + scope: scope.clone(), + artifact: "app.txt".to_owned(), + to: None, + max_bytes: 2048, + }) + .unwrap(); + let export = artifact_export_report(ArtifactExportArgs { + scope, + artifact: "app.txt".to_owned(), + to: export_path.clone(), + receiver_node: "node-b".to_owned(), + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!( + download["download_session"]["status"], + "download_link_issued" + ); + assert_eq!(download["download_session"]["tenant"], "tenant"); + assert_eq!(download["download_session"]["project"], "project"); + assert_eq!(download["download_session"]["process"], "vp"); + assert_eq!( + download["download_session"]["source"]["RetainedNode"], + "node-a" + ); + assert_eq!(download["download_session"]["max_bytes"], 2048); + assert_eq!( + download["download_session"]["token_material_returned"], + false + ); + assert_eq!( + download["download_session"]["scoped_token_digest_present"], + true + ); + assert_eq!(download["download_session"]["authorization_required"], true); + assert_eq!(download["download_session"]["short_lived"], true); + assert_eq!(download["download_session"]["guessable_public_url"], false); + assert_eq!(download["download_session"]["cross_tenant_usable"], false); + assert_eq!( + download["download_session"]["unauthorized_project_usable"], + false + ); + assert_eq!( + download["download_session"]["default_durable_store_assumed"], + false + ); + assert_eq!( + download["grant_disclosures"][0]["grant"], + "artifact_download" + ); + assert_eq!( + download["grant_disclosures"][0]["coordinator_policy_limited"], + true + ); + assert_eq!( + download["grant_disclosures"][0]["scoped_token_digest_present"], + true + ); + assert_eq!( + download["grant_disclosures"][0]["token_material_returned"], + false + ); + assert_eq!( + download["grant_disclosures"][0]["guessable_public_url"], + false + ); + assert_eq!( + download["grant_disclosures"][0]["cross_tenant_reuse_allowed"], + false + ); + assert_eq!( + download["grant_disclosures"][0]["unauthorized_project_reuse_allowed"], + false + ); + let rendered_download = human_report(&download); + assert!(rendered_download.contains("grant artifact_download")); + assert!(rendered_download.contains("policy-limited")); + + assert_eq!(export["export_plan"]["status"], "transfer_plan_created"); + assert_eq!(export["export_plan"]["source_node"], "node-a"); + assert_eq!(export["export_plan"]["receiver_node"], "node-b"); + assert_eq!(export["export_plan"]["artifact"], "app.txt"); + assert_eq!( + export["export_plan"]["local_path"].as_str().unwrap(), + export_path.to_string_lossy().as_ref() + ); + assert_eq!(export["export_plan"]["artifact_size_bytes"], 9); + assert_eq!(export["export_plan"]["local_bytes_written_by_cli"], true); + assert_eq!( + export["export_plan"]["local_export_status"], + "local_bytes_written" + ); + assert_eq!(export["export_plan"]["bytes_written"], 9); + assert_eq!( + export["local_export"]["stream"]["content_material_returned_in_report"], + false + ); + assert_eq!( + export["local_export"]["download_session"]["scoped_token_digest_present"], + true + ); + assert_eq!( + export["local_export"]["download_session"]["guessable_public_url"], + false + ); + assert_eq!(export["grant_disclosures"][0]["grant"], "artifact_download"); + assert_eq!( + export["grant_disclosures"][0]["cross_tenant_reuse_allowed"], + false + ); + assert_eq!( + std::fs::read(&export_path).unwrap().as_slice(), + b"app-bytes" + ); + assert_eq!( + export["export_plan"]["default_durable_store_assumed"], + false + ); + assert_eq!( + export["export_plan"]["coordinator_bulk_relay_allowed"], + false + ); + assert_eq!(export["export_plan"]["authorization_digest_present"], true); +} + +#[test] +fn artifact_download_to_path_streams_and_verifies_published_digest() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut link_request = String::new(); + reader.read_line(&mut link_request).unwrap(); + assert!(link_request.contains(r#""type":"create_artifact_download_link""#)); + stream + .write_all( + br#"{"type":"artifact_download_link","link":{"artifact":"app.txt","artifact_digest":"sha256:8e4c2e339a4a879ce6b1e89da60d5bc8a32d3c84dec737b56eb0e79d45e4432c","artifact_size_bytes":9,"source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:download-token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":2048,"policy_context_digest":"sha256:policy"}}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + + let mut stream_request = String::new(); + reader.read_line(&mut stream_request).unwrap(); + assert!(stream_request.contains(r#""type":"open_artifact_download_stream""#)); + assert!(stream_request.contains(r#""chunk_bytes":9"#)); + stream + .write_all( + br#"{"type":"artifact_download_stream","link":{"artifact":"app.txt","artifact_digest":"sha256:8e4c2e339a4a879ce6b1e89da60d5bc8a32d3c84dec737b56eb0e79d45e4432c","artifact_size_bytes":9,"source":{"RetainedNode":"node-a"},"url_path":"/artifacts/tenant/project/vp/app.txt","scoped_token_digest":"sha256:download-token","expires_at_epoch_seconds":60,"tenant":"tenant","project":"project","process":"vp","actor":{"User":"user"},"max_bytes":2048,"policy_context_digest":"sha256:policy"},"streamed_bytes":9,"charged_download_bytes":9,"content_bytes_available":true,"content_offset":0,"content_eof":true,"content_base64":"YXBwLWJ5dGVz","content_source":"retaining_node_reverse_stream"}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("app.txt"); + + let report = artifact_download_report(ArtifactDownloadArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + artifact: "app.txt".to_owned(), + to: Some(destination.clone()), + max_bytes: 2048, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["local_download"]["status"], "local_bytes_written"); + assert_eq!(report["local_download"]["bytes_written"], 9); + assert_eq!( + report["local_download"]["verified_digest"], + "sha256:8e4c2e339a4a879ce6b1e89da60d5bc8a32d3c84dec737b56eb0e79d45e4432c" + ); + assert_eq!(fs::read(destination).unwrap(), b"app-bytes"); +} + +#[test] +fn artifact_failure_reports_apply_stable_exit_codes() { + let mut download = json!({ + "command": "artifact download", + "download_session": artifact_download_session_summary(&json!({ + "type": "error", + "message": "artifact download unauthorized for project", + })), + }); + assert_eq!(apply_command_report_exit_code(&mut download), Some(21)); + assert_eq!( + download["download_session"]["machine_error"]["category"], + "authorization" + ); + assert_eq!( + download["download_session"]["machine_error"]["process_exit_code_applied"], + true + ); + + let mut export = json!({ + "command": "artifact export", + "export_plan": artifact_export_plan_summary( + &json!({ + "type": "error", + "message": "direct connectivity unavailable for artifact export", + }), + Path::new("dist/app.txt"), + ), + }); + assert_eq!(apply_command_report_exit_code(&mut export), Some(25)); + assert_eq!( + export["export_plan"]["machine_error"]["category"], + "connectivity" + ); + assert_eq!( + export["export_plan"]["machine_error"]["process_exit_code_applied"], + true + ); + + let mut stream = json!({ + "command": "artifact export", + "local_export": { + "stream": artifact_stream_summary(&json!({ + "type": "error", + "message": "quota unavailable: resource limit exceeded for artifact_download_bytes", + })), + }, + }); + assert_eq!(apply_command_report_exit_code(&mut stream), Some(22)); + assert_eq!( + stream["local_export"]["stream"]["machine_error"]["category"], + "quota" + ); + assert_eq!( + stream["local_export"]["stream"]["machine_error"]["process_exit_code_applied"], + true + ); +} + +#[test] +fn process_restart_cancel_and_abort_reports_expose_control_boundaries() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let restart_response = r#"{"type":"process_started","process":"vp","epoch":42}"#; + let cancel_response = r#"{"type":"process_cancellation_requested","process":"vp","cancelled_tasks":[{"process":"vp","task":"compile-linux","node":"node-a"},{"process":"vp","task":"link-linux","node":"node-b"}],"affected_nodes":["node-a","node-b"]}"#; + let abort_response = + r#"{"type":"process_aborted","process":"vp","aborted_tasks":[],"affected_nodes":[]}"#; + for expected in ["start_process", "cancel_process", "abort_process"] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(&format!(r#""type":"{expected}""#))); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""process":"vp""#)); + if expected == "start_process" { + stream.write_all(restart_response.as_bytes()).unwrap(); + } else if expected == "cancel_process" { + assert!(line.contains(r#""actor_user":"user""#)); + stream.write_all(cancel_response.as_bytes()).unwrap(); + } else { + assert!(line.contains(r#""actor_user":"user""#)); + stream.write_all(abort_response.as_bytes()).unwrap(); + } + stream.write_all(b"\n").unwrap(); + } + }); + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + + let restart = process_restart_report(ProcessRestartArgs { + scope: scope.clone(), + process: "vp".to_owned(), + yes: true, + }) + .unwrap(); + let cancel = process_cancel_report(ProcessCancelArgs { + scope: scope.clone(), + process: "vp".to_owned(), + node: None, + task: None, + yes: true, + }) + .unwrap(); + let abort = process_abort_report_with_session( + ProcessAbortArgs { + scope, + process: "vp".to_owned(), + yes: true, + }, + None, + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(restart["restart_request"]["status"], "process_started"); + assert_eq!( + restart["restart_request"]["operation"], + "restart_virtual_process" + ); + assert_eq!(restart["restart_request"]["accepted"], true); + assert_eq!(restart["restart_request"]["process"], "vp"); + assert_eq!(restart["restart_request"]["coordinator_epoch"], 42); + assert_eq!(restart["restart_request"]["requires_confirmation"], false); + assert_eq!(restart["restart_request"]["website_required"], false); + + assert_eq!( + cancel["cancel_request"]["status"], + "process_cancellation_requested" + ); + assert_eq!( + cancel["cancel_request"]["operation"], + "cancel_virtual_process" + ); + assert_eq!(cancel["cancel_request"]["accepted"], true); + assert_eq!(cancel["cancel_request"]["process"], "vp"); + assert_eq!(cancel["cancel_request"]["cancelled_task_count"], 2); + assert_eq!( + cancel["cancel_request"]["cancelled_tasks"][0]["task"], + "compile-linux" + ); + assert_eq!(cancel["cancel_request"]["affected_nodes"][1], "node-b"); + assert_eq!(cancel["cancel_request"]["requires_confirmation"], false); + assert_eq!(cancel["cancel_request"]["website_required"], false); + assert_eq!( + cancel["cancel_request"]["whole_process_cancel_available"], + true + ); + assert_eq!( + cancel["cancel_request"]["node_must_poll_task_control"], + true + ); + assert_eq!(cancel["cancel_request"]["new_task_launches_blocked"], true); + assert_eq!(abort["status"], "aborted"); + assert_eq!(abort["abort_request"]["accepted"], true); + assert_eq!(abort["abort_request"]["forced"], true); + assert_eq!(abort["abort_request"]["cooperative"], false); + assert_eq!(abort["abort_request"]["process_slot_released"], true); +} + +#[test] +fn task_restart_reports_clean_boundary_requirements() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"restart_task""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""actor_user":"user""#)); + assert!(line.contains(r#""process":"vp""#)); + assert!(line.contains(r#""task":"compile-linux""#)); + stream + .write_all( + br#"{"type":"task_restart","process":"vp","task":"compile-linux","actor":"user","accepted":false,"clean_boundary_available":false,"active_task":true,"completed_event_observed":false,"requires_whole_process_restart":true,"message":"selected task is still active; clean task restart requires a captured checkpoint boundary","audit_event":{"tenant":"tenant","project":"project","process":"vp","task":"compile-linux","actor":"user","operation":"restart_task","allowed":true,"reason":"selected task is still active; clean task restart requires a captured checkpoint boundary","charged_debug_read_bytes":1024,"used_debug_read_bytes":1024},"charged_debug_read_bytes":1024,"used_debug_read_bytes":1024}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let report = task_restart_report(TaskRestartArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + task: "compile-linux".to_owned(), + process: "vp".to_owned(), + yes: true, + }) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["command"], "task restart"); + assert_eq!( + report["restart_request"]["operation"], + "restart_selected_task" + ); + assert_eq!(report["restart_request"]["accepted"], false); + assert_eq!(report["restart_request"]["clean_boundary_available"], false); + assert_eq!( + report["restart_request"]["requires_whole_process_restart"], + true + ); + assert_eq!(report["restart_request"]["active_task"], true); + assert_eq!( + report["restart_request"]["audit_event"]["operation"], + "restart_task" + ); + assert_eq!(report["restart_request"]["charged_debug_read_bytes"], 1024); + assert_eq!(report["restart_request"]["used_debug_read_bytes"], 1024); + assert_eq!(report["restart_request"]["debug_reads_quota_limited"], true); + assert_eq!(report["restart_request"]["website_required"], false); + assert_eq!(report["coordinator_session_requests"], 1); +} + +#[test] +fn build_command_reuses_bundle_inspection_without_full_repo_upload() { + let temp = tempfile::tempdir().unwrap(); + let project = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("examples/launch-build-demo"); + let output = temp.path().join("bundle"); + + let report = build_report( + BuildArgs { + project: Some(project), + source_provider: None, + disabled_source_providers: Vec::new(), + output: Some(output.clone()), + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!(report["command"], "build"); + assert_eq!(report["content_addressed"], true); + assert_eq!(report["contains_full_repository_upload"], false); + assert_eq!(report["bundle_artifact"]["task_descriptor_count"], 9); + assert_eq!(report["bundle_artifact"]["entrypoint_count"], 3); + assert!(output.join("module.wasm").is_file()); + assert!(output.join("manifest.json").is_file()); + assert!(output.join("task-descriptors.json").is_file()); + let task_descriptors: Value = + serde_json::from_slice(&fs::read(output.join("task-descriptors.json")).unwrap()).unwrap(); + assert!(task_descriptors + .as_array() + .unwrap() + .iter() + .any(|descriptor| { + descriptor["name"] == "task_add_one" + && descriptor["argument_schema"] == "input : i32" + && descriptor["result_schema"] == "i32" + && descriptor["restart_compatibility_hash"] + .as_str() + .unwrap() + .starts_with("sha256:") + })); + assert!(report["bundle"]["metadata"]["identity"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert!(report["bundle"]["metadata"]["wasm_code"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert_eq!( + report["bundle"]["metadata"]["task_metadata"]["default_entrypoint"], + "build" + ); + assert!(report["bundle"]["metadata"]["task_metadata"]["entrypoints"] + .as_array() + .unwrap() + .iter() + .any(|entrypoint| entrypoint == "build")); + assert!( + !report["bundle"]["metadata"]["task_metadata"]["entrypoints"] + .as_array() + .unwrap() + .iter() + .any(|entrypoint| entrypoint == "release") + ); + assert_eq!( + report["bundle"]["metadata"]["source_metadata"]["transfer_policy"] + ["coordinator_receives_source_bytes_by_default"], + false + ); + assert_eq!( + report["bundle"]["metadata"]["source_metadata"]["transfer_policy"] + ["default_full_repo_tarball"], + false + ); + assert_eq!( + report["bundle"]["metadata"]["debug_metadata"]["dap_virtual_process"], + true + ); + assert_eq!( + report["bundle"]["metadata"]["large_input_policy"]["selected_inputs_are_content_digests"], + true + ); + assert_eq!( + report["bundle"]["metadata"]["large_input_policy"]["selected_input_bytes_included"], + false + ); + assert_eq!( + report["bundle"]["metadata"]["large_input_policy"]["full_repository_bytes_included"], + false + ); + assert_eq!( + report["bundle"]["metadata"]["large_input_policy"]["silent_task_argument_serialization"], + false + ); + assert!( + report["bundle"]["metadata"]["large_input_policy"]["supported_handle_types"] + .as_array() + .unwrap() + .iter() + .any(|handle| handle == "Artifact") + ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"] + ["source_edits_can_restart_from_clean_task_boundary"], + true + ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"]["requires_clean_checkpoint_boundary"], + true + ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"]["compares_task_abi"], + report["bundle"]["metadata"]["task_metadata"]["task_abi"] + ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"] + ["incompatible_changes_require_whole_process_restart"], + true + ); + assert_eq!(report["status"], "built"); + assert_eq!(report["scheduled_work"], false); +} + +#[test] +fn build_blocks_before_schedule_on_missing_environment_reference() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { let _target = env!(\"linux\"); }\n", + ) + .unwrap(); + + let report = build_report( + BuildArgs { + project: Some(temp.path().to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + output: None, + json: false, + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!(report["status"], "blocked_before_schedule"); + assert_eq!(report["scheduled_work"], false); + assert_eq!(report["machine_error"]["category"], "environment"); + assert_eq!(report["diagnostics"][0]["code"], "missing_environment"); +} + +#[test] +fn node_enroll_reports_short_lived_public_api_grant() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"create_node_enrollment_grant""#)); + assert!(line.contains(r#""tenant":"tenant""#)); + assert!(line.contains(r#""project":"project""#)); + assert!(line.contains(r#""actor_user":"user""#)); + assert!(!line.contains(r#""grant":""#)); + assert!(!line.contains("now_epoch_seconds")); + assert!(line.contains(r#""ttl_seconds":300"#)); + stream + .write_all( + br#"{"type":"node_enrollment_grant_created","tenant":"tenant","project":"project","grant":"grant-live","scope":"node:attach","expires_at_epoch_seconds":300}"#, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + }); + + let temp = tempfile::tempdir().unwrap(); + let report = node_enroll_report( + NodeEnrollArgs { + scope: CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + ttl_seconds: 300, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["command"], "node enroll"); + assert_eq!(report["status"], "created"); + assert_eq!(report["private_website_required"], false); + assert_eq!(report["tenant"], "tenant"); + assert_eq!(report["project"], "project"); + assert_eq!(report["user"], "user"); + assert_eq!(report["enrollment_grant"]["grant"], "grant-live"); + assert_eq!(report["enrollment_grant"]["scope"], "node:attach"); + assert_eq!(report["enrollment_grant"]["ttl_seconds"], 300); + assert_eq!(report["enrollment_grant"]["expires_at_epoch_seconds"], 300); + assert_eq!(report["enrollment_grant"]["short_lived"], true); + assert_eq!( + report["enrollment_grant"]["exchange_for_persistent_node_identity"], + true + ); + assert_eq!( + report["enrollment_grant"]["node_credentials_separate_from_user_session"], + true + ); + assert_eq!(report["coordinator_session_requests"], 1); +} + +#[test] +fn node_commands_use_authenticated_envelope_with_stored_cli_session() { + let temp = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: addr.clone(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + session_secret: Some("node-cli-session-secret".to_owned()), + token_expiry_posture: "unknown_coordinator_session".to_owned(), + expires_at: None, + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + let server = std::thread::spawn(move || { + for (request_type, response) in [ + ( + "create_node_enrollment_grant", + br#"{"type":"node_enrollment_grant_created","tenant":"tenant-session","project":"project-session","grant":"grant-session","scope":"node:attach","expires_at_epoch_seconds":300}"#.as_slice(), + ), + ( + "list_node_descriptors", + br#"{"type":"node_descriptors","descriptors":[{"id":"node-session","online":true}],"actor":"user-session"}"#.as_slice(), + ), + ( + "revoke_node_credential", + br#"{"type":"node_credential_revoked","node":"node-session","tenant":"tenant-session","project":"project-session","actor":"user-session","descriptor_removed":true,"queued_assignments_removed":0}"#.as_slice(), + ), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""type":"authenticated""#)); + assert!(line.contains(r#""session_secret":"node-cli-session-secret""#)); + assert!(line.contains(&format!(r#""type":"{request_type}""#))); + assert!(!line.contains(r#""actor_user":"ignored-user""#)); + assert!(!line.contains(r#""tenant":"ignored-tenant""#)); + assert!(!line.contains(r#""project":"ignored-project""#)); + stream.write_all(response).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + + let enrolled = node_enroll_report( + NodeEnrollArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "ignored-tenant".to_owned(), + project: "ignored-project".to_owned(), + user: "ignored-user".to_owned(), + json: false, + }, + ttl_seconds: 300, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + let listed = node_list_report( + NodeListArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "ignored-tenant".to_owned(), + project: "ignored-project".to_owned(), + user: "ignored-user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + let revoked = node_revoke_report( + NodeRevokeArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "ignored-tenant".to_owned(), + project: "ignored-project".to_owned(), + user: "ignored-user".to_owned(), + json: false, + }, + node: "node-session".to_owned(), + yes: true, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(enrolled["tenant"], "tenant-session"); + assert_eq!(enrolled["project"], "project-session"); + assert_eq!(enrolled["user"], "user-session"); + assert_eq!(listed["response"]["descriptors"][0]["id"], "node-session"); + assert_eq!(revoked["credential_revoked"], true); +} + +#[test] +fn node_enroll_and_process_commands_have_safe_plan_without_coordinator() { + let scope = CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }; + let temp = tempfile::tempdir().unwrap(); + let enroll = node_enroll_report( + NodeEnrollArgs { + scope: scope.clone(), + ttl_seconds: 60, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(enroll["status"], "requires_coordinator"); + assert_eq!(enroll["private_website_required"], false); + assert_eq!(enroll["enrollment_grant"], serde_json::Value::Null); + assert_eq!(enroll["requested_ttl_seconds"], 60); + + let cancel = process_cancel_report(ProcessCancelArgs { + scope, + process: "vp".to_owned(), + node: None, + task: None, + yes: false, + }) + .unwrap(); + assert_eq!(cancel["status"], "confirmation_required"); + assert_eq!(cancel["requires_confirmation"], true); + assert_eq!(cancel["coordinator_request_sent"], false); +} diff --git a/crates/disasmer-cli/src/tools.rs b/crates/disasmer-cli/src/tools.rs new file mode 100644 index 0000000..a18e15f --- /dev/null +++ b/crates/disasmer-cli/src/tools.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Result; + +pub(crate) fn command_available(command: &str) -> bool { + Command::new(command) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +pub(crate) fn command_nonce(prefix: &str) -> String { + let now = unix_timestamp_nanos(); + format!("{prefix}-{now}-{}", std::process::id()) +} + +pub(crate) fn unix_timestamp_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn unix_timestamp_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() +} + +pub(crate) fn sibling_binary(name: &str) -> Option { + let mut sibling = std::env::current_exe().ok()?; + sibling.set_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + sibling.is_file().then_some(sibling) +} + +pub(crate) fn dap_binary_path() -> Result { + if let Some(path) = std::env::var_os("DISASMER_DAP_BIN") { + return Ok(PathBuf::from(path)); + } + if let Some(path) = sibling_binary("disasmer-debug-dap") { + return Ok(path); + } + let release = PathBuf::from("target/release").join(format!( + "disasmer-debug-dap{}", + std::env::consts::EXE_SUFFIX + )); + if release.is_file() { + return Ok(release); + } + let debug = PathBuf::from("target/debug").join(format!( + "disasmer-debug-dap{}", + std::env::consts::EXE_SUFFIX + )); + if debug.is_file() { + return Ok(debug); + } + anyhow::bail!("could not locate disasmer-debug-dap; set DISASMER_DAP_BIN") +} diff --git a/crates/disasmer-control/Cargo.toml b/crates/disasmer-control/Cargo.toml new file mode 100644 index 0000000..3231657 --- /dev/null +++ b/crates/disasmer-control/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "disasmer-control" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde_json.workspace = true +thiserror.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ureq.workspace = true diff --git a/crates/disasmer-control/src/lib.rs b/crates/disasmer-control/src/lib.rs new file mode 100644 index 0000000..2e619ad --- /dev/null +++ b/crates/disasmer-control/src/lib.rs @@ -0,0 +1,262 @@ +#[cfg(not(target_arch = "wasm32"))] +use std::io::{BufRead, BufReader, Read, Write}; +#[cfg(not(target_arch = "wasm32"))] +use std::net::TcpStream; +use std::net::{IpAddr, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::Value; +use thiserror::Error; + +pub const CONTROL_API_PATH: &str = "/api/v1/control"; +pub const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Error)] +pub enum ControlTransportError { + #[error("invalid coordinator endpoint: {0}")] + InvalidEndpoint(String), + #[error("insecure remote coordinator endpoint is forbidden: {0}")] + InsecureRemote(String), + #[error("coordinator transport I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("coordinator transport JSON failed: {0}")] + Json(#[from] serde_json::Error), + #[error("coordinator HTTP request failed: {0}")] + Http(String), + #[error("coordinator control frame exceeds {MAX_CONTROL_FRAME_BYTES} bytes")] + FrameTooLarge, + #[error("coordinator closed the local control session without a response")] + Closed, + #[error("coordinator network transport is unavailable inside a Wasm guest")] + UnavailableInWasm, +} + +enum ControlTransport { + #[cfg(not(target_arch = "wasm32"))] + Https { agent: ureq::Agent, url: String }, + #[cfg(not(target_arch = "wasm32"))] + LoopbackJsonLine { + writer: TcpStream, + reader: BufReader, + }, + #[cfg(target_arch = "wasm32")] + #[allow(dead_code)] + Unavailable, +} + +pub struct ControlSession { + transport: ControlTransport, + requests: u64, +} + +impl ControlSession { + pub fn connect(endpoint: &str) -> Result { + Self::connect_with_timeouts(endpoint, Duration::from_secs(10), Duration::from_secs(30)) + } + + pub fn connect_with_timeouts( + endpoint: &str, + connect_timeout: Duration, + io_timeout: Duration, + ) -> Result { + #[cfg(target_arch = "wasm32")] + { + let _ = (endpoint, connect_timeout, io_timeout); + return Err(ControlTransportError::UnavailableInWasm); + } + + #[cfg(not(target_arch = "wasm32"))] + { + let endpoint = endpoint.trim(); + if endpoint.starts_with("https://") || endpoint.starts_with("http://") { + let url = control_api_url(endpoint)?; + if endpoint.starts_with("http://") && !endpoint_is_loopback(endpoint) { + return Err(ControlTransportError::InsecureRemote(endpoint.to_owned())); + } + let agent = ureq::AgentBuilder::new() + .timeout_connect(connect_timeout) + .timeout_read(io_timeout) + .timeout_write(io_timeout) + .build(); + return Ok(Self { + transport: ControlTransport::Https { agent, url }, + requests: 0, + }); + } + + let loopback_address = endpoint.strip_prefix("disasmer+tcp://").unwrap_or(endpoint); + if !endpoint_is_loopback(loopback_address) { + return Err(ControlTransportError::InsecureRemote(endpoint.to_owned())); + } + let writer = TcpStream::connect(loopback_address)?; + writer.set_read_timeout(Some(io_timeout))?; + writer.set_write_timeout(Some(io_timeout))?; + let reader = BufReader::new(writer.try_clone()?); + Ok(Self { + transport: ControlTransport::LoopbackJsonLine { writer, reader }, + requests: 0, + }) + } + } + + pub fn request(&mut self, value: &Value) -> Result { + #[cfg(target_arch = "wasm32")] + { + let _ = (&self.transport, self.requests, value); + return Err(ControlTransportError::UnavailableInWasm); + } + + #[cfg(not(target_arch = "wasm32"))] + { + let encoded = serde_json::to_vec(value)?; + if encoded.len() > MAX_CONTROL_FRAME_BYTES { + return Err(ControlTransportError::FrameTooLarge); + } + let response = match &mut self.transport { + #[cfg(not(target_arch = "wasm32"))] + ControlTransport::Https { agent, url } => { + let response = agent + .post(url) + .set("Content-Type", "application/json") + .set("Accept", "application/json") + .send_bytes(&encoded) + .map_err(|error| ControlTransportError::Http(error.to_string()))?; + if response.status() != 200 { + return Err(ControlTransportError::Http(format!( + "coordinator returned HTTP {} {}", + response.status(), + response.status_text() + ))); + } + let mut bytes = Vec::new(); + response + .into_reader() + .take((MAX_CONTROL_FRAME_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_CONTROL_FRAME_BYTES { + return Err(ControlTransportError::FrameTooLarge); + } + serde_json::from_slice(&bytes)? + } + ControlTransport::LoopbackJsonLine { writer, reader } => { + writer.write_all(&encoded)?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut bytes = Vec::new(); + reader + .take((MAX_CONTROL_FRAME_BYTES + 1) as u64) + .read_until(b'\n', &mut bytes)?; + if bytes.is_empty() { + return Err(ControlTransportError::Closed); + } + if bytes.len() > MAX_CONTROL_FRAME_BYTES { + return Err(ControlTransportError::FrameTooLarge); + } + serde_json::from_slice(&bytes)? + } + }; + self.requests += 1; + Ok(response) + } + } + + pub fn requests(&self) -> u64 { + self.requests + } +} + +pub fn control_api_url(endpoint: &str) -> Result { + let endpoint = endpoint.trim().trim_end_matches('/'); + if !(endpoint.starts_with("https://") || endpoint.starts_with("http://")) { + return Err(ControlTransportError::InvalidEndpoint(endpoint.to_owned())); + } + if endpoint.ends_with(CONTROL_API_PATH) { + Ok(endpoint.to_owned()) + } else { + Ok(format!("{endpoint}{CONTROL_API_PATH}")) + } +} + +pub fn endpoint_identity(endpoint: &str) -> Result { + let endpoint = endpoint.trim(); + if endpoint.starts_with("https://") || endpoint.starts_with("http://") { + if endpoint.starts_with("http://") && !endpoint_is_loopback(endpoint) { + return Err(ControlTransportError::InsecureRemote(endpoint.to_owned())); + } + return control_api_url(endpoint); + } + let loopback_address = endpoint.strip_prefix("disasmer+tcp://").unwrap_or(endpoint); + if endpoint_is_loopback(loopback_address) { + return Ok(format!("disasmer+tcp://{loopback_address}")); + } + Err(ControlTransportError::InsecureRemote(endpoint.to_owned())) +} + +pub fn endpoint_is_loopback(endpoint: &str) -> bool { + let authority = endpoint + .trim() + .strip_prefix("https://") + .or_else(|| endpoint.trim().strip_prefix("http://")) + .or_else(|| endpoint.trim().strip_prefix("disasmer+tcp://")) + .unwrap_or(endpoint.trim()) + .split('/') + .next() + .unwrap_or_default(); + let host = if authority.starts_with('[') { + authority + .strip_prefix('[') + .and_then(|value| value.split_once(']')) + .map(|(host, _)| host) + .unwrap_or(authority) + } else { + authority + .rsplit_once(':') + .map(|(host, _)| host) + .unwrap_or(authority) + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + if host + .parse::() + .is_ok_and(|address| address.is_loopback()) + { + return true; + } + authority + .to_socket_addrs() + .ok() + .is_some_and(|mut addresses| addresses.all(|address| address.ip().is_loopback())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosted_endpoints_are_real_https_api_urls() { + assert_eq!( + control_api_url("https://disasmer.example").unwrap(), + "https://disasmer.example/api/v1/control" + ); + assert_eq!( + endpoint_identity("https://disasmer.example/api/v1/control").unwrap(), + "https://disasmer.example/api/v1/control" + ); + } + + #[test] + fn plaintext_transport_is_restricted_to_loopback() { + assert!(endpoint_is_loopback("127.0.0.1:7999")); + assert!(endpoint_is_loopback("disasmer+tcp://127.0.0.1:7999")); + assert!(endpoint_is_loopback("http://[::1]:7999")); + assert!(matches!( + ControlSession::connect("http://example.com:7999"), + Err(ControlTransportError::InsecureRemote(_)) + )); + assert!(matches!( + ControlSession::connect("example.com:7999"), + Err(ControlTransportError::InsecureRemote(_)) + )); + } +} diff --git a/crates/disasmer-coordinator/Cargo.toml b/crates/disasmer-coordinator/Cargo.toml new file mode 100644 index 0000000..85293d2 --- /dev/null +++ b/crates/disasmer-coordinator/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "disasmer-coordinator" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +base64.workspace = true +disasmer-core = { path = "../disasmer-core" } +disasmer-wasm-runtime = { path = "../disasmer-wasm-runtime" } +postgres.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true +wasmparser.workspace = true diff --git a/crates/disasmer-coordinator/src/agents.rs b/crates/disasmer-coordinator/src/agents.rs new file mode 100644 index 0000000..fe74517 --- /dev/null +++ b/crates/disasmer-coordinator/src/agents.rs @@ -0,0 +1,174 @@ +use disasmer_core::{ + verify_agent_workflow_signature, Actor, AgentId, AgentSignedRequest, AgentWorkflowScope, + AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId, +}; + +use crate::{ + AgentPublicKeyRecord, Coordinator, CoordinatorError, CredentialRecord, ProjectPermissionRecord, +}; + +impl Coordinator { + pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) { + self.durable.project_permissions.insert( + (tenant.clone(), project.clone(), user.clone()), + ProjectPermissionRecord { + tenant, + project, + user, + can_debug: true, + }, + ); + } + + pub fn register_agent_public_key( + &mut self, + tenant: TenantId, + project: ProjectId, + user: UserId, + agent: AgentId, + public_key: impl Into, + ) -> AgentPublicKeyRecord { + let key = (tenant.clone(), project.clone(), agent.clone()); + let version = self + .durable + .agent_public_keys + .get(&key) + .map_or(1, |record| record.version.saturating_add(1)); + let public_key = public_key.into(); + let public_key_fingerprint = Digest::sha256(&public_key); + let record = AgentPublicKeyRecord { + tenant: tenant.clone(), + project: project.clone(), + user: user.clone(), + agent: agent.clone(), + public_key, + public_key_fingerprint: public_key_fingerprint.clone(), + version, + revoked: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + human_account_creation_privilege: false, + browser_interaction_required_each_run: false, + }; + self.durable.agent_public_keys.insert(key, record.clone()); + let subject = format!("agent:{tenant}:{project}:{agent}"); + self.durable.credentials.insert( + subject.clone(), + CredentialRecord { + subject, + tenant, + project: Some(project), + kind: CredentialKind::PublicKey, + public_key_fingerprint: Some(public_key_fingerprint), + }, + ); + record + } + + pub fn list_agent_public_keys(&self, context: &AuthContext) -> Vec { + self.durable + .agent_public_keys + .values() + .filter(|record| record.tenant == context.tenant && record.project == context.project) + .filter(|record| match &context.actor { + Actor::User(user) => &record.user == user, + Actor::Agent(agent) => &record.agent == agent, + Actor::Node(_) | Actor::Task(_) => false, + }) + .cloned() + .collect() + } + + pub fn revoke_agent_public_key( + &mut self, + context: &AuthContext, + agent: &AgentId, + ) -> Result { + let key = ( + context.tenant.clone(), + context.project.clone(), + agent.clone(), + ); + let record = self + .durable + .agent_public_keys + .get_mut(&key) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "agent public key is not registered for this project".to_owned(), + ) + })?; + match &context.actor { + Actor::User(user) if &record.user == user => {} + Actor::User(_) => { + return Err(CoordinatorError::Unauthorized( + "agent public key is outside the signed-in user scope".to_owned(), + )); + } + _ => { + return Err(CoordinatorError::Unauthorized( + "agent public-key revocation requires a user identity".to_owned(), + )); + } + } + record.revoked = true; + let subject = format!( + "agent:{}:{}:{}", + context.tenant, context.project, record.agent + ); + self.durable.credentials.remove(&subject); + Ok(record.clone()) + } + + pub fn authorize_agent_project_run( + &self, + scope: AgentWorkflowScope<'_>, + public_key_fingerprint: Option<&Digest>, + payload_digest: &Digest, + signature: &AgentSignedRequest, + now_epoch_seconds: u64, + ) -> Result { + let record = self + .durable + .agent_public_keys + .get(&( + scope.tenant.clone(), + scope.project.clone(), + scope.agent.clone(), + )) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "agent public key is not registered for this tenant/project".to_owned(), + ) + })?; + if record.revoked { + return Err(CoordinatorError::Unauthorized( + "agent public key has been revoked".to_owned(), + )); + } + if let Some(public_key_fingerprint) = public_key_fingerprint { + if &record.public_key_fingerprint != public_key_fingerprint { + return Err(CoordinatorError::Unauthorized( + "agent public key fingerprint does not match the registered key".to_owned(), + )); + } + } + let max_signature_skew_seconds = 300; + if signature + .issued_at_epoch_seconds + .abs_diff(now_epoch_seconds) + > max_signature_skew_seconds + { + return Err(CoordinatorError::Unauthorized( + "agent signed request is expired or outside the allowed clock skew".to_owned(), + )); + } + if !record.scopes.iter().any(|scope| scope == "project:run") { + return Err(CoordinatorError::Unauthorized( + "agent public key is not scoped for project runs".to_owned(), + )); + } + verify_agent_workflow_signature(&record.public_key, scope, payload_digest, signature) + .map_err(CoordinatorError::Unauthorized)?; + Ok(record.clone()) + } +} diff --git a/crates/disasmer-coordinator/src/durable.rs b/crates/disasmer-coordinator/src/durable.rs new file mode 100644 index 0000000..cffeaae --- /dev/null +++ b/crates/disasmer-coordinator/src/durable.rs @@ -0,0 +1,145 @@ +use std::collections::BTreeMap; + +use disasmer_core::{ + AgentId, CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TenantRecord { + pub id: TenantId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserRecord { + pub id: UserId, + pub tenant: TenantId, + pub credential_kind: CredentialKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectRecord { + pub id: ProjectId, + pub tenant: TenantId, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeIdentityRecord { + pub id: NodeId, + pub tenant: TenantId, + pub project: ProjectId, + pub public_key: String, + pub enrollment_scope: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialRecord { + pub subject: String, + pub tenant: TenantId, + pub project: Option, + pub kind: CredentialKind, + pub public_key_fingerprint: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CliSessionRecord { + pub session_digest: Digest, + pub tenant: TenantId, + pub project: ProjectId, + pub user: UserId, + pub credential_kind: CredentialKind, + pub expires_at_epoch_seconds: Option, + pub revoked: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceProviderConfigRecord { + pub tenant: TenantId, + pub project: ProjectId, + pub provider: SourceProviderKind, + pub manifest_digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServicePolicyRecord { + pub tenant: TenantId, + pub name: String, + pub digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountPolicyState { + pub account_status: String, + pub suspended: bool, + pub disabled: bool, + pub deleted: bool, + pub manual_review: bool, + pub sanitized_reason: Option, + pub next_actions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectPermissionRecord { + pub tenant: TenantId, + pub project: ProjectId, + pub user: UserId, + pub can_debug: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentPublicKeyRecord { + pub tenant: TenantId, + pub project: ProjectId, + pub user: UserId, + pub agent: AgentId, + pub public_key: String, + pub public_key_fingerprint: Digest, + pub version: u64, + pub revoked: bool, + pub scopes: Vec, + pub human_account_creation_privilege: bool, + pub browser_interaction_required_each_run: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct DurableState { + pub tenants: BTreeMap, + pub users: BTreeMap, + pub projects: BTreeMap, + pub node_identities: BTreeMap, + pub credentials: BTreeMap, + pub cli_sessions: BTreeMap, + pub source_provider_configs: + BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>, + pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>, + pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>, + pub agent_public_keys: BTreeMap<(TenantId, ProjectId, AgentId), AgentPublicKeyRecord>, +} + +pub trait DurableStore { + fn load(&self) -> DurableState; + fn save(&mut self, state: DurableState); +} + +pub trait FallibleDurableStore { + type Error; + + fn load_state(&mut self) -> Result; + fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryDurableStore { + state: DurableState, +} + +impl DurableStore for InMemoryDurableStore { + fn load(&self) -> DurableState { + self.state.clone() + } + + fn save(&mut self, state: DurableState) { + self.state = state; + } +} diff --git a/crates/disasmer-coordinator/src/lib.rs b/crates/disasmer-coordinator/src/lib.rs new file mode 100644 index 0000000..a2174f4 --- /dev/null +++ b/crates/disasmer-coordinator/src/lib.rs @@ -0,0 +1,1045 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use disasmer_core::{ + Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant, + NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId, +}; +use thiserror::Error; + +mod agents; +pub mod durable; +pub mod postgres_store; +pub mod service; +mod sessions; +pub use durable::{ + AccountPolicyState, AgentPublicKeyRecord, CliSessionRecord, CredentialRecord, DurableState, + DurableStore, FallibleDurableStore, InMemoryDurableStore, NodeIdentityRecord, + ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, SourceProviderConfigRecord, + TenantRecord, UserRecord, +}; +pub use postgres_store::{ + PostgresDurableStore, PostgresStoreError, PostgresTable, POSTGRES_DURABLE_TABLES, +}; +pub use service::{ + CoordinatorAdmission, CoordinatorRequest, CoordinatorResponse, CoordinatorService, + CoordinatorServiceError, DebugAcknowledgementState, DebugAuditEvent, + DebugParticipantAcknowledgement, SourcePreparationDisposition, SourcePreparationStatus, + TaskAssignment, TaskCancellationTarget, TaskCompletionEvent, TaskExecutor, TaskTerminalState, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActiveProcess { + pub id: ProcessId, + pub tenant: TenantId, + pub project: ProjectId, + pub connected_nodes: BTreeSet, + pub coordinator_epoch: u64, +} + +#[derive(Clone, Debug)] +pub struct Coordinator { + durable: DurableState, + active_processes: BTreeMap<(TenantId, ProjectId, ProcessId), ActiveProcess>, + coordinator_epoch: u64, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CoordinatorError { + #[error("node identity is not enrolled")] + UnknownNode, + #[error("node enrollment failed: {0:?}")] + Enrollment(EnrollmentError), + #[error("stale virtual process state from coordinator epoch {stale_epoch}; current epoch is {current_epoch}")] + StaleProcessEpoch { + stale_epoch: u64, + current_epoch: u64, + }, + #[error("unauthorized coordinator action: {0}")] + Unauthorized(String), +} + +impl Coordinator { + pub fn boot(store: &impl DurableStore, coordinator_epoch: u64) -> Self { + Self { + durable: store.load(), + active_processes: BTreeMap::new(), + coordinator_epoch, + } + } + + pub fn try_boot( + store: &mut S, + coordinator_epoch: u64, + ) -> Result { + Ok(Self { + durable: store.load_state()?, + active_processes: BTreeMap::new(), + coordinator_epoch, + }) + } + + pub fn persist(&self, store: &mut impl DurableStore) { + store.save(self.durable.clone()); + } + + pub fn try_persist(&self, store: &mut S) -> Result<(), S::Error> { + store.save_state(&self.durable) + } + + pub fn coordinator_epoch(&self) -> u64 { + self.coordinator_epoch + } + + pub fn upsert_tenant(&mut self, id: TenantId) { + self.durable.tenants.insert(id.clone(), TenantRecord { id }); + } + + pub fn upsert_user(&mut self, tenant: TenantId, id: UserId, credential_kind: CredentialKind) { + self.durable.users.insert( + id.clone(), + UserRecord { + id, + tenant, + credential_kind, + }, + ); + } + + pub fn upsert_project(&mut self, tenant: TenantId, id: ProjectId, name: impl Into) { + self.durable.projects.insert( + id.clone(), + ProjectRecord { + id, + tenant, + name: name.into(), + }, + ); + } + + pub fn enroll_node( + &mut self, + tenant: TenantId, + project: ProjectId, + node: NodeId, + public_key: impl Into, + enrollment_scope: impl Into, + ) { + self.durable.node_identities.insert( + node.clone(), + NodeIdentityRecord { + id: node, + tenant, + project, + public_key: public_key.into(), + enrollment_scope: enrollment_scope.into(), + }, + ); + } + + pub fn create_node_enrollment_grant( + &self, + tenant: TenantId, + project: ProjectId, + grant_id: impl Into, + scope: impl Into, + expires_at_epoch_seconds: u64, + ) -> EnrollmentGrant { + EnrollmentGrant { + tenant, + project, + grant_id: grant_id.into(), + scope: scope.into(), + expires_at_epoch_seconds, + consumed: false, + } + } + + pub fn exchange_node_enrollment_grant( + &mut self, + grant: &mut EnrollmentGrant, + node: NodeId, + public_key: &str, + requested_scope: &str, + now_epoch_seconds: u64, + ) -> Result { + let credential = grant + .exchange_for_node_identity( + node.clone(), + public_key, + requested_scope, + now_epoch_seconds, + ) + .map_err(CoordinatorError::Enrollment)?; + self.enroll_node( + credential.tenant.clone(), + credential.project.clone(), + node.clone(), + public_key, + credential.scope.clone(), + ); + self.durable.credentials.insert( + format!("node:{node}"), + CredentialRecord { + subject: format!("node:{node}"), + tenant: credential.tenant.clone(), + project: Some(credential.project.clone()), + kind: credential.credential_kind.clone(), + public_key_fingerprint: Some(credential.public_key_fingerprint.clone()), + }, + ); + Ok(credential) + } + + pub fn upsert_source_provider_config( + &mut self, + tenant: TenantId, + project: ProjectId, + provider: SourceProviderKind, + manifest_digest: Digest, + ) { + let provider_key = format!("{provider:?}"); + self.durable.source_provider_configs.insert( + (tenant.clone(), project.clone(), provider_key), + SourceProviderConfigRecord { + tenant, + project, + provider, + manifest_digest, + }, + ); + } + + pub fn upsert_service_policy_record( + &mut self, + tenant: TenantId, + name: impl Into, + digest: Digest, + ) { + let name = name.into(); + self.durable.service_policy_records.insert( + (tenant.clone(), name.clone()), + ServicePolicyRecord { + tenant, + name, + digest, + }, + ); + } + + pub fn suspend_tenant(&mut self, tenant: TenantId, actor: UserId) -> ServicePolicyRecord { + self.upsert_tenant(tenant.clone()); + let name = "tenant:suspended".to_owned(); + let digest = Digest::from_parts([ + b"tenant-suspension:v1".as_slice(), + tenant.as_str().as_bytes(), + actor.as_str().as_bytes(), + ]); + self.upsert_service_policy_record(tenant.clone(), name.clone(), digest); + self.service_policy_record(&tenant, &name) + .expect("tenant suspension record was just inserted") + .clone() + } + + pub fn tenant_suspended(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:suspended") + .is_some() + } + + pub fn tenant_disabled(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:disabled") + .is_some() + } + + pub fn tenant_deleted(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:deleted") + .is_some() + } + + pub fn tenant_manual_review(&self, tenant: &TenantId) -> bool { + self.service_policy_record(tenant, "tenant:manual_review") + .is_some() + } + + pub fn account_policy_state(&self, tenant: &TenantId) -> AccountPolicyState { + let suspended = self.tenant_suspended(tenant); + let disabled = self.tenant_disabled(tenant); + let deleted = self.tenant_deleted(tenant); + let manual_review = self.tenant_manual_review(tenant); + let account_status = if deleted { + "deleted" + } else if disabled { + "disabled" + } else if suspended { + "suspended" + } else if manual_review { + "manual_review" + } else { + "active" + } + .to_owned(); + let sanitized_reason = match account_status.as_str() { + "deleted" => Some("account or tenant is no longer active".to_owned()), + "disabled" => Some("account or tenant is disabled by hosted policy".to_owned()), + "suspended" => Some("account or tenant is suspended by hosted policy".to_owned()), + "manual_review" => Some("account or tenant is pending hosted review".to_owned()), + _ => None, + }; + let next_actions = if account_status == "active" { + Vec::new() + } else { + vec![ + "disasmer auth status --json".to_owned(), + "contact the hosted operator or use a self-hosted coordinator".to_owned(), + ] + }; + AccountPolicyState { + account_status, + suspended, + disabled, + deleted, + manual_review, + sanitized_reason, + next_actions, + } + } + + pub fn ensure_tenant_active(&self, tenant: &TenantId) -> Result<(), CoordinatorError> { + let account_state = self.account_policy_state(tenant); + if account_state.account_status != "active" { + return Err(CoordinatorError::Unauthorized(format!( + "tenant is {} by admin controls", + account_state.account_status + ))); + } + Ok(()) + } + + pub fn start_process( + &mut self, + tenant: TenantId, + project: ProjectId, + id: ProcessId, + ) -> ActiveProcess { + let process = ActiveProcess { + id: id.clone(), + tenant, + project, + connected_nodes: BTreeSet::new(), + coordinator_epoch: self.coordinator_epoch, + }; + self.active_processes.insert( + (process.tenant.clone(), process.project.clone(), id), + process.clone(), + ); + process + } + + pub fn authorize_node_for_process( + &self, + node: &NodeId, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> Result<(), CoordinatorError> { + let identity = self + .durable + .node_identities + .get(node) + .ok_or(CoordinatorError::UnknownNode)?; + if &identity.tenant != tenant || &identity.project != project { + return Err(CoordinatorError::Unauthorized( + "node identity is outside the requested tenant/project scope".to_owned(), + )); + } + if !self + .active_processes + .contains_key(&(tenant.clone(), project.clone(), process.clone())) + { + return Err(CoordinatorError::Unauthorized( + "virtual process is not active in coordinator memory".to_owned(), + )); + } + Ok(()) + } + + pub fn reconnect_node( + &mut self, + node: &NodeId, + process: Option<(&ProcessId, u64)>, + ) -> Result<(), CoordinatorError> { + let identity = self + .durable + .node_identities + .get(node) + .ok_or(CoordinatorError::UnknownNode)?; + + if let Some((process_id, stale_epoch)) = process { + if stale_epoch != self.coordinator_epoch { + return Err(CoordinatorError::StaleProcessEpoch { + stale_epoch, + current_epoch: self.coordinator_epoch, + }); + } + let key = ( + identity.tenant.clone(), + identity.project.clone(), + process_id.clone(), + ); + if let Some(active) = self.active_processes.get_mut(&key) { + active.connected_nodes.insert(node.clone()); + } + } + + Ok(()) + } + + pub fn revoke_node_credential( + &mut self, + context: &AuthContext, + node: &NodeId, + ) -> Result { + let identity = self + .durable + .node_identities + .get(node) + .ok_or(CoordinatorError::UnknownNode)? + .clone(); + if identity.tenant != context.tenant || identity.project != context.project { + return Err(CoordinatorError::Unauthorized( + "node credential is outside the signed-in tenant/project scope".to_owned(), + )); + } + if !matches!(context.actor, Actor::User(_)) { + return Err(CoordinatorError::Unauthorized( + "node credential revocation requires a user identity".to_owned(), + )); + } + self.durable.node_identities.remove(node); + self.durable.credentials.remove(&format!("node:{node}")); + for active in self.active_processes.values_mut() { + active.connected_nodes.remove(node); + } + Ok(identity) + } + + pub fn list_projects(&self, context: &AuthContext) -> Vec { + self.durable + .projects + .values() + .filter(|project| project.tenant == context.tenant) + .cloned() + .collect() + } + + pub fn authorize_debug_attach( + &self, + context: &AuthContext, + process: &ProcessId, + ) -> Authorization { + let Some(active) = self.active_process(&context.tenant, &context.project, process) else { + return Authorization::deny("virtual process is not active in this tenant or project"); + }; + let Actor::User(user) = &context.actor else { + return Authorization::deny("debug attach requires a user identity"); + }; + let permission = self.durable.project_permissions.get(&( + active.tenant.clone(), + active.project.clone(), + user.clone(), + )); + if !permission.is_some_and(|permission| permission.can_debug) { + return Authorization::deny("debug attach requires explicit project permission"); + } + Authorization::allow("debug attach authorized for project") + } + + pub fn project(&self, id: &ProjectId) -> Option<&ProjectRecord> { + self.durable.projects.get(id) + } + + pub fn active_process( + &self, + tenant: &TenantId, + project: &ProjectId, + id: &ProcessId, + ) -> Option<&ActiveProcess> { + self.active_processes + .get(&(tenant.clone(), project.clone(), id.clone())) + } + + pub fn active_process_for_project( + &self, + tenant: &TenantId, + project: &ProjectId, + ) -> Option<&ActiveProcess> { + self.active_processes + .values() + .find(|active| &active.tenant == tenant && &active.project == project) + } + + pub fn active_processes_for_project( + &self, + tenant: &TenantId, + project: &ProjectId, + ) -> Vec { + self.active_processes + .values() + .filter(|active| &active.tenant == tenant && &active.project == project) + .cloned() + .collect() + } + + pub fn active_process_exists_outside_scope( + &self, + tenant: &TenantId, + project: &ProjectId, + id: &ProcessId, + ) -> bool { + self.active_processes.values().any(|active| { + active.id == *id && (active.tenant != *tenant || active.project != *project) + }) + } + + pub fn abort_process( + &mut self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> Result { + let key = (tenant.clone(), project.clone(), process.clone()); + let active = self.active_processes.get(&key).ok_or_else(|| { + CoordinatorError::Unauthorized( + "process abort requires an active virtual process".to_owned(), + ) + })?; + debug_assert_eq!(&active.tenant, tenant); + debug_assert_eq!(&active.project, project); + Ok(self + .active_processes + .remove(&key) + .expect("active process was checked immediately before removal")) + } + + pub fn active_process_count(&self) -> usize { + self.active_processes.len() + } + + pub fn node_identity(&self, id: &NodeId) -> Option<&NodeIdentityRecord> { + self.durable.node_identities.get(id) + } + + pub fn source_provider_config( + &self, + tenant: &TenantId, + project: &ProjectId, + provider: &str, + ) -> Option<&SourceProviderConfigRecord> { + self.durable.source_provider_configs.get(&( + tenant.clone(), + project.clone(), + provider.to_owned(), + )) + } + + pub fn service_policy_record( + &self, + tenant: &TenantId, + name: &str, + ) -> Option<&ServicePolicyRecord> { + self.durable + .service_policy_records + .get(&(tenant.clone(), name.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use disasmer_core::AgentId; + + use super::*; + + #[test] + fn coordinator_restart_preserves_project_but_not_live_processes() { + let mut store = InMemoryDurableStore::default(); + let mut first = Coordinator::boot(&store, 1); + first.upsert_tenant(TenantId::from("tenant")); + first.upsert_user( + TenantId::from("tenant"), + UserId::from("user"), + CredentialKind::CliDeviceSession, + ); + first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + first.upsert_source_provider_config( + TenantId::from("tenant"), + ProjectId::from("project"), + SourceProviderKind::Git, + Digest::sha256("git-manifest"), + ); + first.upsert_service_policy_record( + TenantId::from("tenant"), + "community tier", + Digest::sha256("policy"), + ); + let mut grant = first.create_node_enrollment_grant( + TenantId::from("tenant"), + ProjectId::from("project"), + "grant", + "node:attach", + 100, + ); + first + .exchange_node_enrollment_grant( + &mut grant, + NodeId::from("node"), + "public-key", + "node:attach", + 99, + ) + .unwrap(); + first.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ); + first.persist(&mut store); + + let mut restarted = Coordinator::boot(&store, 2); + + assert!(restarted + .durable + .tenants + .contains_key(&TenantId::from("tenant"))); + assert!(restarted.durable.users.contains_key(&UserId::from("user"))); + assert!(restarted.project(&ProjectId::from("project")).is_some()); + assert!(restarted.node_identity(&NodeId::from("node")).is_some()); + assert_eq!( + restarted + .durable + .credentials + .get("node:node") + .map(|credential| &credential.kind), + Some(&CredentialKind::NodeCredential) + ); + assert!(restarted + .source_provider_config( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "Git" + ) + .is_some()); + assert!(restarted + .service_policy_record(&TenantId::from("tenant"), "community tier") + .is_some()); + assert_eq!(restarted.active_process_count(), 0); + + let process = ProcessId::from("process-rerun"); + let rerun = restarted.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + process.clone(), + ); + assert_eq!(rerun.coordinator_epoch, 2); + restarted + .reconnect_node(&NodeId::from("node"), Some((&process, 2))) + .unwrap(); + assert!(restarted + .active_process( + &TenantId::from("tenant"), + &ProjectId::from("project"), + &process, + ) + .unwrap() + .connected_nodes + .contains(&NodeId::from("node"))); + } + + #[test] + fn identical_process_ids_are_isolated_by_tenant_and_project() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + let process = ProcessId::from("vp-current"); + let tenant_a = TenantId::from("tenant-a"); + let project_a = ProjectId::from("project-a"); + let tenant_b = TenantId::from("tenant-b"); + let project_b = ProjectId::from("project-b"); + + coordinator.start_process(tenant_a.clone(), project_a.clone(), process.clone()); + coordinator.start_process(tenant_b.clone(), project_b.clone(), process.clone()); + + assert_eq!(coordinator.active_process_count(), 2); + assert!(coordinator + .active_process(&tenant_a, &project_a, &process) + .is_some()); + assert!(coordinator + .active_process(&tenant_b, &project_b, &process) + .is_some()); + coordinator + .abort_process(&tenant_a, &project_a, &process) + .unwrap(); + assert!(coordinator + .active_process(&tenant_a, &project_a, &process) + .is_none()); + assert!(coordinator + .active_process(&tenant_b, &project_b, &process) + .is_some()); + } + + #[test] + fn node_reconnect_rejects_stale_process_epoch_after_restart() { + let mut store = InMemoryDurableStore::default(); + let mut first = Coordinator::boot(&store, 1); + first.upsert_tenant(TenantId::from("tenant")); + first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + first.enroll_node( + TenantId::from("tenant"), + ProjectId::from("project"), + NodeId::from("node"), + "public-key", + "node", + ); + first.persist(&mut store); + + let mut restarted = Coordinator::boot(&store, 2); + restarted + .reconnect_node(&NodeId::from("node"), None) + .unwrap(); + + let error = restarted + .reconnect_node( + &NodeId::from("node"), + Some((&ProcessId::from("process"), 1)), + ) + .unwrap_err(); + + assert!(matches!(error, CoordinatorError::StaleProcessEpoch { .. })); + } + + #[test] + fn node_enrollment_grant_becomes_persistent_node_identity() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + let mut grant = coordinator.create_node_enrollment_grant( + TenantId::from("tenant"), + ProjectId::from("project"), + "grant", + "node:attach", + 100, + ); + + let credential = coordinator + .exchange_node_enrollment_grant( + &mut grant, + NodeId::from("node"), + "public-key", + "node:attach", + 99, + ) + .unwrap(); + + assert_eq!(credential.credential_kind, CredentialKind::NodeCredential); + assert!(coordinator.node_identity(&NodeId::from("node")).is_some()); + } + + #[test] + fn node_credential_revocation_is_project_scoped_and_removes_identity() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.enroll_node( + TenantId::from("tenant"), + ProjectId::from("project"), + NodeId::from("node"), + "public-key", + "node:attach", + ); + coordinator.durable.credentials.insert( + "node:node".to_owned(), + CredentialRecord { + subject: "node:node".to_owned(), + tenant: TenantId::from("tenant"), + project: Some(ProjectId::from("project")), + kind: CredentialKind::NodeCredential, + public_key_fingerprint: Some(Digest::sha256("public-key")), + }, + ); + coordinator.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ); + coordinator + .reconnect_node( + &NodeId::from("node"), + Some((&ProcessId::from("process"), 1)), + ) + .unwrap(); + + let foreign = coordinator + .revoke_node_credential( + &AuthContext { + tenant: TenantId::from("other"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }, + &NodeId::from("node"), + ) + .unwrap_err(); + assert!(matches!(foreign, CoordinatorError::Unauthorized(_))); + + let revoked = coordinator + .revoke_node_credential( + &AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }, + &NodeId::from("node"), + ) + .unwrap(); + assert_eq!(revoked.id, NodeId::from("node")); + assert!(coordinator.node_identity(&NodeId::from("node")).is_none()); + assert!(!coordinator.durable.credentials.contains_key("node:node")); + assert!(!coordinator + .active_process( + &TenantId::from("tenant"), + &ProjectId::from("project"), + &ProcessId::from("process"), + ) + .unwrap() + .connected_nodes + .contains(&NodeId::from("node"))); + } + + #[test] + fn project_listing_is_filtered_by_tenant() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.upsert_project( + TenantId::from("tenant-a"), + ProjectId::from("project-a"), + "a", + ); + coordinator.upsert_project( + TenantId::from("tenant-b"), + ProjectId::from("project-b"), + "b", + ); + + let projects = coordinator.list_projects(&AuthContext { + tenant: TenantId::from("tenant-a"), + project: ProjectId::from("project-a"), + actor: Actor::User(UserId::from("user-a")), + }); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, ProjectId::from("project-a")); + } + + #[test] + fn tenant_suspension_is_durable_admin_policy_state() { + let mut store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + let record = + coordinator.suspend_tenant(TenantId::from("tenant"), UserId::from("admin-user")); + + assert_eq!(record.tenant, TenantId::from("tenant")); + assert_eq!(record.name, "tenant:suspended"); + assert!(coordinator.tenant_suspended(&TenantId::from("tenant"))); + assert!(coordinator + .ensure_tenant_active(&TenantId::from("tenant")) + .unwrap_err() + .to_string() + .contains("suspended")); + + coordinator.persist(&mut store); + let restarted = Coordinator::boot(&store, 2); + assert!(restarted.tenant_suspended(&TenantId::from("tenant"))); + } + + #[test] + fn account_policy_state_summarizes_private_admin_records_safely() { + let tenant = TenantId::from("tenant"); + let mut coordinator = Coordinator::boot(&InMemoryDurableStore::default(), 1); + + let active = coordinator.account_policy_state(&tenant); + assert_eq!(active.account_status, "active"); + assert!(active.sanitized_reason.is_none()); + assert!(active.next_actions.is_empty()); + + coordinator.upsert_service_policy_record( + tenant.clone(), + "tenant:manual_review", + Digest::from_parts([b"manual-review".as_slice()]), + ); + let manual_review = coordinator.account_policy_state(&tenant); + assert_eq!(manual_review.account_status, "manual_review"); + assert!(manual_review.manual_review); + assert_eq!( + manual_review.sanitized_reason.as_deref(), + Some("account or tenant is pending hosted review") + ); + + coordinator.upsert_service_policy_record( + tenant.clone(), + "tenant:disabled", + Digest::from_parts([b"disabled".as_slice()]), + ); + let disabled = coordinator.account_policy_state(&tenant); + assert_eq!(disabled.account_status, "disabled"); + assert!(disabled.disabled); + assert!(disabled.manual_review); + assert_eq!( + disabled.sanitized_reason.as_deref(), + Some("account or tenant is disabled by hosted policy") + ); + + coordinator.upsert_service_policy_record( + tenant.clone(), + "tenant:deleted", + Digest::from_parts([b"deleted".as_slice()]), + ); + let deleted = coordinator.account_policy_state(&tenant); + assert_eq!(deleted.account_status, "deleted"); + assert!(deleted.deleted); + assert!(deleted.disabled); + assert!(deleted.manual_review); + assert_eq!( + deleted.sanitized_reason.as_deref(), + Some("account or tenant is no longer active") + ); + assert!(deleted + .next_actions + .iter() + .any(|action| action.contains("hosted operator"))); + } + + #[test] + fn agent_public_keys_are_project_user_scoped_and_restart_durable() { + let mut store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.upsert_tenant(TenantId::from("tenant")); + coordinator.upsert_user( + TenantId::from("tenant"), + UserId::from("user"), + CredentialKind::CliDeviceSession, + ); + coordinator.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + + let registered = coordinator.register_agent_public_key( + TenantId::from("tenant"), + ProjectId::from("project"), + UserId::from("user"), + AgentId::from("agent-ci"), + "agent-key-v1", + ); + assert_eq!(registered.version, 1); + assert!(!registered.revoked); + assert!(!registered.human_account_creation_privilege); + assert_eq!(registered.scopes, vec!["project:read", "project:run"]); + + let rotated = coordinator.register_agent_public_key( + TenantId::from("tenant"), + ProjectId::from("project"), + UserId::from("user"), + AgentId::from("agent-ci"), + "agent-key-v2", + ); + assert_eq!(rotated.version, 2); + assert_eq!(rotated.public_key, "agent-key-v2"); + + coordinator.persist(&mut store); + let mut restarted = Coordinator::boot(&store, 2); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let listed = restarted.list_agent_public_keys(&context); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].agent, AgentId::from("agent-ci")); + assert_eq!(listed[0].version, 2); + + let foreign_user_context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("other-user")), + }; + assert!(restarted + .list_agent_public_keys(&foreign_user_context) + .is_empty()); + + let foreign_project_context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("other-project"), + actor: Actor::User(UserId::from("user")), + }; + assert!(restarted + .list_agent_public_keys(&foreign_project_context) + .is_empty()); + + let revoked = restarted + .revoke_agent_public_key(&context, &AgentId::from("agent-ci")) + .unwrap(); + assert!(revoked.revoked); + assert!(!restarted + .durable + .credentials + .contains_key("agent:tenant:project:agent-ci")); + } + + #[test] + fn node_cannot_claim_process_outside_authorized_scope() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.enroll_node( + TenantId::from("tenant-a"), + ProjectId::from("project-a"), + NodeId::from("node-a"), + "public-key", + "node", + ); + coordinator.start_process( + TenantId::from("tenant-b"), + ProjectId::from("project-b"), + ProcessId::from("process-b"), + ); + + let error = coordinator + .authorize_node_for_process( + &NodeId::from("node-a"), + &TenantId::from("tenant-b"), + &ProjectId::from("project-b"), + &ProcessId::from("process-b"), + ) + .unwrap_err(); + + assert!(matches!(error, CoordinatorError::Unauthorized(_))); + } + + #[test] + fn debug_attach_requires_explicit_project_permission() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let denied = coordinator.authorize_debug_attach(&context, &ProcessId::from("process")); + coordinator.grant_project_debug( + TenantId::from("tenant"), + ProjectId::from("project"), + UserId::from("user"), + ); + let allowed = coordinator.authorize_debug_attach(&context, &ProcessId::from("process")); + + assert!(!denied.allowed); + assert!(denied.reason.contains("explicit project permission")); + assert!(allowed.allowed); + } +} diff --git a/crates/disasmer-coordinator/src/main.rs b/crates/disasmer-coordinator/src/main.rs new file mode 100644 index 0000000..0108498 --- /dev/null +++ b/crates/disasmer-coordinator/src/main.rs @@ -0,0 +1,62 @@ +use std::io::Write; + +use disasmer_coordinator::{service::bind_listener, CoordinatorService}; +use disasmer_core::{ProjectId, TenantId, UserId}; +use serde_json::json; + +fn main() -> Result<(), Box> { + let mut listen = "127.0.0.1:0".to_owned(); + let mut allow_local_trusted = std::env::var("DISASMER_ALLOW_LOCAL_TRUSTED_LOOPBACK") + .ok() + .as_deref() + == Some("1"); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--listen" { + listen = args.next().ok_or("--listen requires an address")?; + } else if arg == "--allow-local-trusted-loopback" { + allow_local_trusted = true; + } + } + + let (listener, addr) = bind_listener(&listen)?; + let database_url = std::env::var("DATABASE_URL").ok(); + let mut service = CoordinatorService::new_with_database_url(1, database_url.as_deref())?; + let self_hosted_session_secret = std::env::var("DISASMER_SELF_HOSTED_SESSION_SECRET") + .ok() + .filter(|secret| !secret.trim().is_empty()); + if let Some(session_secret) = self_hosted_session_secret.as_deref() { + service.issue_cli_session( + TenantId::new( + std::env::var("DISASMER_SELF_HOSTED_TENANT") + .unwrap_or_else(|_| "tenant".to_owned()), + ), + ProjectId::new( + std::env::var("DISASMER_SELF_HOSTED_PROJECT") + .unwrap_or_else(|_| "project".to_owned()), + ), + UserId::new( + std::env::var("DISASMER_SELF_HOSTED_USER").unwrap_or_else(|_| "user".to_owned()), + ), + session_secret, + None, + )?; + } + println!( + "{}", + json!({ + "listen": addr.to_string(), + "client_authority": if allow_local_trusted { "local_trusted_loopback" } else { "strict" }, + "self_hosted_session_bootstrapped": self_hosted_session_secret.is_some(), + "durable_store": service.durable_store_kind(), + }) + ); + std::io::stdout().flush()?; + + if allow_local_trusted { + service.serve_tcp_local_trusted(listener)?; + } else { + service.serve_tcp(listener)?; + } + Ok(()) +} diff --git a/crates/disasmer-coordinator/src/postgres_store.rs b/crates/disasmer-coordinator/src/postgres_store.rs new file mode 100644 index 0000000..5bb7e47 --- /dev/null +++ b/crates/disasmer-coordinator/src/postgres_store.rs @@ -0,0 +1,576 @@ +use postgres::{Client, NoTls}; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; + +use crate::{ + AgentPublicKeyRecord, CliSessionRecord, CredentialRecord, DurableState, FallibleDurableStore, + NodeIdentityRecord, ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, + SourceProviderConfigRecord, TenantRecord, UserRecord, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PostgresTable { + pub name: &'static str, + pub durable_record: &'static str, + pub restart_surviving: bool, +} + +pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[ + PostgresTable { + name: "disasmer_tenants", + durable_record: "tenants", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_users", + durable_record: "users", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_projects", + durable_record: "projects", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_node_identities", + durable_record: "node identities", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_credentials", + durable_record: "credentials", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_cli_sessions", + durable_record: "CLI sessions", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_agent_public_keys", + durable_record: "agent public keys", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_source_provider_configs", + durable_record: "source-provider configuration", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_service_policy_records", + durable_record: "durable service policy records", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_project_permissions", + durable_record: "explicit project permissions", + restart_surviving: true, + }, +]; + +#[derive(Debug, Error)] +pub enum PostgresStoreError { + #[error("postgres durable store error: {0}")] + Postgres(#[from] postgres::Error), + #[error("durable state serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} + +pub struct PostgresDurableStore { + client: Client, +} + +impl PostgresDurableStore { + pub fn connect(connection_string: &str) -> Result { + let mut store = Self { + client: Client::connect(connection_string, NoTls)?, + }; + store.migrate()?; + Ok(store) + } + + pub fn from_client(client: Client) -> Result { + let mut store = Self { client }; + store.migrate()?; + Ok(store) + } + + pub fn schema_sql() -> &'static str { + POSTGRES_SCHEMA_SQL + } + + pub fn durable_tables() -> &'static [PostgresTable] { + POSTGRES_DURABLE_TABLES + } + + pub fn migrate(&mut self) -> Result<(), PostgresStoreError> { + self.client.batch_execute(Self::schema_sql())?; + Ok(()) + } + + fn query_records( + &mut self, + sql: &str, + ) -> Result, PostgresStoreError> { + self.client + .query(sql, &[])? + .into_iter() + .map(|row| { + let value: serde_json::Value = row.get("record"); + Ok(serde_json::from_value(value)?) + }) + .collect() + } + + fn record_value(record: &impl Serialize) -> Result { + Ok(serde_json::to_value(record)?) + } +} + +impl FallibleDurableStore for PostgresDurableStore { + type Error = PostgresStoreError; + + fn load_state(&mut self) -> Result { + let mut state = DurableState::default(); + + for record in self.query_records::( + "SELECT record FROM disasmer_tenants ORDER BY tenant_id", + )? { + state.tenants.insert(record.id.clone(), record); + } + for record in + self.query_records::("SELECT record FROM disasmer_users ORDER BY user_id")? + { + state.users.insert(record.id.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_projects ORDER BY project_id", + )? { + state.projects.insert(record.id.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_node_identities ORDER BY node_id", + )? { + state.node_identities.insert(record.id.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_credentials ORDER BY subject", + )? { + state.credentials.insert(record.subject.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_cli_sessions ORDER BY session_digest", + )? { + state + .cli_sessions + .insert(record.session_digest.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_agent_public_keys ORDER BY tenant_id, project_id, agent_id", + )? { + state.agent_public_keys.insert( + ( + record.tenant.clone(), + record.project.clone(), + record.agent.clone(), + ), + record, + ); + } + for record in self.query_records::( + "SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key", + )? { + let provider_key = format!("{:?}", record.provider); + state.source_provider_configs.insert( + (record.tenant.clone(), record.project.clone(), provider_key), + record, + ); + } + for record in self.query_records::( + "SELECT record FROM disasmer_service_policy_records ORDER BY tenant_id, name", + )? { + state + .service_policy_records + .insert((record.tenant.clone(), record.name.clone()), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_project_permissions ORDER BY tenant_id, project_id, user_id", + )? { + state.project_permissions.insert( + ( + record.tenant.clone(), + record.project.clone(), + record.user.clone(), + ), + record, + ); + } + + Ok(state) + } + + fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> { + let mut tx = self.client.transaction()?; + tx.batch_execute( + " + DELETE FROM disasmer_project_permissions; + DELETE FROM disasmer_service_policy_records; + DELETE FROM disasmer_source_provider_configs; + DELETE FROM disasmer_agent_public_keys; + DELETE FROM disasmer_cli_sessions; + DELETE FROM disasmer_credentials; + DELETE FROM disasmer_node_identities; + DELETE FROM disasmer_projects; + DELETE FROM disasmer_users; + DELETE FROM disasmer_tenants; + ", + )?; + + for record in state.tenants.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_tenants (tenant_id, record) VALUES ($1, $2)", + &[&record.id.as_str(), &value], + )?; + } + for record in state.users.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_users (user_id, tenant_id, record) VALUES ($1, $2, $3)", + &[&record.id.as_str(), &record.tenant.as_str(), &value], + )?; + } + for record in state.projects.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)", + &[&record.id.as_str(), &record.tenant.as_str(), &value], + )?; + } + for record in state.node_identities.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", + &[ + &record.id.as_str(), + &record.tenant.as_str(), + &record.project.as_str(), + &value, + ], + )?; + } + for record in state.credentials.values() { + let value = Self::record_value(record)?; + let project_id = record.project.as_ref().map(|project| project.as_str()); + tx.execute( + "INSERT INTO disasmer_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", + &[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value], + )?; + } + for record in state.cli_sessions.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_cli_sessions (session_digest, tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4, $5)", + &[ + &record.session_digest.as_str(), + &record.tenant.as_str(), + &record.project.as_str(), + &record.user.as_str(), + &value, + ], + )?; + } + for record in state.agent_public_keys.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_agent_public_keys (tenant_id, project_id, user_id, agent_id, record) VALUES ($1, $2, $3, $4, $5)", + &[ + &record.tenant.as_str(), + &record.project.as_str(), + &record.user.as_str(), + &record.agent.as_str(), + &value, + ], + )?; + } + for ((_, _, provider_key), record) in &state.source_provider_configs { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)", + &[ + &record.tenant.as_str(), + &record.project.as_str(), + &provider_key.as_str(), + &value, + ], + )?; + } + for record in state.service_policy_records.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)", + &[&record.tenant.as_str(), &record.name.as_str(), &value], + )?; + } + for record in state.project_permissions.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)", + &[ + &record.tenant.as_str(), + &record.project.as_str(), + &record.user.as_str(), + &value, + ], + )?; + } + + tx.commit()?; + Ok(()) + } +} + +const POSTGRES_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS disasmer_tenants ( + tenant_id TEXT PRIMARY KEY, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_users ( + user_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_projects ( + project_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_node_identities ( + node_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_credentials ( + subject TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_cli_sessions ( + session_digest TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_agent_public_keys ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, + agent_id TEXT NOT NULL, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, project_id, agent_id) +); + +CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + provider_key TEXT NOT NULL, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, project_id, provider_key) +); + +CREATE TABLE IF NOT EXISTS disasmer_service_policy_records ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + name TEXT NOT NULL, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, name) +); + +CREATE TABLE IF NOT EXISTS disasmer_project_permissions ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, project_id, user_id) +); +"#; + +#[cfg(test)] +mod tests { + use disasmer_core::{ + CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId, + }; + + use super::*; + use crate::{Coordinator, DurableStore, FallibleDurableStore, InMemoryDurableStore}; + + #[test] + fn postgres_schema_contains_only_restart_surviving_durable_tables() { + let names = PostgresDurableStore::durable_tables() + .iter() + .map(|table| table.name) + .collect::>(); + + assert_eq!(names.len(), 10); + assert!(names.contains(&"disasmer_tenants")); + assert!(names.contains(&"disasmer_users")); + assert!(names.contains(&"disasmer_projects")); + assert!(names.contains(&"disasmer_node_identities")); + assert!(names.contains(&"disasmer_credentials")); + assert!(names.contains(&"disasmer_cli_sessions")); + assert!(names.contains(&"disasmer_agent_public_keys")); + assert!(names.contains(&"disasmer_source_provider_configs")); + assert!(names.contains(&"disasmer_service_policy_records")); + assert!(names.contains(&"disasmer_project_permissions")); + assert!(PostgresDurableStore::durable_tables() + .iter() + .all(|table| table.restart_surviving)); + + for runtime_only in [ + "active_process", + "virtual_thread", + "scheduler_state", + "debug_epoch", + "vfs_manifest", + "transient_artifact_location", + ] { + assert!( + !PostgresDurableStore::schema_sql().contains(runtime_only), + "{runtime_only} must remain outside Postgres durable state" + ); + } + } + + #[test] + fn fallible_store_boot_uses_durable_state_and_still_drops_live_processes() { + #[derive(Default)] + struct FallibleMemoryStore { + inner: InMemoryDurableStore, + } + + impl FallibleDurableStore for FallibleMemoryStore { + type Error = std::convert::Infallible; + + fn load_state(&mut self) -> Result { + Ok(self.inner.load()) + } + + fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> { + self.inner.save(state.clone()); + Ok(()) + } + } + + let mut store = FallibleMemoryStore::default(); + let mut first = Coordinator::try_boot(&mut store, 1).unwrap(); + first.upsert_tenant(TenantId::from("tenant")); + first.upsert_user( + TenantId::from("tenant"), + UserId::from("user"), + CredentialKind::CliDeviceSession, + ); + first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + first.enroll_node( + TenantId::from("tenant"), + ProjectId::from("project"), + NodeId::from("node"), + "public-key", + "node:attach", + ); + first.upsert_source_provider_config( + TenantId::from("tenant"), + ProjectId::from("project"), + SourceProviderKind::Git, + Digest::sha256("git-manifest"), + ); + first.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + disasmer_core::ProcessId::from("process"), + ); + first.try_persist(&mut store).unwrap(); + + let restarted = Coordinator::try_boot(&mut store, 2).unwrap(); + + assert!(restarted.project(&ProjectId::from("project")).is_some()); + assert!(restarted.node_identity(&NodeId::from("node")).is_some()); + assert_eq!(restarted.active_process_count(), 0); + } + + #[test] + fn postgres_round_trip_runs_when_dsn_is_configured() { + let Ok(dsn) = std::env::var("DISASMER_TEST_POSTGRES") else { + return; + }; + + let mut store = PostgresDurableStore::connect(&dsn).unwrap(); + let mut state = DurableState::default(); + state.tenants.insert( + TenantId::from("tenant"), + TenantRecord { + id: TenantId::from("tenant"), + }, + ); + state.projects.insert( + ProjectId::from("project"), + ProjectRecord { + id: ProjectId::from("project"), + tenant: TenantId::from("tenant"), + name: "demo".to_owned(), + }, + ); + state.users.insert( + UserId::from("user"), + UserRecord { + id: UserId::from("user"), + tenant: TenantId::from("tenant"), + credential_kind: CredentialKind::CliDeviceSession, + }, + ); + let session_digests = [ + Digest::sha256("postgres-round-trip-session-one"), + Digest::sha256("postgres-round-trip-session-two"), + ]; + for session_digest in &session_digests { + state.cli_sessions.insert( + session_digest.clone(), + CliSessionRecord { + session_digest: session_digest.clone(), + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + user: UserId::from("user"), + credential_kind: CredentialKind::CliDeviceSession, + expires_at_epoch_seconds: None, + revoked: false, + }, + ); + let subject = format!("cli-session:{}", session_digest.as_str()); + state.credentials.insert( + subject.clone(), + CredentialRecord { + subject, + tenant: TenantId::from("tenant"), + project: Some(ProjectId::from("project")), + kind: CredentialKind::CliDeviceSession, + public_key_fingerprint: None, + }, + ); + } + store.save_state(&state).unwrap(); + let loaded = store.load_state().unwrap(); + + assert!(loaded.projects.contains_key(&ProjectId::from("project"))); + assert!(session_digests + .iter() + .all(|session_digest| loaded.cli_sessions.contains_key(session_digest))); + assert_eq!(loaded.credentials.len(), 2); + } +} diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs new file mode 100644 index 0000000..6177d6e --- /dev/null +++ b/crates/disasmer-coordinator/src/service.rs @@ -0,0 +1,423 @@ +// Request handlers intentionally spell out their deserialized protocol fields. +// Keeping those authority and payload values explicit at this boundary is safer +// than passing an unvalidated wire request deeper into the service. +#![allow(clippy::too_many_arguments)] + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{ + Actor, AgentId, ArtifactRegistry, CapabilityReportError, CredentialKind, Digest, LimitError, + NativeQuicTransport, NodeDescriptor, NodeId, PanelState, Placement, ProcessId, ProjectId, + RateLimit, TenantId, TransportError, UserId, +}; +use thiserror::Error; + +use crate::{Coordinator, CoordinatorError}; + +mod admin; +mod artifacts; +mod authenticated; +mod authorization; +mod debug; +mod debug_requests; +mod durable_runtime; +mod keys; +mod logs; +mod main_runtime; +mod nodes; +mod panels; +mod process_launch; +mod processes; +mod protocol; +mod quota; +mod routing; +mod signed_nodes; +mod tcp; +mod wire_protocol; +use authorization::authorize_authenticated_user_operation; +use durable_runtime::RuntimeDurableStore; +use keys::{ + artifact_id_from_path, enrollment_grant_key, EnrollmentGrantKey, PanelStopKey, + ProcessControlKey, TaskAssignmentKey, TaskControlKey, TaskRestartKey, +}; +pub use protocol::{ + ArtifactTransferAssignment, AuthenticatedCoordinatorRequest, CoordinatorRequest, + CoordinatorResponse, DebugAcknowledgementState, DebugAuditEvent, + DebugParticipantAcknowledgement, SourcePreparationDisposition, SourcePreparationStatus, + TaskAssignment, TaskCancellationTarget, TaskCompletionEvent, TaskExecutor, + TaskReplacementBundle, TaskTerminalState, VirtualProcessStatus, WorkflowActor, +}; +pub use quota::CoordinatorQuotaConfiguration; +pub use tcp::{bind_listener, ClientAuthorityMode}; +pub use wire_protocol::CoordinatorWireRequest; + +const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024; +const DEBUG_CONTROL_READ_BYTES: u64 = 1024; +const MAX_REPLAY_NONCES_PER_AUTHORITY: usize = 1_024; +const NODE_SIGNATURE_WINDOW_SECONDS: u64 = 30; +const MAX_NODE_REPLAY_NONCES_PER_AUTHORITY: usize = 4_096; +const MAX_ENROLLMENT_GRANTS_PER_PROJECT: usize = 64; +const MAX_TASK_EVENTS_PER_PROCESS: usize = 128; +const MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS: usize = 256; +const MAX_RESTART_CHECKPOINTS_PER_PROCESS: usize = 128; +const MAX_IN_FLIGHT_TASKS_PER_PROCESS: usize = 256; +const MAX_NODE_REPORTED_OBJECTS_PER_KIND: usize = 1_024; +fn bounded_ttl(requested: u64, maximum: u64) -> u64 { + requested.clamp(1, maximum) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CoordinatorAdmission { + pub workflow_placement_allowed: bool, + pub max_node_enrollment_ttl_seconds: u64, + pub max_artifact_download_ttl_seconds: u64, +} + +impl Default for CoordinatorAdmission { + fn default() -> Self { + Self { + workflow_placement_allowed: true, + max_node_enrollment_ttl_seconds: 15 * 60, + max_artifact_download_ttl_seconds: 15 * 60, + } + } +} + +#[derive(Debug, Error)] +pub enum CoordinatorServiceError { + #[error("coordinator protocol I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("coordinator protocol JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("coordinator protocol error: {0}")] + Protocol(String), + #[error("coordinator request failed: {0}")] + Coordinator(#[from] CoordinatorError), + #[error("artifact download request failed: {0}")] + Download(#[from] disasmer_core::DownloadError), + #[error("scheduler placement failed: {0}")] + Scheduler(#[from] disasmer_core::PlacementError), + #[error("transport request failed: {0}")] + Transport(#[from] TransportError), + #[error("resource limit failed: {0}")] + Resource(#[from] LimitError), + #[error("operator panel request failed: {0}")] + Panel(#[from] disasmer_core::PanelError), + #[error("invalid node capability report: {0}")] + CapabilityReport(#[from] CapabilityReportError), + #[error("invalid VFS artifact path reported by node: {0}")] + InvalidArtifactPath(String), + #[error("invalid task log tail reported by node: {0}")] + InvalidTaskLogTail(String), + #[error("durable coordinator state failed: {0}")] + Durable(String), +} + +pub struct CoordinatorService { + coordinator: Coordinator, + store: RuntimeDurableStore, + node_descriptors: BTreeMap, + enrollment_grants: BTreeMap, + task_events: VecDeque, + debug_audit_events: VecDeque, + debug_epochs: BTreeMap, + debug_epoch_runtime: BTreeMap, + debug_breakpoints: BTreeMap, + debug_commands: BTreeMap, + task_assignments: BTreeMap>, + task_restart_checkpoints: BTreeMap, + task_restart_checkpoint_order: VecDeque, + main_runtime: main_runtime::CoordinatorMainRuntime, + pending_task_launches: VecDeque, + task_placements: BTreeMap, + active_tasks: BTreeSet, + task_cancellations: BTreeSet, + task_aborts: BTreeSet, + process_cancellations: BTreeSet, + process_aborts: BTreeSet, + agent_replay_nonces: BTreeMap<(TenantId, ProjectId, AgentId, String), u64>, + node_replay_nonces: BTreeMap<(NodeId, String), u64>, + panel_snapshots: BTreeMap, + stopped_panels: BTreeSet, + panel_event_limits: BTreeMap<(TenantId, ProjectId, ProcessId, String), RateLimit>, + artifact_registry: ArtifactRegistry, + artifact_reverse_transfers: BTreeMap, + artifact_transfer_by_token: BTreeMap, + transport: NativeQuicTransport, + quota: quota::CoordinatorQuota, + admission: CoordinatorAdmission, + #[cfg(test)] + server_time_override: Option, + admin_token_digest: Option, + admin_replay_nonces: BTreeMap, +} + +impl CoordinatorService { + pub(super) fn authorize_node_for_process_or_termination( + &self, + node: &NodeId, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> Result<(), CoordinatorServiceError> { + let process_key = keys::process_control_key(tenant, project, process); + if self.process_cancellations.contains(&process_key) + || self.process_aborts.contains(&process_key) + { + let identity = self + .coordinator + .node_identity(node) + .ok_or(CoordinatorError::UnknownNode)?; + if &identity.tenant != tenant || &identity.project != project { + return Err(CoordinatorError::Unauthorized( + "node process-control request is outside its enrolled tenant/project scope" + .to_owned(), + ) + .into()); + } + return Ok(()); + } + self.coordinator + .authorize_node_for_process(node, tenant, project, process)?; + Ok(()) + } + + pub fn new(coordinator_epoch: u64) -> Self { + Self::new_with_optional_admin_token_and_admission( + coordinator_epoch, + std::env::var("DISASMER_ADMIN_TOKEN").ok(), + CoordinatorAdmission::default(), + ) + } + + pub fn new_with_admin_token(coordinator_epoch: u64, admin_token: impl Into) -> Self { + Self::new_with_optional_admin_token_and_admission( + coordinator_epoch, + Some(admin_token.into()), + CoordinatorAdmission::default(), + ) + } + + pub fn new_with_admission(coordinator_epoch: u64, admission: CoordinatorAdmission) -> Self { + Self::new_with_optional_admin_token_and_admission( + coordinator_epoch, + std::env::var("DISASMER_ADMIN_TOKEN").ok(), + admission, + ) + } + + fn new_with_optional_admin_token_and_admission( + coordinator_epoch: u64, + admin_token: Option, + admission: CoordinatorAdmission, + ) -> Self { + Self::try_new_with_optional_admin_token_admission_and_database_url( + coordinator_epoch, + admin_token, + admission, + None, + CoordinatorQuotaConfiguration::default(), + ) + .expect("in-memory durable coordinator store initialization cannot fail") + } + + pub fn new_with_database_url( + coordinator_epoch: u64, + database_url: Option<&str>, + ) -> Result { + Self::try_new_with_optional_admin_token_admission_and_database_url( + coordinator_epoch, + std::env::var("DISASMER_ADMIN_TOKEN").ok(), + CoordinatorAdmission::default(), + database_url, + CoordinatorQuotaConfiguration::default(), + ) + } + + pub fn new_with_admin_token_and_database_url( + coordinator_epoch: u64, + admin_token: impl Into, + database_url: Option<&str>, + ) -> Result { + Self::new_with_admin_token_database_url_and_quota( + coordinator_epoch, + admin_token, + database_url, + CoordinatorQuotaConfiguration::default(), + ) + } + + pub fn new_with_admin_token_database_url_and_quota( + coordinator_epoch: u64, + admin_token: impl Into, + database_url: Option<&str>, + quota_configuration: CoordinatorQuotaConfiguration, + ) -> Result { + Self::try_new_with_optional_admin_token_admission_and_database_url( + coordinator_epoch, + Some(admin_token.into()), + CoordinatorAdmission::default(), + database_url, + quota_configuration, + ) + } + + fn try_new_with_optional_admin_token_admission_and_database_url( + coordinator_epoch: u64, + admin_token: Option, + admission: CoordinatorAdmission, + database_url: Option<&str>, + quota_configuration: CoordinatorQuotaConfiguration, + ) -> Result { + let mut store = RuntimeDurableStore::from_database_url(database_url) + .map_err(CoordinatorServiceError::Durable)?; + let coordinator = Coordinator::try_boot(&mut store, coordinator_epoch) + .map_err(CoordinatorServiceError::Durable)?; + let admin_token_digest = admin_token + .filter(|token| !token.trim().is_empty()) + .map(Digest::sha256); + Ok(Self { + coordinator, + store, + node_descriptors: BTreeMap::new(), + enrollment_grants: BTreeMap::new(), + task_events: VecDeque::new(), + debug_audit_events: VecDeque::new(), + debug_epochs: BTreeMap::new(), + debug_epoch_runtime: BTreeMap::new(), + debug_breakpoints: BTreeMap::new(), + debug_commands: BTreeMap::new(), + task_assignments: BTreeMap::new(), + task_restart_checkpoints: BTreeMap::new(), + task_restart_checkpoint_order: VecDeque::new(), + main_runtime: main_runtime::CoordinatorMainRuntime::default(), + pending_task_launches: VecDeque::new(), + task_placements: BTreeMap::new(), + active_tasks: BTreeSet::new(), + task_cancellations: BTreeSet::new(), + task_aborts: BTreeSet::new(), + process_cancellations: BTreeSet::new(), + process_aborts: BTreeSet::new(), + agent_replay_nonces: BTreeMap::new(), + node_replay_nonces: BTreeMap::new(), + panel_snapshots: BTreeMap::new(), + stopped_panels: BTreeSet::new(), + panel_event_limits: BTreeMap::new(), + artifact_registry: ArtifactRegistry::default(), + artifact_reverse_transfers: BTreeMap::new(), + artifact_transfer_by_token: BTreeMap::new(), + transport: NativeQuicTransport, + quota: quota::CoordinatorQuota::new(quota_configuration), + admission, + #[cfg(test)] + server_time_override: None, + admin_token_digest, + admin_replay_nonces: BTreeMap::new(), + }) + } + + pub fn durable_store_kind(&self) -> &'static str { + self.store.kind() + } + + fn persist_durable_state(&mut self) -> Result<(), CoordinatorServiceError> { + self.coordinator + .try_persist(&mut self.store) + .map_err(CoordinatorServiceError::Durable) + } + + fn current_epoch_seconds(&self) -> Result { + #[cfg(test)] + if let Some(now) = self.server_time_override { + return Ok(now); + } + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|err| CoordinatorServiceError::Protocol(format!("system clock error: {err}"))) + } + + fn handle_quota_status( + &self, + tenant: String, + project: String, + actor_user: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let durable_project = self.coordinator.project(&project).ok_or_else(|| { + CoordinatorError::Unauthorized("quota status requires an existing project".to_owned()) + })?; + if durable_project.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "quota status project is outside the tenant scope".to_owned(), + ) + .into()); + } + let now_epoch_seconds = self.current_epoch_seconds()?; + let status = self + .quota + .project_status(&tenant, &project, now_epoch_seconds); + Ok(CoordinatorResponse::QuotaStatus { + tenant, + project, + actor, + policy_label: status.policy_label, + limits: status.limits, + window_seconds: status.window_seconds, + usage: status.usage, + window_started_epoch_seconds: status.window_started_epoch_seconds, + }) + } + + #[cfg(test)] + fn set_server_time(&mut self, now_epoch_seconds: u64) { + self.server_time_override = Some(now_epoch_seconds); + } + + pub fn issue_cli_session( + &mut self, + tenant: TenantId, + project: ProjectId, + user: UserId, + session_secret: &str, + expires_at_epoch_seconds: Option, + ) -> Result { + if let Some(existing) = self.coordinator.project(&project) { + if existing.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "CLI session project belongs to a different tenant".to_owned(), + ) + .into()); + } + } else { + self.coordinator + .upsert_project(tenant.clone(), project.clone(), "Session project"); + } + self.coordinator + .grant_project_debug(tenant.clone(), project.clone(), user.clone()); + let record = self.coordinator.issue_cli_session( + tenant, + project, + user, + session_secret, + expires_at_epoch_seconds, + ); + self.persist_durable_state()?; + Ok(record) + } + + pub fn revoke_cli_session( + &mut self, + session_secret: &str, + ) -> Result { + let record = self.coordinator.revoke_cli_session(session_secret)?; + self.persist_durable_state()?; + Ok(record) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/disasmer-coordinator/src/service/admin.rs b/crates/disasmer-coordinator/src/service/admin.rs new file mode 100644 index 0000000..8e9866b --- /dev/null +++ b/crates/disasmer-coordinator/src/service/admin.rs @@ -0,0 +1,147 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{ + admin_request_proof_from_token_digest, CredentialKind, Digest, TenantId, UserId, +}; + +use crate::CoordinatorError; + +use super::{CoordinatorResponse, CoordinatorService, CoordinatorServiceError}; + +const ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS: u64 = 300; + +impl CoordinatorService { + pub(super) fn handle_admin_status( + &mut self, + tenant: String, + actor_user: String, + admin_proof: Digest, + admin_nonce: String, + issued_at_epoch_seconds: u64, + ) -> Result { + self.verify_admin_request( + "admin_status", + &tenant, + &actor_user, + &tenant, + &admin_proof, + &admin_nonce, + issued_at_epoch_seconds, + )?; + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + Ok(CoordinatorResponse::AdminStatus { + suspended: self.coordinator.tenant_suspended(&tenant), + tenant, + actor, + safe_default: "read_only".to_owned(), + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_suspend_tenant( + &mut self, + tenant: String, + actor_user: String, + target_tenant: String, + admin_proof: Digest, + admin_nonce: String, + issued_at_epoch_seconds: u64, + ) -> Result { + self.verify_admin_request( + "suspend_tenant", + &tenant, + &actor_user, + &target_tenant, + &admin_proof, + &admin_nonce, + issued_at_epoch_seconds, + )?; + let actor_tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let target_tenant = TenantId::new(target_tenant); + self.coordinator.upsert_tenant(actor_tenant.clone()); + self.coordinator.upsert_user( + actor_tenant, + actor.clone(), + CredentialKind::CliDeviceSession, + ); + let policy = self + .coordinator + .suspend_tenant(target_tenant.clone(), actor.clone()); + self.persist_durable_state()?; + Ok(CoordinatorResponse::TenantSuspended { + tenant: target_tenant, + actor, + policy, + }) + } + + #[allow(clippy::too_many_arguments)] + fn verify_admin_request( + &mut self, + operation: &str, + tenant: &str, + actor_user: &str, + target_tenant: &str, + admin_proof: &Digest, + admin_nonce: &str, + issued_at_epoch_seconds: u64, + ) -> Result<(), CoordinatorServiceError> { + let expected = self.admin_token_digest.as_ref().ok_or_else(|| { + CoordinatorError::Unauthorized( + "self-hosted admin credential is not configured".to_owned(), + ) + })?; + if admin_nonce.trim().is_empty() || admin_nonce.len() > 256 { + return Err(CoordinatorError::Unauthorized( + "admin request nonce is missing or invalid".to_owned(), + ) + .into()); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + if now.abs_diff(issued_at_epoch_seconds) > ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS { + return Err(CoordinatorError::Unauthorized( + "admin request timestamp is outside the allowed 300-second window".to_owned(), + ) + .into()); + } + let expected_proof = admin_request_proof_from_token_digest( + expected, + operation, + tenant, + actor_user, + target_tenant, + admin_nonce, + issued_at_epoch_seconds, + ); + if admin_proof != &expected_proof { + return Err(CoordinatorError::Unauthorized( + "admin request proof is invalid".to_owned(), + ) + .into()); + } + self.admin_replay_nonces.retain(|_, issued_at| { + now <= issued_at.saturating_add(ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS) + }); + if self.admin_replay_nonces.contains_key(admin_nonce) { + return Err(CoordinatorError::Unauthorized( + "admin request nonce was already used".to_owned(), + ) + .into()); + } + if self.admin_replay_nonces.len() >= super::MAX_REPLAY_NONCES_PER_AUTHORITY { + return Err(CoordinatorError::Unauthorized( + "admin request replay window is full; retry after the bounded signature window advances" + .to_owned(), + ) + .into()); + } + self.admin_replay_nonces + .insert(admin_nonce.to_owned(), issued_at_epoch_seconds); + Ok(()) + } +} diff --git a/crates/disasmer-coordinator/src/service/artifacts.rs b/crates/disasmer-coordinator/src/service/artifacts.rs new file mode 100644 index 0000000..38430b5 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/artifacts.rs @@ -0,0 +1,649 @@ +use std::io::{Read, Seek, SeekFrom, Write}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::{ + generate_opaque_token, Actor, ArtifactId, AuthContext, DataPlaneObject, DataPlaneScope, Digest, + DownloadPolicy, NodeEndpoint, NodeId, ProjectId, RendezvousRequest, ResourceLimits, + ResourceMeter, StorageLocation, TenantId, UserId, +}; +use sha2::{Digest as _, Sha256}; + +use crate::CoordinatorError; + +use super::{ + bounded_ttl, ArtifactTransferAssignment, CoordinatorResponse, CoordinatorService, + CoordinatorServiceError, +}; + +pub(super) const MAX_ARTIFACT_REVERSE_CHUNK_BYTES: u64 = 256 * 1024; +const MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS: usize = 4; + +#[derive(Debug)] +pub(super) struct ArtifactReverseTransfer { + transfer_id: String, + token_digest: Digest, + tenant: TenantId, + project: ProjectId, + source_node: NodeId, + artifact: ArtifactId, + expected_digest: Digest, + expected_size_bytes: u64, + expires_at_epoch_seconds: u64, + spool: tempfile::NamedTempFile, + received_bytes: u64, + content_hasher: Sha256, + delivered_offset: u64, + error: Option, +} + +impl CoordinatorService { + pub(super) fn handle_create_artifact_download_link( + &mut self, + tenant: String, + project: String, + actor_user: String, + artifact: String, + max_bytes: u64, + ttl_seconds: u64, + ) -> Result { + let context = user_context(tenant, project, actor_user); + let artifact = ArtifactId::new(artifact); + let policy = DownloadPolicy { max_bytes }; + let action = self + .artifact_registry + .download_action(&context, &artifact, &policy)?; + self.ensure_download_source_connectivity(&action.source)?; + let downloadable_size = self + .artifact_registry + .downloadable_size(&context, &artifact, &policy)?; + let now_epoch_seconds = self.current_epoch_seconds()?; + self.quota.can_charge_download( + &context.tenant, + &context.project, + downloadable_size, + now_epoch_seconds, + )?; + let token_nonce = generate_opaque_token("artifact_download") + .map_err(CoordinatorServiceError::Protocol)?; + let ttl_seconds = bounded_ttl( + ttl_seconds, + self.admission.max_artifact_download_ttl_seconds, + ); + let link = self.artifact_registry.create_download_link( + &context, + &artifact, + &policy, + &token_nonce, + now_epoch_seconds, + ttl_seconds, + )?; + Ok(CoordinatorResponse::ArtifactDownloadLink { link }) + } + + pub(super) fn handle_open_artifact_download_stream( + &mut self, + tenant: String, + project: String, + actor_user: String, + artifact: String, + max_bytes: u64, + token_digest: Digest, + chunk_bytes: u64, + ) -> Result { + let context = user_context(tenant, project, actor_user); + let artifact = ArtifactId::new(artifact); + let policy = DownloadPolicy { max_bytes }; + let now_epoch_seconds = self.current_epoch_seconds()?; + self.artifact_registry + .expire_download_links(now_epoch_seconds); + let downloadable_size = self + .artifact_registry + .downloadable_size(&context, &artifact, &policy)?; + let validation_limits = ResourceLimits::unlimited(); + let mut validation_meter = ResourceMeter::default(); + let mut stream = self.artifact_registry.open_download_stream( + disasmer_core::DownloadStreamRequest { + context: &context, + artifact: &artifact, + policy: &policy, + presented_token_digest: &token_digest, + now_epoch_seconds, + limits: &validation_limits, + }, + &mut validation_meter, + )?; + self.ensure_download_source_connectivity(&stream.link.source)?; + self.expire_artifact_reverse_transfers(now_epoch_seconds); + + if let Some(transfer_id) = self.artifact_transfer_by_token.get(&token_digest).cloned() { + if let Some(message) = self + .artifact_reverse_transfers + .get(&transfer_id) + .and_then(|transfer| transfer.error.clone()) + { + self.artifact_reverse_transfers.remove(&transfer_id); + self.artifact_transfer_by_token.remove(&token_digest); + return Err(CoordinatorServiceError::Protocol(format!( + "retaining node could not stream artifact: {message}" + ))); + } + let (content_offset, content, end, complete) = { + let transfer = self + .artifact_reverse_transfers + .get_mut(&transfer_id) + .ok_or_else(|| { + CoordinatorServiceError::Protocol( + "artifact reverse transfer index is inconsistent".to_owned(), + ) + })?; + if transfer.tenant != context.tenant + || transfer.project != context.project + || transfer.artifact != artifact + || transfer.token_digest != token_digest + { + return Err(disasmer_core::DownloadError::InvalidToken.into()); + } + if transfer.received_bytes != transfer.expected_size_bytes { + return Ok(CoordinatorResponse::ArtifactDownloadStream { + link: stream.link, + streamed_bytes: 0, + charged_download_bytes: self.quota.used_download_bytes( + &context.tenant, + &context.project, + now_epoch_seconds, + ), + content_bytes_available: false, + content_offset: None, + content_eof: false, + content_base64: None, + content_source: Some("retaining_node_reverse_stream_pending".to_owned()), + }); + } + if chunk_bytes == 0 && downloadable_size != 0 { + return Err(CoordinatorServiceError::Protocol( + "artifact download chunk_bytes must be greater than zero".to_owned(), + )); + } + let requested = chunk_bytes + .min(downloadable_size) + .min(MAX_ARTIFACT_REVERSE_CHUNK_BYTES); + let start = transfer.delivered_offset; + let end = start.saturating_add(requested).min(transfer.received_bytes); + let length = usize::try_from(end.saturating_sub(start)).map_err(|_| { + CoordinatorServiceError::Protocol( + "artifact download chunk length does not fit memory bounds".to_owned(), + ) + })?; + let mut content = vec![0_u8; length]; + let mut spool = transfer.spool.reopen().map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "open bounded artifact transfer spool: {error}" + )) + })?; + spool.seek(SeekFrom::Start(start)).map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "seek bounded artifact transfer spool: {error}" + )) + })?; + spool.read_exact(&mut content).map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "read bounded artifact transfer spool: {error}" + )) + })?; + (start, content, end, end == transfer.received_bytes) + }; + let streamed_bytes = content.len() as u64; + self.artifact_registry.stream_download_chunk( + &mut stream, + &validation_limits, + &mut validation_meter, + streamed_bytes, + )?; + let charged_download_bytes = self.quota.charge_download( + &context.tenant, + &context.project, + streamed_bytes, + now_epoch_seconds, + )?; + if let Some(transfer) = self.artifact_reverse_transfers.get_mut(&transfer_id) { + transfer.delivered_offset = end; + } + if complete { + self.artifact_reverse_transfers.remove(&transfer_id); + self.artifact_transfer_by_token.remove(&token_digest); + } + return Ok(CoordinatorResponse::ArtifactDownloadStream { + link: stream.link, + streamed_bytes, + charged_download_bytes, + content_bytes_available: true, + content_offset: Some(content_offset), + content_eof: complete, + content_base64: Some(BASE64_STANDARD.encode(content)), + content_source: Some("retaining_node_reverse_stream".to_owned()), + }); + } + + if self.artifact_reverse_transfers.len() >= MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS { + return Err(CoordinatorServiceError::Protocol(format!( + "artifact reverse transfer concurrency limit of {MAX_CONCURRENT_ARTIFACT_REVERSE_TRANSFERS} reached" + ))); + } + let StorageLocation::RetainedNode(source_node) = &stream.link.source else { + return Err(disasmer_core::DownloadError::Unavailable.into()); + }; + let metadata = self + .artifact_registry + .metadata(&artifact) + .ok_or(disasmer_core::DownloadError::NotFound)?; + let transfer_id = generate_opaque_token("artifact_transfer") + .map_err(CoordinatorServiceError::Protocol)?; + let spool = tempfile::Builder::new() + .prefix("disasmer-artifact-transfer-") + .tempfile() + .map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "create bounded artifact transfer spool: {error}" + )) + })?; + let transfer = ArtifactReverseTransfer { + transfer_id: transfer_id.clone(), + token_digest: token_digest.clone(), + tenant: context.tenant.clone(), + project: context.project.clone(), + source_node: source_node.clone(), + artifact, + expected_digest: metadata.digest.clone(), + expected_size_bytes: metadata.size, + expires_at_epoch_seconds: stream.link.expires_at_epoch_seconds, + spool, + received_bytes: 0, + content_hasher: Sha256::new(), + delivered_offset: 0, + error: None, + }; + self.artifact_reverse_transfers + .insert(transfer_id.clone(), transfer); + self.artifact_transfer_by_token + .insert(token_digest, transfer_id); + Ok(CoordinatorResponse::ArtifactDownloadStream { + link: stream.link, + streamed_bytes: 0, + charged_download_bytes: self.quota.used_download_bytes( + &context.tenant, + &context.project, + now_epoch_seconds, + ), + content_bytes_available: false, + content_offset: None, + content_eof: false, + content_base64: None, + content_source: Some("retaining_node_reverse_stream_pending".to_owned()), + }) + } + + pub(super) fn handle_revoke_artifact_download_link( + &mut self, + tenant: String, + project: String, + actor_user: String, + artifact: String, + token_digest: Digest, + ) -> Result { + let context = user_context(tenant, project, actor_user); + let now_epoch_seconds = self.current_epoch_seconds()?; + self.artifact_registry + .expire_download_links(now_epoch_seconds); + let link = self.artifact_registry.revoke_download_link( + &context, + &ArtifactId::new(artifact), + &token_digest, + )?; + if let Some(transfer_id) = self.artifact_transfer_by_token.remove(&token_digest) { + self.artifact_reverse_transfers.remove(&transfer_id); + } + Ok(CoordinatorResponse::ArtifactDownloadLinkRevoked { link }) + } + + pub(super) fn handle_export_artifact_to_node( + &mut self, + tenant: String, + project: String, + actor_user: String, + artifact: String, + receiver_node: String, + direct_connectivity: bool, + failure_reason: String, + ) -> Result { + let context = user_context(tenant, project, actor_user); + let artifact = ArtifactId::new(artifact); + let receiver_node = NodeId::new(receiver_node); + let action = self.artifact_registry.download_action( + &context, + &artifact, + &DownloadPolicy { + max_bytes: u64::MAX, + }, + )?; + let StorageLocation::RetainedNode(source_node) = action.source else { + return Err(disasmer_core::DownloadError::Unavailable.into()); + }; + let metadata = self + .artifact_registry + .metadata(&artifact) + .ok_or(disasmer_core::DownloadError::NotFound)?; + let source = self.export_endpoint(&source_node, &context.tenant, &context.project)?; + let destination = + self.export_endpoint(&receiver_node, &context.tenant, &context.project)?; + let plan = self.transport.plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: DataPlaneScope { + tenant: context.tenant.clone(), + project: context.project.clone(), + process: metadata.process.clone(), + object: DataPlaneObject::Artifact(artifact.clone()), + authorization_subject: format!( + "artifact-export:{}-to-{}", + source_node, receiver_node + ), + }, + source, + destination, + }, + direct_connectivity, + failure_reason, + )?; + Ok(CoordinatorResponse::ArtifactExportPlan { + plan, + source_node, + receiver_node, + artifact_size_bytes: metadata.size, + }) + } + + pub(super) fn handle_poll_artifact_transfer( + &mut self, + tenant: String, + project: String, + node: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + self.authorize_artifact_transfer_node(&tenant, &project, &node)?; + let transfer = self.artifact_reverse_transfers.values().find(|transfer| { + transfer.tenant == tenant + && transfer.project == project + && transfer.source_node == node + && transfer.error.is_none() + && transfer.received_bytes < transfer.expected_size_bytes + }); + Ok(CoordinatorResponse::ArtifactTransferAssignment { + transfer: transfer.map(|transfer| ArtifactTransferAssignment { + transfer_id: transfer.transfer_id.clone(), + artifact: transfer.artifact.clone(), + expected_digest: transfer.expected_digest.clone(), + expected_size_bytes: transfer.expected_size_bytes, + offset: transfer.received_bytes, + max_chunk_bytes: MAX_ARTIFACT_REVERSE_CHUNK_BYTES, + }), + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_upload_artifact_transfer_chunk( + &mut self, + tenant: String, + project: String, + node: String, + transfer_id: String, + artifact: String, + offset: u64, + content_base64: String, + chunk_digest: Digest, + eof: bool, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + self.authorize_artifact_transfer_node(&tenant, &project, &node)?; + let transfer = self + .artifact_reverse_transfers + .get_mut(&transfer_id) + .ok_or_else(|| { + CoordinatorServiceError::Protocol("unknown artifact reverse transfer".to_owned()) + })?; + if transfer.tenant != tenant + || transfer.project != project + || transfer.source_node != node + || transfer.artifact.as_str() != artifact + { + return Err(CoordinatorError::Unauthorized( + "artifact reverse transfer is outside the signed node scope".to_owned(), + ) + .into()); + } + if transfer.received_bytes != offset { + return Err(CoordinatorServiceError::Protocol(format!( + "artifact reverse transfer expected offset {}, received {offset}", + transfer.received_bytes + ))); + } + let content = BASE64_STANDARD.decode(content_base64).map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "artifact reverse transfer chunk is not valid base64: {error}" + )) + })?; + if content.len() as u64 > MAX_ARTIFACT_REVERSE_CHUNK_BYTES { + return Err(CoordinatorServiceError::Protocol( + "artifact reverse transfer chunk exceeds the control-plane limit".to_owned(), + )); + } + if Digest::sha256(&content) != chunk_digest { + return Err(CoordinatorServiceError::Protocol( + "artifact reverse transfer chunk digest mismatch".to_owned(), + )); + } + let next_offset = offset.saturating_add(content.len() as u64); + if next_offset > transfer.expected_size_bytes { + return Err(CoordinatorServiceError::Protocol( + "artifact reverse transfer exceeds retained metadata size".to_owned(), + )); + } + let complete = next_offset == transfer.expected_size_bytes; + if eof != complete { + return Err(CoordinatorServiceError::Protocol( + "artifact reverse transfer EOF does not match retained metadata size".to_owned(), + )); + } + transfer + .spool + .as_file_mut() + .seek(SeekFrom::Start(offset)) + .and_then(|_| transfer.spool.as_file_mut().write_all(&content)) + .map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "write bounded artifact transfer spool: {error}" + )) + })?; + transfer.content_hasher.update(&content); + transfer.received_bytes = next_offset; + if complete { + transfer.spool.as_file().sync_all().map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "sync bounded artifact transfer spool: {error}" + )) + })?; + let digest_hex = format!("{:x}", transfer.content_hasher.clone().finalize()); + let digest = + Digest::from_sha256_hex(&digest_hex).map_err(CoordinatorServiceError::Protocol)?; + if digest != transfer.expected_digest { + transfer.spool.as_file_mut().set_len(0).map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "reset invalid artifact transfer spool: {error}" + )) + })?; + transfer.received_bytes = 0; + transfer.content_hasher = Sha256::new(); + return Err(CoordinatorServiceError::Protocol( + "artifact reverse transfer content digest mismatch".to_owned(), + )); + } + } + Ok(CoordinatorResponse::ArtifactTransferChunkAccepted { + transfer_id, + next_offset, + complete, + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_fail_artifact_transfer( + &mut self, + tenant: String, + project: String, + node: String, + transfer_id: String, + artifact: String, + message: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + self.authorize_artifact_transfer_node(&tenant, &project, &node)?; + let transfer = self + .artifact_reverse_transfers + .get_mut(&transfer_id) + .ok_or_else(|| { + CoordinatorServiceError::Protocol("unknown artifact reverse transfer".to_owned()) + })?; + if transfer.tenant != tenant + || transfer.project != project + || transfer.source_node != node + || transfer.artifact.as_str() != artifact + { + return Err(CoordinatorError::Unauthorized( + "artifact reverse transfer failure is outside the signed node scope".to_owned(), + ) + .into()); + } + let message = message.trim(); + transfer.error = Some(if message.is_empty() { + "retained artifact bytes are unavailable".to_owned() + } else { + message.chars().take(1024).collect() + }); + Ok(CoordinatorResponse::ArtifactTransferFailed { transfer_id }) + } + + fn authorize_artifact_transfer_node( + &self, + tenant: &TenantId, + project: &ProjectId, + node: &NodeId, + ) -> Result<(), CoordinatorServiceError> { + let identity = self + .coordinator + .node_identity(node) + .ok_or(CoordinatorError::UnknownNode)?; + if &identity.tenant != tenant || &identity.project != project { + return Err(CoordinatorError::Unauthorized( + "artifact reverse transfer node is outside its enrolled tenant/project scope" + .to_owned(), + ) + .into()); + } + Ok(()) + } + + fn expire_artifact_reverse_transfers(&mut self, now_epoch_seconds: u64) { + let expired = self + .artifact_reverse_transfers + .iter() + .filter(|(_, transfer)| transfer.expires_at_epoch_seconds < now_epoch_seconds) + .map(|(id, transfer)| (id.clone(), transfer.token_digest.clone())) + .collect::>(); + for (id, token) in expired { + self.artifact_reverse_transfers.remove(&id); + self.artifact_transfer_by_token.remove(&token); + } + } + + fn export_endpoint( + &self, + node: &NodeId, + tenant: &TenantId, + project: &ProjectId, + ) -> Result { + let identity = self + .coordinator + .node_identity(node) + .ok_or(CoordinatorError::UnknownNode)?; + if &identity.tenant != tenant || &identity.project != project { + return Err(CoordinatorError::Unauthorized( + "artifact export node is outside the tenant/project scope".to_owned(), + ) + .into()); + } + let descriptor = self.node_descriptors.get(node).ok_or_else(|| { + disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + "node {node} has not reported export connectivity" + )) + })?; + if descriptor.tenant != *tenant || descriptor.project != *project { + return Err(CoordinatorError::Unauthorized( + "artifact export node descriptor is outside the tenant/project scope".to_owned(), + ) + .into()); + } + if !descriptor.online { + return Err( + disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + "node {node} is offline for artifact export" + )) + .into(), + ); + } + if !descriptor.direct_connectivity { + return Err( + disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + "direct connectivity unavailable to node {node} for artifact export" + )) + .into(), + ); + } + Ok(NodeEndpoint { + node: node.clone(), + advertised_addr: format!("{node}.mesh.invalid:4433"), + public_key_fingerprint: Digest::sha256(&identity.public_key), + }) + } + + fn ensure_download_source_connectivity( + &self, + source: &StorageLocation, + ) -> Result<(), disasmer_core::DownloadError> { + let StorageLocation::RetainedNode(node) = source else { + return Ok(()); + }; + let descriptor = self.node_descriptors.get(node).ok_or_else(|| { + disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + "retaining node {node} has not reported online status for artifact download" + )) + })?; + if !descriptor.online { + return Err(disasmer_core::DownloadError::DirectConnectivityUnavailable( + format!("retaining node {node} is offline for artifact download"), + )); + } + Ok(()) + } +} + +fn user_context(tenant: String, project: String, actor_user: String) -> AuthContext { + AuthContext { + tenant: TenantId::new(tenant), + project: ProjectId::new(project), + actor: Actor::User(UserId::new(actor_user)), + } +} diff --git a/crates/disasmer-coordinator/src/service/authenticated.rs b/crates/disasmer-coordinator/src/service/authenticated.rs new file mode 100644 index 0000000..389ba2c --- /dev/null +++ b/crates/disasmer-coordinator/src/service/authenticated.rs @@ -0,0 +1,139 @@ +use disasmer_core::{AuthContext, UserId}; + +use super::{ + AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService, + CoordinatorServiceError, +}; + +impl CoordinatorService { + pub(super) fn handle_authenticated_agent_key_request( + &mut self, + context: &AuthContext, + actor: &UserId, + request: AuthenticatedCoordinatorRequest, + ) -> Result { + let request = match request { + AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { agent, public_key } => { + CoordinatorRequest::RegisterAgentPublicKey { + tenant: context.tenant.as_str().to_owned(), + project: context.project.as_str().to_owned(), + user: actor.as_str().to_owned(), + agent, + public_key, + } + } + AuthenticatedCoordinatorRequest::ListAgentPublicKeys => { + CoordinatorRequest::ListAgentPublicKeys { + tenant: context.tenant.as_str().to_owned(), + project: context.project.as_str().to_owned(), + user: actor.as_str().to_owned(), + } + } + AuthenticatedCoordinatorRequest::RotateAgentPublicKey { agent, public_key } => { + CoordinatorRequest::RotateAgentPublicKey { + tenant: context.tenant.as_str().to_owned(), + project: context.project.as_str().to_owned(), + user: actor.as_str().to_owned(), + agent, + public_key, + } + } + AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { agent } => { + CoordinatorRequest::RevokeAgentPublicKey { + tenant: context.tenant.as_str().to_owned(), + project: context.project.as_str().to_owned(), + user: actor.as_str().to_owned(), + agent, + } + } + _ => unreachable!("caller filters authenticated agent key operations"), + }; + self.handle_request(request) + } + + pub(super) fn handle_authenticated_launch_task( + &mut self, + context: &AuthContext, + actor: &UserId, + request: AuthenticatedCoordinatorRequest, + ) -> Result { + let AuthenticatedCoordinatorRequest::LaunchTask { + task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + } = request + else { + unreachable!("caller filters authenticated task launches"); + }; + self.handle_launch_task( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + Some(actor.as_str().to_owned()), + None, + None, + None, + None, + *task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + ) + } + + pub(super) fn handle_authenticated_schedule_task( + &mut self, + context: &AuthContext, + request: AuthenticatedCoordinatorRequest, + ) -> Result { + let AuthenticatedCoordinatorRequest::ScheduleTask { + environment, + environment_digest, + required_capabilities, + dependency_cache, + source_snapshot, + required_artifacts, + prefer_node, + } = request + else { + unreachable!("caller filters authenticated task scheduling"); + }; + self.handle_schedule_task( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + environment, + environment_digest, + required_capabilities, + dependency_cache, + source_snapshot, + required_artifacts, + prefer_node, + ) + } + + pub(super) fn handle_authenticated_artifact_export( + &mut self, + context: &AuthContext, + actor: &UserId, + request: AuthenticatedCoordinatorRequest, + ) -> Result { + let AuthenticatedCoordinatorRequest::ExportArtifactToNode { + artifact, + receiver_node, + direct_connectivity, + failure_reason, + } = request + else { + unreachable!("caller filters authenticated artifact exports"); + }; + self.handle_export_artifact_to_node( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + artifact, + receiver_node, + direct_connectivity, + failure_reason, + ) + } +} diff --git a/crates/disasmer-coordinator/src/service/authorization.rs b/crates/disasmer-coordinator/src/service/authorization.rs new file mode 100644 index 0000000..c1c9dfc --- /dev/null +++ b/crates/disasmer-coordinator/src/service/authorization.rs @@ -0,0 +1,198 @@ +use disasmer_core::{Actor, AuthContext, UserId}; + +use crate::CoordinatorError; + +use super::{AuthenticatedCoordinatorRequest, CoordinatorServiceError}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PublicUserOperation { + AuthStatus, + RevokeCliSession, + CreateProject, + SelectProject, + ListProjects, + RegisterAgentPublicKey, + ListAgentPublicKeys, + RotateAgentPublicKey, + RevokeAgentPublicKey, + CreateNodeEnrollmentGrant, + ListNodeDescriptors, + RevokeNodeCredential, + StartProcess, + ScheduleTask, + LaunchTask, + CancelProcess, + AbortProcess, + ListProcesses, + QuotaStatus, + RestartTask, + DebugAttach, + SetDebugBreakpoints, + InspectDebugBreakpoints, + CreateDebugEpoch, + ResumeDebugEpoch, + InspectDebugEpoch, + ListTaskEvents, + JoinTask, + CreateArtifactDownloadLink, + OpenArtifactDownloadStream, + RevokeArtifactDownloadLink, + ExportArtifactToNode, +} + +impl PublicUserOperation { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::AuthStatus => "auth_status", + Self::RevokeCliSession => "revoke_cli_session", + Self::CreateProject => "create_project", + Self::SelectProject => "select_project", + Self::ListProjects => "list_projects", + Self::RegisterAgentPublicKey => "register_agent_public_key", + Self::ListAgentPublicKeys => "list_agent_public_keys", + Self::RotateAgentPublicKey => "rotate_agent_public_key", + Self::RevokeAgentPublicKey => "revoke_agent_public_key", + Self::CreateNodeEnrollmentGrant => "create_node_enrollment_grant", + Self::ListNodeDescriptors => "list_node_descriptors", + Self::RevokeNodeCredential => "revoke_node_credential", + Self::StartProcess => "start_process", + Self::ScheduleTask => "schedule_task", + Self::LaunchTask => "launch_task", + Self::CancelProcess => "cancel_process", + Self::AbortProcess => "abort_process", + Self::ListProcesses => "list_processes", + Self::QuotaStatus => "quota_status", + Self::RestartTask => "restart_task", + Self::DebugAttach => "debug_attach", + Self::SetDebugBreakpoints => "set_debug_breakpoints", + Self::InspectDebugBreakpoints => "inspect_debug_breakpoints", + Self::CreateDebugEpoch => "create_debug_epoch", + Self::ResumeDebugEpoch => "resume_debug_epoch", + Self::InspectDebugEpoch => "inspect_debug_epoch", + Self::ListTaskEvents => "list_task_events", + Self::JoinTask => "join_task", + Self::CreateArtifactDownloadLink => "create_artifact_download_link", + Self::OpenArtifactDownloadStream => "open_artifact_download_stream", + Self::RevokeArtifactDownloadLink => "revoke_artifact_download_link", + Self::ExportArtifactToNode => "export_artifact_to_node", + } + } +} + +impl From<&AuthenticatedCoordinatorRequest> for PublicUserOperation { + fn from(request: &AuthenticatedCoordinatorRequest) -> Self { + match request { + AuthenticatedCoordinatorRequest::AuthStatus => Self::AuthStatus, + AuthenticatedCoordinatorRequest::RevokeCliSession => Self::RevokeCliSession, + AuthenticatedCoordinatorRequest::CreateProject { .. } => Self::CreateProject, + AuthenticatedCoordinatorRequest::SelectProject { .. } => Self::SelectProject, + AuthenticatedCoordinatorRequest::ListProjects => Self::ListProjects, + AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { .. } => { + Self::RegisterAgentPublicKey + } + AuthenticatedCoordinatorRequest::ListAgentPublicKeys => Self::ListAgentPublicKeys, + AuthenticatedCoordinatorRequest::RotateAgentPublicKey { .. } => { + Self::RotateAgentPublicKey + } + AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { .. } => { + Self::RevokeAgentPublicKey + } + AuthenticatedCoordinatorRequest::CreateNodeEnrollmentGrant { .. } => { + Self::CreateNodeEnrollmentGrant + } + AuthenticatedCoordinatorRequest::ListNodeDescriptors => Self::ListNodeDescriptors, + AuthenticatedCoordinatorRequest::RevokeNodeCredential { .. } => { + Self::RevokeNodeCredential + } + AuthenticatedCoordinatorRequest::StartProcess { .. } => Self::StartProcess, + AuthenticatedCoordinatorRequest::ScheduleTask { .. } => Self::ScheduleTask, + AuthenticatedCoordinatorRequest::LaunchTask { .. } => Self::LaunchTask, + AuthenticatedCoordinatorRequest::CancelProcess { .. } => Self::CancelProcess, + AuthenticatedCoordinatorRequest::AbortProcess { .. } => Self::AbortProcess, + AuthenticatedCoordinatorRequest::ListProcesses => Self::ListProcesses, + AuthenticatedCoordinatorRequest::QuotaStatus => Self::QuotaStatus, + AuthenticatedCoordinatorRequest::RestartTask { .. } => Self::RestartTask, + AuthenticatedCoordinatorRequest::DebugAttach { .. } => Self::DebugAttach, + AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. } => { + Self::SetDebugBreakpoints + } + AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { .. } => { + Self::InspectDebugBreakpoints + } + AuthenticatedCoordinatorRequest::CreateDebugEpoch { .. } => Self::CreateDebugEpoch, + AuthenticatedCoordinatorRequest::ResumeDebugEpoch { .. } => Self::ResumeDebugEpoch, + AuthenticatedCoordinatorRequest::InspectDebugEpoch { .. } => Self::InspectDebugEpoch, + AuthenticatedCoordinatorRequest::ListTaskEvents { .. } => Self::ListTaskEvents, + AuthenticatedCoordinatorRequest::JoinTask { .. } => Self::JoinTask, + AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink { .. } => { + Self::CreateArtifactDownloadLink + } + AuthenticatedCoordinatorRequest::OpenArtifactDownloadStream { .. } => { + Self::OpenArtifactDownloadStream + } + AuthenticatedCoordinatorRequest::RevokeArtifactDownloadLink { .. } => { + Self::RevokeArtifactDownloadLink + } + AuthenticatedCoordinatorRequest::ExportArtifactToNode { .. } => { + Self::ExportArtifactToNode + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct AuthorizedPublicUser { + pub(super) actor: UserId, + pub(super) operation: PublicUserOperation, +} + +pub(super) fn authorize_authenticated_user_operation( + context: &AuthContext, + request: &AuthenticatedCoordinatorRequest, +) -> Result { + let operation = PublicUserOperation::from(request); + let actor = match &context.actor { + Actor::User(user) => user.clone(), + _ => { + return Err(CoordinatorError::Unauthorized(format!( + "authenticated {} request requires a user CLI session", + operation.as_str() + )) + .into()); + } + }; + Ok(AuthorizedPublicUser { actor, operation }) +} + +#[cfg(test)] +mod tests { + use disasmer_core::{AgentId, ProjectId, TenantId}; + + use super::*; + + #[test] + fn authenticated_public_authorization_requires_user_context_and_names_operation() { + let request = AuthenticatedCoordinatorRequest::DebugAttach { + process: "vp".to_owned(), + }; + let agent_context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::Agent(AgentId::from("agent-ci")), + }; + + let denied = authorize_authenticated_user_operation(&agent_context, &request).unwrap_err(); + assert!(denied + .to_string() + .contains("authenticated debug_attach request requires a user CLI session")); + + let user_context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let authorized = authorize_authenticated_user_operation(&user_context, &request).unwrap(); + assert_eq!(authorized.actor, UserId::from("user")); + assert_eq!(authorized.operation, PublicUserOperation::DebugAttach); + } +} diff --git a/crates/disasmer-coordinator/src/service/debug.rs b/crates/disasmer-coordinator/src/service/debug.rs new file mode 100644 index 0000000..89a8d9b --- /dev/null +++ b/crates/disasmer-coordinator/src/service/debug.rs @@ -0,0 +1,995 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use disasmer_core::{ + Actor, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId, + WasmHostDebugProbeResult, WASM_TASK_ABI_VERSION, +}; + +use crate::{CoordinatorError, CoordinatorServiceError}; + +use super::keys::{process_control_key, task_control_key, task_restart_key, TaskControlKey}; +use super::{ + CoordinatorResponse, CoordinatorService, DebugAcknowledgementState, DebugAuditEvent, + DebugParticipantAcknowledgement, TaskCancellationTarget, DEBUG_CONTROL_READ_BYTES, +}; + +mod validation; +use validation::{runtime_all_in_state, validate_debug_snapshot, validate_probe_symbols}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct DebugPendingCommand { + pub(super) epoch: u64, + pub(super) command: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct DebugEpochRuntime { + pub(super) epoch: u64, + pub(super) command: String, + pub(super) expected: BTreeSet, + pub(super) acknowledgements: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct DebugBreakpointPlan { + pub(super) actor: UserId, + pub(super) probe_symbols: BTreeSet, + pub(super) hit_epoch: Option, + pub(super) hit_task: Option, + pub(super) hit_probe_symbol: Option, +} + +impl CoordinatorService { + pub(super) fn handle_debug_attach( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + let audit_event = self.record_debug_audit_event( + tenant, + project, + process.clone(), + None, + actor.clone(), + "debug_attach", + authorization.allowed, + authorization.reason.clone(), + )?; + Ok(CoordinatorResponse::DebugAttach { + process, + actor, + authorization, + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, + }) + } + + pub(super) fn handle_set_debug_breakpoints( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + probe_symbols: Vec, + ) -> Result { + let probe_symbols = validate_probe_symbols(probe_symbols)?; + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + let audit_event = self.record_debug_audit_event( + tenant.clone(), + project.clone(), + process.clone(), + None, + actor.clone(), + "set_debug_breakpoints", + authorization.allowed, + authorization.reason.clone(), + )?; + if !authorization.allowed { + return Err(CoordinatorError::Unauthorized(format!( + "set_debug_breakpoints denied: {}", + authorization.reason + )) + .into()); + } + self.debug_breakpoints.insert( + process_control_key(&tenant, &project, &process), + DebugBreakpointPlan { + actor: actor.clone(), + probe_symbols: probe_symbols.iter().cloned().collect(), + hit_epoch: None, + hit_task: None, + hit_probe_symbol: None, + }, + ); + Ok(CoordinatorResponse::DebugBreakpoints { + process, + actor, + probe_symbols, + hit_epoch: None, + hit_task: None, + hit_probe_symbol: None, + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, + }) + } + + pub(super) fn handle_inspect_debug_breakpoints( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + let plan = self + .debug_breakpoints + .get(&process_control_key(&tenant, &project, &process)) + .cloned() + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "no debug breakpoints are configured for {process}" + )) + })?; + if plan.actor != actor { + return Err(CoordinatorError::Unauthorized( + "debug breakpoint inspection belongs to another authenticated user".to_owned(), + ) + .into()); + } + let audit_event = self.record_debug_audit_event( + tenant, + project, + process.clone(), + plan.hit_task.clone(), + actor.clone(), + "inspect_debug_breakpoints", + authorization.allowed, + authorization.reason.clone(), + )?; + if !authorization.allowed { + return Err(CoordinatorError::Unauthorized(format!( + "inspect_debug_breakpoints denied: {}", + authorization.reason + )) + .into()); + } + Ok(CoordinatorResponse::DebugBreakpoints { + process, + actor, + probe_symbols: plan.probe_symbols.into_iter().collect(), + hit_epoch: plan.hit_epoch, + hit_task: plan.hit_task, + hit_probe_symbol: plan.hit_probe_symbol, + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, + }) + } + + pub(super) fn handle_create_debug_epoch( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + stopped_task: String, + reason: String, + ) -> Result { + self.handle_debug_epoch_control( + tenant, + project, + actor_user, + process, + Some(stopped_task), + None, + "create_debug_epoch", + "freeze", + true, + reason, + ) + } + + pub(super) fn handle_resume_debug_epoch( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + epoch: u64, + ) -> Result { + self.handle_debug_epoch_control( + tenant, + project, + actor_user, + process, + None, + Some(epoch), + "resume_debug_epoch", + "resume", + false, + "continue requested by debugger", + ) + } + + pub(super) fn handle_poll_debug_command( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskInstanceId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + let pending = self + .debug_commands + .remove(&task_control_key(&tenant, &project, &process, &node, &task)); + Ok(CoordinatorResponse::DebugCommand { + process, + task, + epoch: pending.as_ref().map(|command| command.epoch), + command: pending.map(|command| command.command), + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_report_debug_state( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + epoch: u64, + state: DebugAcknowledgementState, + stack_frames: Vec, + local_values: Vec<(String, String)>, + task_args: Vec<(String, String)>, + handles: Vec<(String, String)>, + command_status: Option, + recent_output: Vec, + message: Option, + ) -> Result { + validate_debug_snapshot( + &stack_frames, + &local_values, + &task_args, + &handles, + command_status.as_deref(), + &recent_output, + message.as_deref(), + )?; + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskInstanceId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + let participant_key = task_control_key(&tenant, &project, &process, &node, &task); + let process_key = process_control_key(&tenant, &project, &process); + let runtime = self + .debug_epoch_runtime + .get_mut(&process_key) + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "cannot acknowledge debug epoch {epoch} for {process}: no active debug epoch" + )) + })?; + if runtime.epoch != epoch { + return Err(CoordinatorServiceError::Protocol(format!( + "cannot acknowledge debug epoch {epoch} for {process}: current debug epoch is {}", + runtime.epoch + ))); + } + if !runtime.expected.contains(&participant_key) { + return Err(CoordinatorError::Unauthorized( + "debug acknowledgement is not from an expected active task participant".to_owned(), + ) + .into()); + } + let task_definition = self + .task_restart_checkpoints + .get(&task_restart_key(&tenant, &project, &process, &task)) + .map(|checkpoint| checkpoint.assignment.task_spec.task_definition.clone()) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "debug acknowledgement does not name a coordinator-issued task instance" + .to_owned(), + ) + })?; + let valid_state = matches!( + (runtime.command.as_str(), &state), + ("freeze", DebugAcknowledgementState::Frozen) + | ("resume", DebugAcknowledgementState::Running) + | (_, DebugAcknowledgementState::Failed) + ); + if !valid_state { + return Err(CoordinatorServiceError::Protocol(format!( + "debug epoch {epoch} command `{}` cannot be acknowledged as {state:?}", + runtime.command + ))); + } + runtime.acknowledgements.insert( + participant_key, + DebugParticipantAcknowledgement { + node: node.clone(), + task_definition, + task: task.clone(), + epoch, + state: state.clone(), + stack_frames, + local_values, + task_args, + handles, + command_status, + recent_output, + message, + }, + ); + Ok(CoordinatorResponse::DebugStateRecorded { + process, + node, + task, + epoch, + state, + }) + } + + pub(super) fn handle_report_debug_probe_hit( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + probe_symbol: String, + ) -> Result { + let probe_symbol = validate_probe_symbols(vec![probe_symbol])? + .into_iter() + .next() + .expect("one validated probe symbol remains one symbol"); + let tenant_id = TenantId::new(tenant.clone()); + let project_id = ProjectId::new(project.clone()); + let process_id = ProcessId::new(process.clone()); + let node_id = NodeId::new(node.clone()); + let task_id = TaskInstanceId::new(task.clone()); + self.coordinator.authorize_node_for_process( + &node_id, + &tenant_id, + &project_id, + &process_id, + )?; + let participant_key = + task_control_key(&tenant_id, &project_id, &process_id, &node_id, &task_id); + if !self.active_tasks.contains(&participant_key) { + return Err(CoordinatorError::Unauthorized( + "debug probe hit is not from an active task participant".to_owned(), + ) + .into()); + } + let process_key = process_control_key(&tenant_id, &project_id, &process_id); + let Some(plan) = self.debug_breakpoints.get(&process_key).cloned() else { + return Ok(CoordinatorResponse::DebugProbeHit { + process: process_id, + node: node_id, + task: task_id, + probe_symbol, + breakpoint_matched: false, + debug_epoch: None, + }); + }; + if !plan.probe_symbols.contains(&probe_symbol) { + return Ok(CoordinatorResponse::DebugProbeHit { + process: process_id, + node: node_id, + task: task_id, + probe_symbol, + breakpoint_matched: false, + debug_epoch: None, + }); + } + let response = self.handle_debug_epoch_control( + tenant, + project, + plan.actor.as_str().to_owned(), + process, + Some(task.clone()), + None, + "wasm_debug_probe_hit", + "freeze", + true, + format!("executing Wasm reached probe `{probe_symbol}`"), + )?; + let CoordinatorResponse::DebugEpoch { epoch, .. } = response else { + unreachable!("debug epoch control always returns DebugEpoch") + }; + if let Some(plan) = self.debug_breakpoints.get_mut(&process_key) { + plan.hit_epoch = Some(epoch); + plan.hit_task = Some(task_id.clone()); + plan.hit_probe_symbol = Some(probe_symbol.clone()); + } + Ok(CoordinatorResponse::DebugProbeHit { + process: process_id, + node: node_id, + task: task_id, + probe_symbol, + breakpoint_matched: true, + debug_epoch: Some(epoch), + }) + } + + pub(super) fn handle_coordinator_main_debug_probe( + &mut self, + tenant: TenantId, + project: ProjectId, + process: ProcessId, + task: TaskInstanceId, + probe_symbol: String, + ) -> Result { + let probe_symbol = validate_probe_symbols(vec![probe_symbol])? + .into_iter() + .next() + .expect("one validated probe symbol remains one symbol"); + let process_key = process_control_key(&tenant, &project, &process); + let main_matches = self + .main_runtime + .controls + .get(&process_key) + .is_some_and(|control| control.task_instance == task); + if !main_matches { + return Err(CoordinatorError::Unauthorized( + "debug probe does not belong to the active coordinator main participant".to_owned(), + ) + .into()); + } + let Some(plan) = self.debug_breakpoints.get(&process_key).cloned() else { + return Ok(WasmHostDebugProbeResult { + abi_version: WASM_TASK_ABI_VERSION, + breakpoint_matched: false, + debug_epoch: None, + }); + }; + if !plan.probe_symbols.contains(&probe_symbol) { + return Ok(WasmHostDebugProbeResult { + abi_version: WASM_TASK_ABI_VERSION, + breakpoint_matched: false, + debug_epoch: None, + }); + } + if let Some(control) = self.main_runtime.controls.get_mut(&process_key) { + control.stopped_probe_symbol = Some(probe_symbol.clone()); + } + let response = self.handle_debug_epoch_control( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + plan.actor.as_str().to_owned(), + process.as_str().to_owned(), + Some(task.as_str().to_owned()), + None, + "wasm_debug_probe_hit", + "freeze", + true, + format!("coordinator main reached probe `{probe_symbol}`"), + )?; + let CoordinatorResponse::DebugEpoch { epoch, .. } = response else { + unreachable!("debug epoch control always returns DebugEpoch") + }; + if let Some(plan) = self.debug_breakpoints.get_mut(&process_key) { + plan.hit_epoch = Some(epoch); + plan.hit_task = Some(task); + plan.hit_probe_symbol = Some(probe_symbol); + } + Ok(WasmHostDebugProbeResult { + abi_version: WASM_TASK_ABI_VERSION, + breakpoint_matched: true, + debug_epoch: Some(epoch), + }) + } + + pub(super) fn handle_inspect_debug_epoch( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + epoch: u64, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + let process_key = process_control_key(&tenant, &project, &process); + self.sync_coordinator_main_debug_acknowledgement(&process_key, epoch); + let runtime = self + .debug_epoch_runtime + .get(&process_key) + .filter(|runtime| runtime.epoch == epoch) + .cloned() + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "debug epoch {epoch} is not active for {process}" + )) + })?; + let audit_event = self.record_debug_audit_event( + tenant, + project, + process.clone(), + None, + actor.clone(), + "inspect_debug_epoch", + authorization.allowed, + authorization.reason.clone(), + )?; + if !authorization.allowed { + return Err(CoordinatorError::Unauthorized(format!( + "inspect_debug_epoch denied: {}", + authorization.reason + )) + .into()); + } + let acknowledgements = runtime + .acknowledgements + .values() + .cloned() + .collect::>(); + let all_acknowledged = !runtime.expected.is_empty() + && runtime + .expected + .iter() + .all(|key| runtime.acknowledgements.contains_key(key)); + let fully_frozen = runtime.command == "freeze" + && all_acknowledged + && acknowledgements + .iter() + .all(|ack| ack.state == DebugAcknowledgementState::Frozen); + let fully_resumed = runtime.command == "resume" + && all_acknowledged + && acknowledgements + .iter() + .all(|ack| ack.state == DebugAcknowledgementState::Running); + let failed = acknowledgements + .iter() + .any(|ack| ack.state == DebugAcknowledgementState::Failed); + let failure_messages = acknowledgements + .iter() + .filter(|ack| ack.state == DebugAcknowledgementState::Failed) + .map(|ack| { + ack.message + .clone() + .unwrap_or_else(|| format!("task {} failed debug control", ack.task)) + }) + .collect(); + let expected_tasks = runtime + .expected + .into_iter() + .map(|(_, _, process, node, task)| TaskCancellationTarget { + process, + node, + task, + }) + .collect(); + Ok(CoordinatorResponse::DebugEpochStatus { + process, + actor, + epoch, + command: runtime.command, + expected_tasks, + acknowledgements, + fully_frozen, + fully_resumed, + failed, + failure_messages, + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, + }) + } + + pub(super) fn clear_debug_state_for_process( + &mut self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) { + self.debug_epochs + .remove(&process_control_key(tenant, project, process)); + self.debug_epoch_runtime + .remove(&process_control_key(tenant, project, process)); + self.debug_breakpoints + .remove(&process_control_key(tenant, project, process)); + self.debug_commands + .retain(|(task_tenant, task_project, task_process, _, _), _| { + task_tenant != tenant || task_project != project || task_process != process + }); + } + + fn handle_debug_epoch_control( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + stopped_task: Option, + expected_epoch: Option, + operation: &'static str, + command: &'static str, + all_stop_requested: bool, + reason: impl Into, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + let reason = reason.into(); + let audit_event = self.record_debug_audit_event( + tenant.clone(), + project.clone(), + process.clone(), + stopped_task.as_ref().map(TaskInstanceId::new), + actor.clone(), + operation, + authorization.allowed, + if authorization.allowed { + reason + } else { + authorization.reason.clone() + }, + )?; + if !authorization.allowed { + return Err(CoordinatorError::Unauthorized(format!( + "{operation} denied: {}", + authorization.reason + )) + .into()); + } + + let process_key = process_control_key(&tenant, &project, &process); + let epoch = match expected_epoch { + Some(expected) => { + let current = self + .debug_epochs + .get(&process_key) + .copied() + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "cannot resume debug epoch {expected} for {process}: no active debug epoch" + )) + })?; + if current != expected { + return Err(CoordinatorServiceError::Protocol(format!( + "cannot resume debug epoch {expected} for {process}: current debug epoch is {current}" + ))); + } + let runtime = self.debug_epoch_runtime.get(&process_key).ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "cannot resume debug epoch {expected} for {process}: participant state is unavailable" + )) + })?; + if runtime.command != "freeze" + || !runtime_all_in_state(runtime, DebugAcknowledgementState::Frozen) + { + return Err(CoordinatorServiceError::Protocol(format!( + "cannot resume debug epoch {expected} for {process}: all expected participants have not acknowledged frozen state" + ))); + } + current + } + None => { + if let Some(runtime) = self.debug_epoch_runtime.get(&process_key) { + let previous_complete = runtime.command == "resume" + && runtime_all_in_state(runtime, DebugAcknowledgementState::Running); + if !previous_complete { + return Err(CoordinatorServiceError::Protocol(format!( + "cannot create a new debug epoch for {process}: epoch {} has not fully resumed", + runtime.epoch + ))); + } + } + let next = self.debug_epochs.get(&process_key).copied().unwrap_or(0) + 1; + self.debug_epochs.insert(process_key.clone(), next); + next + } + }; + let mut affected_tasks = self + .enqueue_debug_command_for_active_tasks(&tenant, &project, &process, epoch, command); + let mut expected = self + .active_tasks + .iter() + .filter(|(task_tenant, task_project, task_process, _, _)| { + task_tenant == &tenant && task_project == &project && task_process == &process + }) + .cloned() + .collect::>(); + if let Some(control) = self + .main_runtime + .controls + .get(&process_key) + .filter(|control| matches!(control.state.as_str(), "running" | "stopping")) + { + let key = task_control_key( + &tenant, + &project, + &process, + &NodeId::from("coordinator-main"), + &control.task_instance, + ); + expected.insert(key); + affected_tasks.push(TaskCancellationTarget { + process: process.clone(), + node: NodeId::from("coordinator-main"), + task: control.task_instance.clone(), + }); + } + self.debug_epoch_runtime.insert( + process_key.clone(), + DebugEpochRuntime { + epoch, + command: command.to_owned(), + expected, + acknowledgements: BTreeMap::new(), + }, + ); + if let Some(control) = self + .main_runtime + .controls + .get(&process_key) + .filter(|control| matches!(control.state.as_str(), "running" | "stopping")) + { + if command == "freeze" { + control.debug.request_freeze(epoch); + } else { + control.debug.request_resume(epoch); + } + } + self.sync_coordinator_main_debug_acknowledgement(&process_key, epoch); + Ok(CoordinatorResponse::DebugEpoch { + process, + actor, + epoch, + command: command.to_owned(), + affected_tasks, + all_stop_requested, + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, + }) + } + + fn sync_coordinator_main_debug_acknowledgement( + &mut self, + process_key: &super::keys::ProcessControlKey, + epoch: u64, + ) { + let Some(control) = self.main_runtime.controls.get(process_key) else { + return; + }; + let Some(runtime) = self.debug_epoch_runtime.get_mut(process_key) else { + return; + }; + if runtime.epoch != epoch { + return; + } + let state = if runtime.command == "freeze" && control.debug.frozen_epoch() == Some(epoch) { + Some(DebugAcknowledgementState::Frozen) + } else if runtime.command == "resume" + && control.debug.resume_requested(epoch) + && control.debug.frozen_epoch() != Some(epoch) + { + Some(DebugAcknowledgementState::Running) + } else { + None + }; + let Some(state) = state else { + return; + }; + let handles = control + .handles + .lock() + .map(|handles| { + let mut snapshot = handles + .iter() + .map(|(handle_id, spec)| { + ( + format!("task_handle_{handle_id}"), + format!( + "definition={} instance={} state=active", + spec.task_definition, spec.task_instance + ), + ) + }) + .collect::>(); + snapshot.sort_by(|left, right| left.0.cmp(&right.0)); + snapshot + }) + .unwrap_or_else(|_| { + vec![( + "handle-registry-diagnostic".to_owned(), + "coordinator main handle registry was unavailable".to_owned(), + )] + }); + let node = NodeId::from("coordinator-main"); + let key = task_control_key( + &process_key.0, + &process_key.1, + &process_key.2, + &node, + &control.task_instance, + ); + if !runtime.expected.contains(&key) { + return; + } + runtime.acknowledgements.insert( + key, + DebugParticipantAcknowledgement { + node, + task_definition: control.task_definition.clone(), + task: control.task_instance.clone(), + epoch, + state, + stack_frames: vec![control + .stopped_probe_symbol + .as_deref() + .and_then(|symbol| symbol.strip_prefix("disasmer.probe.")) + .map(|function| format!("{function}::wasm")) + .unwrap_or_else(|| { + format!("coordinator_main::wasm ({})", control.task_definition) + })], + local_values: Vec::new(), + task_args: Vec::new(), + handles, + command_status: None, + recent_output: vec![ + "coordinator-hosted capless main acknowledged all-stop control".to_owned(), + ], + message: None, + }, + ); + } + + fn enqueue_debug_command_for_active_tasks( + &mut self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + epoch: u64, + command: &str, + ) -> Vec { + let task_keys = self + .active_tasks + .iter() + .filter(|(task_tenant, task_project, task_process, _, _)| { + task_tenant == tenant && task_project == project && task_process == process + }) + .cloned() + .collect::>(); + for key in &task_keys { + self.debug_commands.insert( + key.clone(), + DebugPendingCommand { + epoch, + command: command.to_owned(), + }, + ); + } + task_keys + .into_iter() + .map(|(_, _, process, node, task)| TaskCancellationTarget { + process, + task, + node, + }) + .collect() + } + + pub(super) fn record_debug_audit_event( + &mut self, + tenant: TenantId, + project: ProjectId, + process: ProcessId, + task: Option, + actor: UserId, + operation: &'static str, + allowed: bool, + reason: impl Into, + ) -> Result { + let now_epoch_seconds = self.current_epoch_seconds()?; + let charged_debug_read_bytes = if allowed { + self.quota.charge_debug_read( + &tenant, + &project, + DEBUG_CONTROL_READ_BYTES, + now_epoch_seconds, + )?; + DEBUG_CONTROL_READ_BYTES + } else { + 0 + }; + let used_debug_read_bytes = + self.quota + .used_debug_read_bytes(&tenant, &project, now_epoch_seconds); + let event = DebugAuditEvent { + tenant, + project, + process, + task, + actor, + operation: operation.to_owned(), + allowed, + reason: reason.into(), + charged_debug_read_bytes, + used_debug_read_bytes, + }; + while self + .debug_audit_events + .iter() + .filter(|retained| { + retained.tenant == event.tenant + && retained.project == event.project + && retained.process == event.process + }) + .count() + >= super::MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS + { + let Some(index) = self.debug_audit_events.iter().position(|retained| { + retained.tenant == event.tenant + && retained.project == event.project + && retained.process == event.process + }) else { + break; + }; + self.debug_audit_events.remove(index); + } + self.debug_audit_events.push_back(event.clone()); + Ok(event) + } +} diff --git a/crates/disasmer-coordinator/src/service/debug/validation.rs b/crates/disasmer-coordinator/src/service/debug/validation.rs new file mode 100644 index 0000000..bf0c6ec --- /dev/null +++ b/crates/disasmer-coordinator/src/service/debug/validation.rs @@ -0,0 +1,92 @@ +use std::collections::BTreeSet; + +use crate::CoordinatorServiceError; + +use super::{DebugAcknowledgementState, DebugEpochRuntime}; + +pub(super) fn runtime_all_in_state( + runtime: &DebugEpochRuntime, + state: DebugAcknowledgementState, +) -> bool { + !runtime.expected.is_empty() + && runtime.expected.iter().all(|key| { + runtime + .acknowledgements + .get(key) + .is_some_and(|ack| ack.state == state) + }) +} + +pub(super) fn validate_probe_symbols( + probe_symbols: Vec, +) -> Result, CoordinatorServiceError> { + if probe_symbols.len() > 128 { + return Err(CoordinatorServiceError::Protocol( + "debug breakpoint request exceeds 128 probe symbols".to_owned(), + )); + } + let mut unique = BTreeSet::new(); + for symbol in probe_symbols { + if symbol.trim().is_empty() + || symbol.len() > 256 + || !symbol + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._:-/".contains(character)) + { + return Err(CoordinatorServiceError::Protocol( + "debug breakpoint probe symbol is invalid".to_owned(), + )); + } + unique.insert(symbol); + } + Ok(unique.into_iter().collect()) +} + +pub(super) fn validate_debug_snapshot( + stack_frames: &[String], + local_values: &[(String, String)], + task_args: &[(String, String)], + handles: &[(String, String)], + command_status: Option<&str>, + recent_output: &[String], + message: Option<&str>, +) -> Result<(), CoordinatorServiceError> { + const MAX_ITEMS: usize = 128; + const MAX_TEXT_BYTES: usize = 16 * 1024; + if [ + stack_frames.len(), + local_values.len(), + task_args.len(), + handles.len(), + recent_output.len(), + ] + .into_iter() + .any(|count| count > MAX_ITEMS) + { + return Err(CoordinatorServiceError::Protocol( + "debug participant snapshot exceeds the 128-item field limit".to_owned(), + )); + } + let text_bytes = stack_frames.iter().map(String::len).sum::() + + local_values + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::() + + task_args + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::() + + handles + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::() + + recent_output.iter().map(String::len).sum::() + + command_status.map(str::len).unwrap_or(0) + + message.map(str::len).unwrap_or(0); + if text_bytes > MAX_TEXT_BYTES { + return Err(CoordinatorServiceError::Protocol(format!( + "debug participant snapshot is {text_bytes} bytes; maximum is {MAX_TEXT_BYTES}" + ))); + } + Ok(()) +} diff --git a/crates/disasmer-coordinator/src/service/debug_requests.rs b/crates/disasmer-coordinator/src/service/debug_requests.rs new file mode 100644 index 0000000..0db48c6 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/debug_requests.rs @@ -0,0 +1,470 @@ +use std::collections::BTreeSet; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::{ + Actor, Capability, CredentialKind, Digest, EnvironmentResource, ProcessId, ProjectId, + RestartDecision, RestartPolicy, RestartRequest, TaskDispatch, TaskInstanceId, TenantId, UserId, + WasmExportAbi, +}; + +use crate::{CoordinatorError, CoordinatorServiceError}; + +use super::keys::task_restart_key; +use super::{ + AuthenticatedCoordinatorRequest, CoordinatorRequest, CoordinatorResponse, CoordinatorService, + TaskReplacementBundle, WorkflowActor, +}; + +impl CoordinatorService { + pub(super) fn handle_debug_request( + &mut self, + request: CoordinatorRequest, + ) -> Result { + match request { + CoordinatorRequest::DebugAttach { + tenant, + project, + actor_user, + process, + } => self.handle_debug_attach(tenant, project, actor_user, process), + CoordinatorRequest::SetDebugBreakpoints { + tenant, + project, + actor_user, + process, + probe_symbols, + } => self.handle_set_debug_breakpoints( + tenant, + project, + actor_user, + process, + probe_symbols, + ), + CoordinatorRequest::InspectDebugBreakpoints { + tenant, + project, + actor_user, + process, + } => self.handle_inspect_debug_breakpoints(tenant, project, actor_user, process), + CoordinatorRequest::CreateDebugEpoch { + tenant, + project, + actor_user, + process, + stopped_task, + reason, + } => self.handle_create_debug_epoch( + tenant, + project, + actor_user, + process, + stopped_task, + reason, + ), + CoordinatorRequest::ResumeDebugEpoch { + tenant, + project, + actor_user, + process, + epoch, + } => self.handle_resume_debug_epoch(tenant, project, actor_user, process, epoch), + CoordinatorRequest::InspectDebugEpoch { + tenant, + project, + actor_user, + process, + epoch, + } => self.handle_inspect_debug_epoch(tenant, project, actor_user, process, epoch), + _ => unreachable!("handle_debug_request only accepts debug coordinator requests"), + } + } + + pub(super) fn handle_authenticated_debug_request( + &mut self, + tenant: &TenantId, + project: &ProjectId, + actor: &UserId, + request: AuthenticatedCoordinatorRequest, + ) -> Result { + match request { + AuthenticatedCoordinatorRequest::DebugAttach { process } => self.handle_debug_attach( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + ), + AuthenticatedCoordinatorRequest::SetDebugBreakpoints { + process, + probe_symbols, + } => self.handle_set_debug_breakpoints( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + probe_symbols, + ), + AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { process } => self + .handle_inspect_debug_breakpoints( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + ), + AuthenticatedCoordinatorRequest::CreateDebugEpoch { + process, + stopped_task, + reason, + } => self.handle_create_debug_epoch( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + stopped_task, + reason, + ), + AuthenticatedCoordinatorRequest::ResumeDebugEpoch { process, epoch } => self + .handle_resume_debug_epoch( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + epoch, + ), + AuthenticatedCoordinatorRequest::InspectDebugEpoch { process, epoch } => self + .handle_inspect_debug_epoch( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + epoch, + ), + _ => unreachable!( + "handle_authenticated_debug_request only accepts debug coordinator requests" + ), + } + } + + pub(super) fn handle_restart_task( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + task: String, + replacement_bundle: Option, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let task = TaskInstanceId::new(task); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + let authorization = self.coordinator.authorize_debug_attach(&context, &process); + if !authorization.allowed { + let _ = self.record_debug_audit_event( + tenant, + project, + process, + Some(task), + actor, + "restart_task", + false, + authorization.reason.clone(), + )?; + return Err(CoordinatorError::Unauthorized(format!( + "task restart denied: {}", + authorization.reason + )) + .into()); + } + let active_key = self + .active_tasks + .iter() + .find(|(task_tenant, task_project, task_process, _, task_id)| { + task_tenant == &tenant + && task_project == &project + && task_process == &process + && task_id == &task + }) + .cloned(); + let process_key = super::keys::process_control_key(&tenant, &project, &process); + let active_main = self + .main_runtime + .controls + .get(&process_key) + .is_some_and(|control| { + control.task_instance == task + && matches!(control.state.as_str(), "running" | "stopping") + }); + let active_task = active_key.is_some() || active_main; + let completed_event_observed = self.task_events.iter().any(|event| { + event.tenant == tenant + && event.project == project + && event.process == process + && event.task == task + }); + let checkpoint_key = task_restart_key(&tenant, &project, &process, &task); + let checkpoint = self.task_restart_checkpoints.get(&checkpoint_key).cloned(); + let mut accepted = false; + let mut restarted_task_instance = None; + let mut clean_boundary_available = false; + let mut requires_whole_process_restart = true; + let message = if active_main { + "selected coordinator main is still active; restart the whole virtual process to rerun its capless entry boundary".to_owned() + } else if active_task { + "selected task is still active; wait for its terminal event or abort the whole virtual process before restarting from its clean entry boundary".to_owned() + } else if !completed_event_observed { + "selected task is not known in the active process; restart the whole virtual process or inspect task list".to_owned() + } else if let Some(checkpoint) = checkpoint { + let vfs_available = + checkpoint + .checkpoint + .vfs_manifest + .objects + .iter() + .all(|(path, object)| { + let artifact = super::keys::artifact_id_from_path(path); + self.artifact_registry + .metadata(&artifact) + .is_some_and(|metadata| { + metadata.tenant == tenant + && metadata.project == project + && metadata.digest == object.digest + && metadata.size == object.size + && !metadata.retaining_nodes.is_empty() + }) + }); + if !vfs_available { + "selected task checkpoint references VFS artifacts that are no longer retained; restart the whole virtual process".to_owned() + } else { + let replacement = replacement_bundle + .as_ref() + .map(|bundle| { + validate_task_replacement( + bundle, + &checkpoint.assignment.task_spec.task_definition, + checkpoint.assignment.task_spec.environment_id.as_deref(), + ) + }) + .transpose()?; + let request = RestartRequest { + task: task.clone(), + entrypoint: replacement.as_ref().map_or_else( + || checkpoint.checkpoint.boundary.task_entrypoint.clone(), + |replacement| replacement.export.clone(), + ), + serialized_args: checkpoint.checkpoint.boundary.serialized_args.clone(), + environment_digest: replacement.as_ref().map_or_else( + || checkpoint.checkpoint.boundary.environment_digest.clone(), + |replacement| replacement.environment_digest.clone(), + ), + task_abi: replacement.as_ref().map_or_else( + || checkpoint.checkpoint.boundary.task_abi.clone(), + |replacement| replacement.restart_compatibility.clone(), + ), + source_edited: replacement.is_some(), + }; + match RestartPolicy.decide(&checkpoint.checkpoint, &request) { + RestartDecision::RestartTask { from_vfs_epoch, .. } => { + clean_boundary_available = true; + let mut assignment = checkpoint.assignment; + let new_task_instance = disasmer_core::TaskInstanceId::new( + disasmer_core::generate_opaque_token("ti") + .map_err(CoordinatorServiceError::Protocol)?, + ); + assignment.task = new_task_instance.clone(); + assignment.task_spec.task_instance = new_task_instance.clone(); + assignment.artifact_path = + format!("/vfs/artifacts/{}-result.json", new_task_instance.as_str()); + if let Some(replacement) = replacement { + assignment.task_spec.dispatch = TaskDispatch::CoordinatorNodeWasm { + export: Some(replacement.export), + abi: WasmExportAbi::TaskV1, + }; + assignment.task_spec.environment = replacement + .environment + .map(|environment| environment.requirements); + assignment.task_spec.environment_digest = + Some(replacement.environment_digest); + assignment.task_spec.required_capabilities = + replacement.required_capabilities; + assignment.task_spec.bundle_digest = + Some(replacement.bundle_digest.clone()); + assignment.wasm_module_base64 = replacement.wasm_module_base64; + } + let launch = self.handle_launch_task_with_actor( + tenant.clone(), + project.clone(), + WorkflowActor { + kind: "user".to_owned(), + user: Some(actor.clone()), + agent: None, + credential_kind: CredentialKind::CliDeviceSession, + public_key_fingerprint: None, + authenticated_without_browser: false, + scopes: vec!["process:restart-task".to_owned()], + }, + assignment.task_spec, + true, + assignment.artifact_path, + assignment.wasm_module_base64, + )?; + accepted = matches!( + launch, + CoordinatorResponse::TaskLaunched { .. } + | CoordinatorResponse::TaskQueued { .. } + ); + if accepted { + restarted_task_instance = Some(new_task_instance.clone()); + } + requires_whole_process_restart = !accepted; + format!( + "selected task restarted as new instance {new_task_instance} from clean VFS entry boundary epoch {from_vfs_epoch}; unflushed task changes were discarded" + ) + } + RestartDecision::RestartWholeVirtualProcess { message } => message, + } + } + } else { + "selected task has terminal metadata but no captured clean VFS entry boundary; restart the whole virtual process".to_owned() + }; + let audit_event = self.record_debug_audit_event( + tenant, + project, + process.clone(), + Some(task.clone()), + actor.clone(), + "restart_task", + true, + &message, + )?; + Ok(CoordinatorResponse::TaskRestart { + process, + task, + restarted_task_instance, + actor, + accepted, + clean_boundary_available, + active_task, + completed_event_observed, + requires_whole_process_restart, + message, + charged_debug_read_bytes: audit_event.charged_debug_read_bytes, + used_debug_read_bytes: audit_event.used_debug_read_bytes, + audit_event, + }) + } +} + +struct ValidatedTaskReplacement { + bundle_digest: Digest, + wasm_module_base64: String, + export: String, + restart_compatibility: Digest, + environment: Option, + environment_digest: Digest, + required_capabilities: BTreeSet, +} + +fn validate_task_replacement( + replacement: &TaskReplacementBundle, + task_definition: &disasmer_core::TaskDefinitionId, + environment_id: Option<&str>, +) -> Result { + let module = BASE64_STANDARD + .decode(&replacement.wasm_module_base64) + .map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "replacement task bundle is not valid base64: {error}" + )) + })?; + let actual_digest = Digest::sha256(&module); + if actual_digest != replacement.bundle_digest { + return Err(CoordinatorServiceError::Protocol(format!( + "replacement task bundle digest mismatch: expected {}, actual {actual_digest}", + replacement.bundle_digest + ))); + } + let descriptors = super::main_runtime::task_descriptors(&module)?; + let descriptor = descriptors.get(task_definition.as_str()).ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "replacement bundle has no task definition `{task_definition}`" + )) + })?; + if descriptor + .get("abi_version") + .and_then(serde_json::Value::as_u64) + != Some(disasmer_core::WASM_TASK_ABI_VERSION as u64) + { + return Err(CoordinatorServiceError::Protocol(format!( + "replacement task `{task_definition}` uses an unsupported task ABI" + ))); + } + let export = descriptor + .get("export") + .and_then(serde_json::Value::as_str) + .filter(|export| !export.is_empty()) + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "replacement task `{task_definition}` omitted its export" + )) + })? + .to_owned(); + let restart_compatibility = serde_json::from_value( + descriptor + .get("restart_compatibility_hash") + .cloned() + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "replacement task `{task_definition}` omitted restart compatibility metadata" + )) + })?, + )?; + let mut required_capabilities = descriptor + .get("required_capabilities") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .map(|capability| { + super::main_runtime::capability_from_descriptor(capability.as_str().ok_or_else( + || { + CoordinatorServiceError::Protocol( + "replacement task capability is not a string".to_owned(), + ) + }, + )?) + .map_err(CoordinatorServiceError::Protocol) + }) + .collect::, _>>()?; + let environment = environment_id + .map(|environment_id| { + super::main_runtime::bundle_environments(&module)? + .remove(environment_id) + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "replacement bundle has no environment `{environment_id}`" + )) + }) + }) + .transpose()?; + if let Some(environment) = &environment { + required_capabilities.extend(environment.requirements.capabilities.iter().cloned()); + } + let environment_digest = environment.as_ref().map_or_else( + || Digest::sha256("disasmer.environment.unconstrained.v1"), + |environment| environment.digest.clone(), + ); + Ok(ValidatedTaskReplacement { + bundle_digest: replacement.bundle_digest.clone(), + wasm_module_base64: replacement.wasm_module_base64.clone(), + export, + restart_compatibility, + environment, + environment_digest, + required_capabilities, + }) +} diff --git a/crates/disasmer-coordinator/src/service/durable_runtime.rs b/crates/disasmer-coordinator/src/service/durable_runtime.rs new file mode 100644 index 0000000..7c01fd4 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/durable_runtime.rs @@ -0,0 +1,47 @@ +use crate::{ + DurableState, DurableStore, FallibleDurableStore, InMemoryDurableStore, PostgresDurableStore, +}; + +pub(super) enum RuntimeDurableStore { + InMemory(InMemoryDurableStore), + Postgres(PostgresDurableStore), +} + +impl RuntimeDurableStore { + pub(super) fn from_database_url(database_url: Option<&str>) -> Result { + match database_url.map(str::trim).filter(|url| !url.is_empty()) { + Some(url) => PostgresDurableStore::connect(url) + .map(Self::Postgres) + .map_err(|error| error.to_string()), + None => Ok(Self::InMemory(InMemoryDurableStore::default())), + } + } + + pub(super) fn kind(&self) -> &'static str { + match self { + Self::InMemory(_) => "in_memory", + Self::Postgres(_) => "postgres", + } + } +} + +impl FallibleDurableStore for RuntimeDurableStore { + type Error = String; + + fn load_state(&mut self) -> Result { + match self { + Self::InMemory(store) => Ok(store.load()), + Self::Postgres(store) => store.load_state().map_err(|error| error.to_string()), + } + } + + fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> { + match self { + Self::InMemory(store) => { + store.save(state.clone()); + Ok(()) + } + Self::Postgres(store) => store.save_state(state).map_err(|error| error.to_string()), + } + } +} diff --git a/crates/disasmer-coordinator/src/service/keys.rs b/crates/disasmer-coordinator/src/service/keys.rs new file mode 100644 index 0000000..5be2f1e --- /dev/null +++ b/crates/disasmer-coordinator/src/service/keys.rs @@ -0,0 +1,71 @@ +use disasmer_core::{ArtifactId, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, VfsPath}; + +pub(super) type TaskControlKey = (TenantId, ProjectId, ProcessId, NodeId, TaskInstanceId); +pub(super) type TaskRestartKey = (TenantId, ProjectId, ProcessId, TaskInstanceId); +pub(super) type TaskAssignmentKey = (TenantId, ProjectId, NodeId); +pub(super) type PanelStopKey = (TenantId, ProjectId, ProcessId); +pub(super) type EnrollmentGrantKey = (TenantId, ProjectId, String); +pub(super) type ProcessControlKey = (TenantId, ProjectId, ProcessId); + +pub(super) fn task_control_key( + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + node: &NodeId, + task: &TaskInstanceId, +) -> TaskControlKey { + ( + tenant.clone(), + project.clone(), + process.clone(), + node.clone(), + task.clone(), + ) +} + +pub(super) fn task_restart_key( + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + task: &TaskInstanceId, +) -> TaskRestartKey { + ( + tenant.clone(), + project.clone(), + process.clone(), + task.clone(), + ) +} + +pub(super) fn process_control_key( + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, +) -> ProcessControlKey { + (tenant.clone(), project.clone(), process.clone()) +} + +pub(super) fn panel_stop_key( + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, +) -> PanelStopKey { + (tenant.clone(), project.clone(), process.clone()) +} + +pub(super) fn enrollment_grant_key( + tenant: &TenantId, + project: &ProjectId, + grant: &str, +) -> EnrollmentGrantKey { + (tenant.clone(), project.clone(), grant.to_owned()) +} + +pub(super) fn artifact_id_from_path(path: &VfsPath) -> ArtifactId { + let value = path + .as_str() + .strip_prefix("/vfs/artifacts/") + .unwrap_or(path.as_str()) + .replace('/', ":"); + ArtifactId::new(value) +} diff --git a/crates/disasmer-coordinator/src/service/logs.rs b/crates/disasmer-coordinator/src/service/logs.rs new file mode 100644 index 0000000..3324ab9 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/logs.rs @@ -0,0 +1,535 @@ +use disasmer_core::{ + ArtifactFlush, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskBoundaryValue, + TaskInstanceId, TaskJoinResult, TaskJoinState, TenantId, UserId, VfsPath, +}; + +use crate::CoordinatorError; + +use super::keys::{process_control_key, task_control_key}; +use super::{ + artifact_id_from_path, CoordinatorResponse, CoordinatorService, CoordinatorServiceError, + TaskCompletionEvent, TaskTerminalState, MAX_TASK_LOG_TAIL_BYTES, +}; + +impl CoordinatorService { + pub(super) fn handle_report_task_log( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + stdout_bytes: u64, + stderr_bytes: u64, + stdout_tail: String, + stderr_tail: String, + stdout_truncated: bool, + stderr_truncated: bool, + backpressured: bool, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskInstanceId::new(task); + self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?; + validate_task_log_tail("stdout_tail", &stdout_tail)?; + validate_task_log_tail("stderr_tail", &stderr_tail)?; + let reported_bytes = checked_reported_log_bytes(stdout_bytes, stderr_bytes)?; + let now_epoch_seconds = self.current_epoch_seconds()?; + self.quota + .can_charge_log_bytes(&tenant, &project, reported_bytes, now_epoch_seconds)?; + self.quota + .charge_log_bytes(&tenant, &project, reported_bytes, now_epoch_seconds)?; + Ok(CoordinatorResponse::TaskLogRecorded { + process, + task, + stdout_bytes, + stderr_bytes, + stdout_tail: if stdout_truncated { + format!("{stdout_tail}\n... truncated") + } else { + stdout_tail + }, + stderr_tail: if stderr_truncated { + format!("{stderr_tail}\n... truncated") + } else { + stderr_tail + }, + backpressured, + }) + } + + pub(super) fn handle_report_vfs_metadata( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + artifact_path: Option, + artifact_digest: Option, + artifact_size_bytes: Option, + large_bytes_uploaded: bool, + ) -> Result { + let artifact_path = artifact_path + .map(VfsPath::new) + .transpose() + .map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?; + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskInstanceId::new(task); + self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?; + if let (Some(path), Some(digest)) = (&artifact_path, artifact_digest) { + self.flush_artifact_metadata(ArtifactFlush { + id: artifact_id_from_path(path), + tenant, + project, + process: process.clone(), + producer_task: task.clone(), + retaining_node: node, + digest, + size: artifact_size_bytes.unwrap_or_default(), + })?; + } + Ok(CoordinatorResponse::VfsMetadataRecorded { + process, + task, + artifact_path, + large_bytes_uploaded, + }) + } + + pub(super) fn handle_task_completed( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + terminal_state: Option, + status_code: Option, + stdout_bytes: u64, + stderr_bytes: u64, + stdout_tail: String, + stderr_tail: String, + stdout_truncated: bool, + stderr_truncated: bool, + artifact_path: Option, + artifact_digest: Option, + artifact_size_bytes: Option, + result: Option, + ) -> Result { + validate_task_log_tail("stdout_tail", &stdout_tail)?; + validate_task_log_tail("stderr_tail", &stderr_tail)?; + let artifact_path = artifact_path + .map(VfsPath::new) + .transpose() + .map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?; + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskInstanceId::new(task); + self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?; + let checkpoint = self + .task_restart_checkpoints + .get(&super::keys::task_restart_key( + &tenant, &project, &process, &task, + )) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "signed node task completion does not name a coordinator-issued task instance" + .to_owned(), + ) + })?; + if checkpoint.assignment.node != node { + return Err(CoordinatorError::Unauthorized( + "signed node task completion came from a node other than the assigned node" + .to_owned(), + ) + .into()); + } + let mut event = TaskCompletionEvent { + tenant, + project, + process, + node, + executor: super::TaskExecutor::Node, + task_definition: checkpoint.assignment.task_spec.task_definition.clone(), + task, + placement: None, + terminal_state: terminal_state + .unwrap_or_else(|| TaskTerminalState::from_status_code(status_code)), + status_code, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + artifact_path, + artifact_digest: artifact_digest.clone(), + artifact_size_bytes, + result, + }; + let reported_bytes = checked_reported_log_bytes(event.stdout_bytes, event.stderr_bytes)?; + let now_epoch_seconds = self.current_epoch_seconds()?; + self.quota.can_charge_log_bytes( + &event.tenant, + &event.project, + reported_bytes, + now_epoch_seconds, + )?; + self.quota.charge_log_bytes( + &event.tenant, + &event.project, + reported_bytes, + now_epoch_seconds, + )?; + let task_key = task_control_key( + &event.tenant, + &event.project, + &event.process, + &event.node, + &event.task, + ); + let process_key = process_control_key(&event.tenant, &event.project, &event.process); + let process_was_aborted = self.process_aborts.contains(&process_key); + event.placement = self.task_placements.remove(&task_key); + if let (Some(path), Some(digest)) = (&event.artifact_path, artifact_digest) { + self.flush_artifact_metadata(ArtifactFlush { + id: artifact_id_from_path(path), + tenant: event.tenant.clone(), + project: event.project.clone(), + process: event.process.clone(), + producer_task: event.task.clone(), + retaining_node: event.node.clone(), + digest, + size: artifact_size_bytes.unwrap_or(stdout_bytes), + })?; + } + self.task_cancellations.remove(&task_key); + self.task_aborts.remove(&task_key); + self.debug_commands.remove(&task_key); + self.active_tasks.remove(&task_key); + if !self + .active_tasks + .iter() + .any(|(task_tenant, task_project, task_process, _, _)| { + task_tenant == &event.tenant + && task_project == &event.project + && task_process == &event.process + }) + { + self.process_aborts.remove(&process_key); + } + if process_was_aborted { + let checkpoint_key = super::keys::task_restart_key( + &event.tenant, + &event.project, + &event.process, + &event.task, + ); + self.task_restart_checkpoints.remove(&checkpoint_key); + self.task_restart_checkpoint_order + .retain(|retained| retained != &checkpoint_key); + } + self.record_task_completion_event(event.clone()); + self.notify_coordinator_main_waiters(&event); + Ok(CoordinatorResponse::TaskRecorded { + process: event.process, + task: event.task, + events_recorded: self.task_events.len(), + }) + } + + pub(super) fn handle_list_task_events( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: Option, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let _actor = UserId::new(actor_user); + let process = process.map(ProcessId::new); + if let Some(process) = &process { + self.authorize_task_event_process_scope(&tenant, &project, process)?; + } + let events = self + .task_events + .iter() + .filter(|event| { + event.tenant == tenant + && event.project == project + && process + .as_ref() + .is_none_or(|process| event.process == *process) + }) + .cloned() + .collect(); + Ok(CoordinatorResponse::TaskEvents { events }) + } + + fn authorize_task_event_process_scope( + &self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> Result<(), CoordinatorServiceError> { + let active_in_scope = self + .coordinator + .active_process(tenant, project, process) + .is_some(); + let historical_in_scope = self.task_events.iter().any(|event| { + event.tenant == *tenant && event.project == *project && event.process == *process + }); + let process_exists_outside_scope = self + .coordinator + .active_process_exists_outside_scope(tenant, project, process) + || self.task_events.iter().any(|event| { + event.process == *process && (event.tenant != *tenant || event.project != *project) + }); + if !active_in_scope && !historical_in_scope && process_exists_outside_scope { + return Err(CoordinatorError::Unauthorized( + "task event access is outside the virtual process tenant/project scope".to_owned(), + ) + .into()); + } + Ok(()) + } + + pub(super) fn handle_join_task( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + task: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let _actor = UserId::new(actor_user); + let process = ProcessId::new(process); + let task = TaskInstanceId::new(task); + Ok(CoordinatorResponse::TaskJoined { + join: self.task_join_result(tenant, project, process, task), + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_join_child_task( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + parent_task: String, + task: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let parent_task = TaskInstanceId::new(parent_task); + self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?; + if !self.active_tasks.contains(&super::keys::task_control_key( + &tenant, + &project, + &process, + &node, + &parent_task, + )) { + return Err(CoordinatorError::Unauthorized( + "child task join requires a currently active parent task on the signed node" + .to_owned(), + ) + .into()); + } + Ok(CoordinatorResponse::TaskJoined { + join: self.task_join_result(tenant, project, process, TaskInstanceId::new(task)), + }) + } + + pub(super) fn task_join_result( + &self, + tenant: TenantId, + project: ProjectId, + process: ProcessId, + task: TaskInstanceId, + ) -> TaskJoinResult { + let event = self.task_events.iter().rev().find(|event| { + event.tenant == tenant + && event.project == project + && event.process == process + && event.task == task + }); + + if let Some(event) = event { + TaskJoinResult::from_remote_completion( + event.process.clone(), + event.task.clone(), + event.node.clone(), + join_state_for_terminal(&event.terminal_state), + event.result.clone(), + event.status_code, + join_message_for_event(event), + ) + } else { + let known = self.task_is_known_or_active(&tenant, &project, &process, &task); + TaskJoinResult::pending( + process, + task, + if known { + "waiting for signed node task_completed event before join returns" + } else { + "no signed node completion event has been observed for this task" + }, + ) + } + } + + pub(super) fn record_task_completion_event(&mut self, mut event: TaskCompletionEvent) { + event.stdout_tail = bounded_log_tail(event.stdout_tail, &mut event.stdout_truncated); + event.stderr_tail = bounded_log_tail(event.stderr_tail, &mut event.stderr_truncated); + while self + .task_events + .iter() + .filter(|retained| { + retained.tenant == event.tenant + && retained.project == event.project + && retained.process == event.process + }) + .count() + >= super::MAX_TASK_EVENTS_PER_PROCESS + { + let Some(index) = self.task_events.iter().position(|retained| { + retained.tenant == event.tenant + && retained.project == event.project + && retained.process == event.process + }) else { + break; + }; + self.task_events.remove(index); + } + self.task_events.push_back(event); + } + + fn flush_artifact_metadata( + &mut self, + flush: ArtifactFlush, + ) -> Result<(), CoordinatorServiceError> { + let now_epoch_seconds = self.current_epoch_seconds()?; + self.artifact_registry + .expire_download_links(now_epoch_seconds); + let pinned = self + .task_restart_checkpoints + .values() + .flat_map(|checkpoint| checkpoint.assignment.task_spec.required_artifacts.iter()) + .chain( + self.pending_task_launches + .iter() + .flat_map(|pending| pending.task_spec.required_artifacts.iter()), + ) + .cloned() + .collect::>(); + self.artifact_registry + .flush_metadata_bounded(flush, &pinned) + .map(|_| ()) + .map_err(CoordinatorServiceError::Protocol) + } + + fn task_is_known_or_active( + &self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + task: &TaskInstanceId, + ) -> bool { + self.active_tasks + .iter() + .any(|(task_tenant, task_project, task_process, _, task_id)| { + task_tenant == tenant + && task_project == project + && task_process == process + && task_id == task + }) + || self.pending_task_launches.iter().any(|pending| { + &pending.tenant == tenant + && &pending.project == project + && &pending.process == process + && &pending.task == task + }) + || self.task_assignments.values().any(|assignments| { + assignments.iter().any(|assignment| { + &assignment.tenant == tenant + && &assignment.project == project + && &assignment.process == process + && &assignment.task == task + }) + }) + } +} + +fn checked_reported_log_bytes( + stdout_bytes: u64, + stderr_bytes: u64, +) -> Result { + stdout_bytes.checked_add(stderr_bytes).ok_or_else(|| { + CoordinatorServiceError::Protocol( + "reported task log byte counts exceed the supported range".to_owned(), + ) + }) +} + +fn validate_task_log_tail(kind: &str, value: &str) -> Result<(), CoordinatorServiceError> { + if value.len() > MAX_TASK_LOG_TAIL_BYTES { + return Err(CoordinatorServiceError::InvalidTaskLogTail(format!( + "{kind} is {} bytes; max is {MAX_TASK_LOG_TAIL_BYTES}", + value.len() + ))); + } + Ok(()) +} + +fn bounded_log_tail(mut value: String, truncated: &mut bool) -> String { + if value.len() <= MAX_TASK_LOG_TAIL_BYTES { + return value; + } + let mut boundary = MAX_TASK_LOG_TAIL_BYTES; + while !value.is_char_boundary(boundary) { + boundary -= 1; + } + value.truncate(boundary); + *truncated = true; + value +} + +fn join_state_for_terminal(terminal: &TaskTerminalState) -> TaskJoinState { + match terminal { + TaskTerminalState::Completed => TaskJoinState::Completed, + TaskTerminalState::Failed => TaskJoinState::Failed, + TaskTerminalState::Cancelled => TaskJoinState::Cancelled, + } +} + +fn join_message_for_event(event: &TaskCompletionEvent) -> String { + match event.terminal_state { + TaskTerminalState::Completed => { + "joined result from signed node task_completed event".to_owned() + } + TaskTerminalState::Failed => { + let stderr = event.stderr_tail.trim(); + if stderr.is_empty() { + "remote task failed".to_owned() + } else { + format!("remote task failed: {stderr}") + } + } + TaskTerminalState::Cancelled => "remote task was cancelled".to_owned(), + } +} diff --git a/crates/disasmer-coordinator/src/service/main_runtime.rs b/crates/disasmer-coordinator/src/service/main_runtime.rs new file mode 100644 index 0000000..b38be8e --- /dev/null +++ b/crates/disasmer-coordinator/src/service/main_runtime.rs @@ -0,0 +1,999 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError}; +use std::sync::{Arc, Mutex}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::{ + Capability, CredentialKind, Digest, EnvironmentResource, NodeId, ProcessId, ProjectId, + TaskBoundaryValue, TaskDefinitionId, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec, + TenantId, WasmExportAbi, WasmHostCommandRequest, WasmHostCommandResult, + WasmHostDebugProbeRequest, WasmHostDebugProbeResult, WasmHostSourceSnapshotRequest, + WasmHostSourceSnapshotResult, WasmHostTaskControlRequest, WasmHostTaskControlResult, + WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest, + WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, WasmTaskOutcome, WasmTaskResult, +}; +use disasmer_wasm_runtime::{WasmDebugControl, WasmTaskHost, WasmtimeTaskRuntime}; +use wasmparser::{Parser, Payload}; + +use crate::{CoordinatorError, CoordinatorServiceError}; + +use super::keys::{process_control_key, task_restart_key, ProcessControlKey, TaskRestartKey}; +use super::{ + CoordinatorResponse, CoordinatorService, TaskCompletionEvent, TaskExecutor, TaskTerminalState, + WorkflowActor, +}; + +#[derive(Clone)] +struct MainScope { + tenant: TenantId, + project: ProjectId, + process: ProcessId, + task_definition: TaskDefinitionId, + task_instance: TaskInstanceId, + epoch: u64, + launch_id: u64, +} + +enum MainCommand { + StartTask { + scope: MainScope, + handle_id: u64, + task_spec: TaskSpec, + wasm_module_base64: String, + response: SyncSender>, + }, + JoinTask { + scope: MainScope, + task_instance: TaskInstanceId, + response: SyncSender>, + }, + DebugProbe { + scope: MainScope, + request: WasmHostDebugProbeRequest, + response: SyncSender>, + }, + Finished { + scope: MainScope, + result: Result, + }, +} + +pub(super) struct CoordinatorMainControl { + pub(super) task_definition: TaskDefinitionId, + pub(super) task_instance: TaskInstanceId, + pub(super) abort: Arc, + pub(super) debug: Arc, + pub(super) state: String, + pub(super) stopped_probe_symbol: Option, + pub(super) handles: Arc>>, + pub(super) launch_id: u64, +} + +pub(super) struct CoordinatorMainRuntime { + sender: Sender, + receiver: Receiver, + pub(super) controls: BTreeMap, + join_waiters: BTreeMap>>>, + next_launch_id: u64, +} + +impl Default for CoordinatorMainRuntime { + fn default() -> Self { + let (sender, receiver) = mpsc::channel(); + Self { + sender, + receiver, + controls: BTreeMap::new(), + join_waiters: BTreeMap::new(), + next_launch_id: 1, + } + } +} + +impl CoordinatorMainRuntime { + pub(super) fn is_waiting_for_task( + &self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> bool { + self.join_waiters + .keys() + .any(|(waiter_tenant, waiter_project, waiter_process, _)| { + waiter_tenant == tenant && waiter_project == project && waiter_process == process + }) + } + + fn drain_commands(&self) -> Vec { + let mut commands = Vec::new(); + loop { + match self.receiver.try_recv() { + Ok(command) => commands.push(command), + Err(TryRecvError::Empty | TryRecvError::Disconnected) => break, + } + } + commands + } + + fn launch( + &mut self, + mut scope: MainScope, + export: String, + module: Vec, + wasm_module_base64: String, + bundle_digest: Digest, + task_descriptors: HashMap, + environments: BTreeMap, + ) -> Result<(), CoordinatorServiceError> { + let process_key = process_control_key(&scope.tenant, &scope.project, &scope.process); + if self.controls.contains_key(&process_key) { + return Err(CoordinatorServiceError::Protocol(format!( + "virtual process {} already has a coordinator main instance", + scope.process + ))); + } + scope.launch_id = self.next_launch_id; + self.next_launch_id = self.next_launch_id.saturating_add(1); + let abort = Arc::new(AtomicBool::new(false)); + let debug = Arc::new(WasmDebugControl::default()); + let handles = Arc::new(Mutex::new(HashMap::new())); + self.controls.insert( + process_key, + CoordinatorMainControl { + task_definition: scope.task_definition.clone(), + task_instance: scope.task_instance.clone(), + abort: Arc::clone(&abort), + debug: Arc::clone(&debug), + state: "running".to_owned(), + stopped_probe_symbol: None, + handles: Arc::clone(&handles), + launch_id: scope.launch_id, + }, + ); + let sender = self.sender.clone(); + let invocation = WasmTaskInvocation::new( + scope.task_definition.clone(), + scope.task_instance.clone(), + Vec::new(), + ); + std::thread::Builder::new() + .name(format!("disasmer-main-{}", scope.process)) + .spawn(move || { + let host = CoordinatorMainHost { + scope: scope.clone(), + sender: sender.clone(), + abort, + debug, + task_descriptors, + environments, + bundle_digest: bundle_digest.clone(), + wasm_module_base64, + next_handle_id: 1, + handles, + }; + let result = WasmtimeTaskRuntime::new() + .and_then(|runtime| { + runtime.run_task_export_verified_with_task_host( + &module, + &bundle_digest, + &export, + &invocation, + Box::new(host), + ) + }) + .map_err(|error| error.to_string()); + let _ = sender.send(MainCommand::Finished { scope, result }); + }) + .map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "start coordinator main runtime thread: {error}" + )) + })?; + Ok(()) + } + + pub(super) fn interrupt_process( + &mut self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + reason: &str, + ) { + let process_key = process_control_key(tenant, project, process); + if let Some(control) = self.controls.get_mut(&process_key) { + control.abort.store(true, Ordering::Release); + control.state = "stopping".to_owned(); + } + let waiter_keys = self + .join_waiters + .keys() + .filter(|(waiter_tenant, waiter_project, waiter_process, _)| { + waiter_tenant == tenant && waiter_project == project && waiter_process == process + }) + .cloned() + .collect::>(); + for key in waiter_keys { + if let Some(waiters) = self.join_waiters.remove(&key) { + for waiter in waiters { + let _ = waiter.send(Err(reason.to_owned())); + } + } + } + } + + fn is_current_scope(&self, scope: &MainScope) -> bool { + self.controls + .get(&process_control_key( + &scope.tenant, + &scope.project, + &scope.process, + )) + .is_some_and(|control| control.launch_id == scope.launch_id) + } +} + +struct CoordinatorMainHost { + scope: MainScope, + sender: Sender, + abort: Arc, + debug: Arc, + task_descriptors: HashMap, + environments: BTreeMap, + bundle_digest: Digest, + wasm_module_base64: String, + next_handle_id: u64, + handles: Arc>>, +} + +impl WasmTaskHost for CoordinatorMainHost { + fn abort_signal(&self) -> Option> { + Some(Arc::clone(&self.abort)) + } + + fn debug_control(&self) -> Option> { + Some(Arc::clone(&self.debug)) + } + + fn start_task( + &mut self, + request: WasmHostTaskStartRequest, + ) -> Result { + request.validate()?; + if self.abort.load(Ordering::Acquire) { + return Err("coordinator main is stopping".to_owned()); + } + let descriptor = self + .task_descriptors + .get(request.task_definition.as_str()) + .ok_or_else(|| { + format!( + "bundle has no task descriptor named `{}`", + request.task_definition + ) + })?; + let export = descriptor + .get("export") + .and_then(serde_json::Value::as_str) + .filter(|export| !export.trim().is_empty()) + .ok_or_else(|| { + format!( + "task `{}` descriptor omitted its Wasm export", + request.task_definition + ) + })?; + let mut required_capabilities = descriptor + .get("required_capabilities") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .map(|value| { + capability_from_descriptor( + value + .as_str() + .ok_or("task capability descriptor is not a string")?, + ) + }) + .collect::, _>>()?; + let selected_environment = request + .environment_id + .as_deref() + .map(|environment| { + self.environments.get(environment).cloned().ok_or_else(|| { + format!("bundle environment manifest has no environment `{environment}`") + }) + }) + .transpose()?; + let environment = selected_environment + .as_ref() + .map(|environment| environment.requirements.clone()); + let environment_digest = selected_environment + .as_ref() + .map(|environment| environment.digest.clone()); + if let Some(environment) = &environment { + required_capabilities.extend(environment.capabilities.iter().cloned()); + } + let required_artifacts = request + .args + .iter() + .flat_map(TaskBoundaryValue::required_artifacts) + .collect::>() + .into_iter() + .collect::>(); + let source_snapshots = request + .args + .iter() + .flat_map(TaskBoundaryValue::source_snapshots) + .collect::>(); + if source_snapshots.len() > 1 { + return Err( + "one task invocation cannot require multiple distinct source snapshots".to_owned(), + ); + } + if self + .handles + .lock() + .map_err(|_| "coordinator main handle registry is unavailable".to_owned())? + .len() + >= super::MAX_IN_FLIGHT_TASKS_PER_PROCESS + { + return Err(format!( + "coordinator main task-handle limit of {} reached", + super::MAX_IN_FLIGHT_TASKS_PER_PROCESS + )); + } + let handle_id = self.next_handle_id; + let task_instance = + TaskInstanceId::new(format!("{}:child:{handle_id}", self.scope.task_instance)); + let task_spec = TaskSpec { + tenant: self.scope.tenant.clone(), + project: self.scope.project.clone(), + process: self.scope.process.clone(), + task_definition: request.task_definition, + task_instance, + dispatch: TaskDispatch::CoordinatorNodeWasm { + export: Some(export.to_owned()), + abi: WasmExportAbi::TaskV1, + }, + environment_id: request.environment_id, + environment, + environment_digest, + required_capabilities, + dependency_cache: None, + source_snapshot: source_snapshots.into_iter().next(), + required_artifacts, + args: request.args, + vfs_epoch: self.scope.epoch, + bundle_digest: Some(self.bundle_digest.clone()), + }; + let (response, receiver) = mpsc::sync_channel(1); + self.sender + .send(MainCommand::StartTask { + scope: self.scope.clone(), + handle_id, + task_spec: task_spec.clone(), + wasm_module_base64: self.wasm_module_base64.clone(), + response, + }) + .map_err(|_| "coordinator main command channel closed".to_owned())?; + let handle = receiver + .recv() + .map_err(|_| "coordinator main task-start response channel closed".to_owned())??; + self.handles + .lock() + .map_err(|_| "coordinator main handle registry is unavailable".to_owned())? + .insert(handle_id, task_spec); + self.next_handle_id = self.next_handle_id.saturating_add(1); + Ok(handle) + } + + fn join_task( + &mut self, + request: WasmHostTaskJoinRequest, + ) -> Result { + let task_spec = self + .handles + .lock() + .map_err(|_| "coordinator main handle registry is unavailable".to_owned())? + .get(&request.handle_id) + .cloned() + .ok_or_else(|| format!("unknown Wasm task handle {}", request.handle_id))?; + let (response, receiver) = mpsc::sync_channel(1); + self.sender + .send(MainCommand::JoinTask { + scope: self.scope.clone(), + task_instance: task_spec.task_instance, + response, + }) + .map_err(|_| "coordinator main command channel closed".to_owned())?; + let joined = receiver + .recv() + .map_err(|_| "coordinator main task-join response channel closed".to_owned())?; + self.handles + .lock() + .map_err(|_| "coordinator main handle registry is unavailable".to_owned())? + .remove(&request.handle_id); + joined + } + + fn run_command( + &mut self, + _request: WasmHostCommandRequest, + ) -> Result { + Err("coordinator main is capless and cannot run native commands".to_owned()) + } + + fn poll_task_control( + &mut self, + request: WasmHostTaskControlRequest, + ) -> Result { + request.validate()?; + Ok(WasmHostTaskControlResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + cancellation_requested: self.abort.load(Ordering::Acquire), + }) + } + + fn debug_probe( + &mut self, + request: WasmHostDebugProbeRequest, + ) -> Result { + request.validate()?; + let (response, receiver) = mpsc::sync_channel(1); + self.sender + .send(MainCommand::DebugProbe { + scope: self.scope.clone(), + request, + response, + }) + .map_err(|_| "coordinator main command channel closed".to_owned())?; + receiver + .recv() + .map_err(|_| "coordinator main debug-probe response channel closed".to_owned())? + } + + fn vfs_operation(&mut self, _request: WasmHostVfsRequest) -> Result { + Err("coordinator main is capless and cannot access task VFS files".to_owned()) + } + + fn snapshot_source( + &mut self, + _request: WasmHostSourceSnapshotRequest, + ) -> Result { + Err("coordinator main is capless and cannot access source checkouts".to_owned()) + } +} + +impl CoordinatorService { + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_launch_coordinator_main( + &mut self, + tenant: String, + project: String, + actor_user: Option, + actor_agent: Option, + agent_public_key_fingerprint: Option, + agent_signature: Option, + request_payload_digest: Option<&Digest>, + task_spec: TaskSpec, + wasm_module_base64: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = task_spec.process.clone(); + let task_instance = task_spec.task_instance.clone(); + let actor = self.workflow_actor( + &tenant, + &project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + request_payload_digest, + "launch_task", + &process, + Some(&task_instance), + )?; + let active = self + .coordinator + .active_process(&tenant, &project, &process) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "coordinator main launch requires an active virtual process".to_owned(), + ) + })?; + debug_assert_eq!(active.tenant, tenant); + debug_assert_eq!(active.project, project); + if task_spec.tenant != tenant || task_spec.project != project { + return Err(CoordinatorError::Unauthorized( + "coordinator main TaskSpec is outside the authenticated scope".to_owned(), + ) + .into()); + } + let export = match &task_spec.dispatch { + TaskDispatch::CoordinatorNodeWasm { + export: Some(export), + abi: WasmExportAbi::EntrypointV1, + } => export.clone(), + _ => { + return Err(CoordinatorServiceError::Protocol( + "coordinator main requires an explicit EntrypointV1 Wasm export".to_owned(), + )) + } + }; + if task_spec.environment_id.is_some() + || task_spec.environment.is_some() + || task_spec.environment_digest.is_some() + || !task_spec.required_capabilities.is_empty() + || task_spec.dependency_cache.is_some() + || task_spec.source_snapshot.is_some() + || !task_spec.required_artifacts.is_empty() + || !task_spec.args.is_empty() + { + return Err(CoordinatorError::Unauthorized( + "coordinator main must be capless and may not receive environment, source, artifact, cache, or argument authority" + .to_owned(), + ) + .into()); + } + let bundle_digest = task_spec.bundle_digest.clone().ok_or_else(|| { + CoordinatorServiceError::Protocol( + "coordinator main TaskSpec omitted bundle digest".to_owned(), + ) + })?; + let module = BASE64_STANDARD + .decode(&wasm_module_base64) + .map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "coordinator main module is not valid base64: {error}" + )) + })?; + let actual_digest = Digest::sha256(&module); + if actual_digest != bundle_digest { + return Err(CoordinatorError::Unauthorized(format!( + "coordinator main module digest mismatch: expected {bundle_digest}, actual {actual_digest}" + )) + .into()); + } + WasmTaskInvocation::new( + task_spec.task_definition.clone(), + task_instance.clone(), + Vec::new(), + ) + .validate() + .map_err(CoordinatorServiceError::Protocol)?; + let descriptors = task_descriptors(&module)?; + let environments = bundle_environments(&module)?; + let scope = MainScope { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + task_definition: task_spec.task_definition.clone(), + task_instance: task_instance.clone(), + epoch: task_spec.vfs_epoch, + launch_id: 0, + }; + self.main_runtime.launch( + scope, + export, + module, + wasm_module_base64, + bundle_digest, + descriptors, + environments, + )?; + Ok(CoordinatorResponse::MainLaunched { + process, + task_definition: task_spec.task_definition, + task_instance, + actor, + state: "running".to_owned(), + }) + } + + pub(super) fn pump_main_runtime_commands(&mut self) { + let commands = self.main_runtime.drain_commands(); + for command in commands { + match command { + MainCommand::StartTask { + scope, + handle_id, + task_spec, + wasm_module_base64, + response, + } => { + if !self.main_runtime.is_current_scope(&scope) { + let _ = response.send(Err( + "coordinator main process incarnation was replaced".to_owned(), + )); + continue; + } + let actor = WorkflowActor { + kind: "task".to_owned(), + user: None, + agent: None, + credential_kind: CredentialKind::TaskCredential, + public_key_fingerprint: None, + authenticated_without_browser: true, + scopes: vec!["process:spawn-child".to_owned()], + }; + let result = self + .handle_launch_task_with_actor( + scope.tenant, + scope.project, + actor, + task_spec.clone(), + true, + format!("/vfs/artifacts/{}-result.json", task_spec.task_instance), + wasm_module_base64, + ) + .and_then(|launch| match launch { + CoordinatorResponse::TaskLaunched { .. } + | CoordinatorResponse::TaskQueued { .. } => Ok(WasmHostTaskHandle { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + handle_id, + task_spec, + }), + other => Err(CoordinatorServiceError::Protocol(format!( + "unexpected coordinator-main child launch response: {other:?}" + ))), + }) + .map_err(|error| error.to_string()); + let _ = response.send(result); + } + MainCommand::JoinTask { + scope, + task_instance, + response, + } => { + if !self.main_runtime.is_current_scope(&scope) { + let _ = response.send(Err( + "coordinator main process incarnation was replaced".to_owned(), + )); + continue; + } + let join = self.task_join_result( + scope.tenant.clone(), + scope.project.clone(), + scope.process.clone(), + task_instance.clone(), + ); + match join.state { + TaskJoinState::Completed => { + let result = join + .result + .ok_or_else(|| { + "completed child task omitted its boundary result".to_owned() + }) + .map(|result| WasmHostTaskJoinResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + task_instance, + result, + }); + let _ = response.send(result); + } + TaskJoinState::Failed | TaskJoinState::Cancelled => { + let _ = response.send(Err(join.message)); + } + TaskJoinState::Pending => { + self.main_runtime + .join_waiters + .entry(task_restart_key( + &scope.tenant, + &scope.project, + &scope.process, + &task_instance, + )) + .or_default() + .push(response); + } + } + } + MainCommand::DebugProbe { + scope, + request, + response, + } => { + if !self.main_runtime.is_current_scope(&scope) { + let _ = response.send(Err( + "coordinator main process incarnation was replaced".to_owned(), + )); + continue; + } + let result = self + .handle_coordinator_main_debug_probe( + scope.tenant, + scope.project, + scope.process, + scope.task_instance, + request.symbol, + ) + .map_err(|error| error.to_string()); + let _ = response.send(result); + } + MainCommand::Finished { scope, result } => { + if !self.main_runtime.is_current_scope(&scope) { + continue; + } + self.record_coordinator_main_completion(scope, result); + } + } + } + } + + pub(super) fn notify_coordinator_main_waiters(&mut self, event: &TaskCompletionEvent) { + let key = task_restart_key(&event.tenant, &event.project, &event.process, &event.task); + let Some(waiters) = self.main_runtime.join_waiters.remove(&key) else { + return; + }; + let result = match event.terminal_state { + TaskTerminalState::Completed => event + .result + .clone() + .ok_or_else(|| "completed child task omitted its boundary result".to_owned()) + .map(|result| WasmHostTaskJoinResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + task_instance: event.task.clone(), + result, + }), + TaskTerminalState::Failed | TaskTerminalState::Cancelled => { + Err(event.stderr_tail.clone()) + } + }; + for waiter in waiters { + let _ = waiter.send(result.clone()); + } + } + + fn record_coordinator_main_completion( + &mut self, + scope: MainScope, + result: Result, + ) { + let (terminal_state, boundary, error) = match result { + Ok(result) if result.outcome == WasmTaskOutcome::Completed => { + (TaskTerminalState::Completed, result.result, String::new()) + } + Ok(result) => ( + TaskTerminalState::Failed, + None, + result + .error + .unwrap_or_else(|| "coordinator main failed without an error".to_owned()), + ), + Err(error) => (TaskTerminalState::Failed, None, error), + }; + let main_state = match terminal_state { + TaskTerminalState::Completed => "completed", + TaskTerminalState::Failed => "failed", + TaskTerminalState::Cancelled => "cancelled", + }; + let event = TaskCompletionEvent { + tenant: scope.tenant.clone(), + project: scope.project.clone(), + process: scope.process.clone(), + node: NodeId::from("coordinator-main"), + executor: TaskExecutor::CoordinatorMain, + task_definition: scope.task_definition, + task: scope.task_instance, + placement: None, + terminal_state, + status_code: if error.is_empty() { Some(0) } else { None }, + stdout_bytes: 0, + stderr_bytes: error.len() as u64, + stdout_tail: String::new(), + stderr_tail: error, + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: boundary, + }; + self.record_task_completion_event(event); + if let Some(control) = self.main_runtime.controls.get_mut(&process_control_key( + &scope.tenant, + &scope.project, + &scope.process, + )) { + control.state = main_state.to_owned(); + control.stopped_probe_symbol = None; + if let Ok(mut handles) = control.handles.lock() { + handles.clear(); + } + } + // Completion keeps the virtual process and its final debug handshake + // inspectable. In particular, the DAP client may still be waiting for + // every participant's resume acknowledgement when the main Wasm task + // exits. A new process incarnation or an explicit abort clears this + // ephemeral state. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completed_main_keeps_process_and_debug_handshake_visible_until_explicit_abort() { + let mut service = CoordinatorService::new(7); + let tenant = TenantId::from("tenant"); + let project = ProjectId::from("project"); + let process = ProcessId::from("vp-current"); + let main_task = TaskInstanceId::from("ti:vp-current:main"); + let scope = MainScope { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + task_definition: TaskDefinitionId::from("build"), + task_instance: main_task.clone(), + epoch: 7, + launch_id: 1, + }; + service + .coordinator + .start_process(tenant.clone(), project.clone(), process.clone()); + let process_key = process_control_key(&tenant, &project, &process); + service.main_runtime.controls.insert( + process_key.clone(), + CoordinatorMainControl { + task_definition: scope.task_definition.clone(), + task_instance: main_task.clone(), + abort: Arc::new(AtomicBool::new(false)), + debug: Arc::new(WasmDebugControl::default()), + state: "running".to_owned(), + stopped_probe_symbol: None, + handles: Arc::new(Mutex::new(HashMap::new())), + launch_id: 1, + }, + ); + service.debug_epochs.insert(process_key.clone(), 2); + service.debug_epoch_runtime.insert( + process_key.clone(), + super::super::debug::DebugEpochRuntime { + epoch: 2, + command: "resume".to_owned(), + expected: BTreeSet::new(), + acknowledgements: BTreeMap::new(), + }, + ); + + service.record_coordinator_main_completion( + scope, + Ok(WasmTaskResult::completed( + main_task, + TaskBoundaryValue::SmallJson(serde_json::Value::Null), + )), + ); + + assert!(service + .coordinator + .active_process(&tenant, &project, &process) + .is_some()); + let CoordinatorResponse::ProcessStatuses { processes, .. } = service + .handle_list_processes( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + "user".to_owned(), + ) + .unwrap() + else { + panic!("expected process statuses"); + }; + assert_eq!(processes.len(), 1); + assert_eq!(processes[0].state, "completed"); + assert_eq!(processes[0].main_state.as_deref(), Some("completed")); + assert_eq!(service.debug_epochs.get(&process_key), Some(&2)); + assert_eq!( + service + .debug_epoch_runtime + .get(&process_key) + .map(|runtime| runtime.command.as_str()), + Some("resume") + ); + + service + .handle_abort_process( + tenant.as_str().to_owned(), + project.as_str().to_owned(), + "user".to_owned(), + process.as_str().to_owned(), + ) + .unwrap(); + assert!(service + .coordinator + .active_process(&tenant, &project, &process) + .is_none()); + assert!(!service.main_runtime.controls.contains_key(&process_key)); + assert!(!service.debug_epochs.contains_key(&process_key)); + assert!(!service.debug_epoch_runtime.contains_key(&process_key)); + } +} + +pub(super) fn task_descriptors( + module: &[u8], +) -> Result, CoordinatorServiceError> { + let mut descriptors = HashMap::new(); + for payload in Parser::new(0).parse_all(module) { + let Payload::CustomSection(section) = payload.map_err(|error| { + CoordinatorServiceError::Protocol(format!("parse coordinator main bundle: {error}")) + })? + else { + continue; + }; + if section.name() != "disasmer.tasks" { + continue; + } + for record in section + .data() + .split(|byte| *byte == b'\n' || *byte == 0) + .filter(|record| !record.is_empty()) + { + let descriptor: serde_json::Value = serde_json::from_slice(record)?; + let name = descriptor + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + CoordinatorServiceError::Protocol("task descriptor omitted its name".to_owned()) + })? + .to_owned(); + descriptors.insert(name, descriptor); + } + } + Ok(descriptors) +} + +pub(super) fn bundle_environments( + module: &[u8], +) -> Result, CoordinatorServiceError> { + for payload in Parser::new(0).parse_all(module) { + let Payload::CustomSection(section) = payload.map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "parse coordinator main environment manifest: {error}" + )) + })? + else { + continue; + }; + if section.name() != "disasmer.environments" { + continue; + } + let environments: Vec = serde_json::from_slice(section.data()) + .map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "bundle environment manifest is invalid: {error}" + )) + })?; + let mut by_name = BTreeMap::new(); + for environment in environments { + if by_name + .insert(environment.name.clone(), environment) + .is_some() + { + return Err(CoordinatorServiceError::Protocol( + "bundle environment manifest contains duplicate names".to_owned(), + )); + } + } + return Ok(by_name); + } + Ok(BTreeMap::new()) +} + +pub(super) fn capability_from_descriptor(capability: &str) -> Result { + match capability.to_ascii_lowercase().as_str() { + "command" => Ok(Capability::Command), + "rootless_podman" => Ok(Capability::RootlessPodman), + "source_git" => Ok(Capability::SourceGit), + "source_filesystem" => Ok(Capability::SourceFilesystem), + "network" => Ok(Capability::Network), + "host_filesystem" => Ok(Capability::HostFilesystem), + "secrets" => Ok(Capability::Secrets), + "inbound_ports" => Ok(Capability::InboundPorts), + "arbitrary_syscalls" => Ok(Capability::ArbitrarySyscalls), + "vfs_artifacts" => Ok(Capability::VfsArtifacts), + "windows_command_dev" => Ok(Capability::WindowsCommandDev), + "quic_direct" => Ok(Capability::QuicDirect), + other => Err(format!("unknown task capability `{other}`")), + } +} diff --git a/crates/disasmer-coordinator/src/service/nodes.rs b/crates/disasmer-coordinator/src/service/nodes.rs new file mode 100644 index 0000000..becd733 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/nodes.rs @@ -0,0 +1,407 @@ +use std::collections::BTreeSet; +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{ + generate_opaque_token, verify_node_request_signature, Actor, ArtifactId, CredentialKind, + Digest, NodeCapabilities, NodeDescriptor, NodeId, NodeSignedRequest, ProjectId, + SourceProviderKind, TenantId, UserId, +}; + +use crate::CoordinatorError; + +use super::{ + bounded_ttl, enrollment_grant_key, CoordinatorResponse, CoordinatorService, + CoordinatorServiceError, +}; + +impl CoordinatorService { + pub(super) fn handle_attach_node( + &mut self, + tenant: String, + project: String, + node: String, + public_key: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + self.coordinator.ensure_tenant_active(&tenant)?; + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator.upsert_user( + tenant.clone(), + UserId::from("local-user"), + CredentialKind::CliDeviceSession, + ); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), "local"); + self.coordinator.upsert_source_provider_config( + tenant.clone(), + project.clone(), + SourceProviderKind::Filesystem, + Digest::sha256("local-filesystem"), + ); + self.coordinator.enroll_node( + tenant.clone(), + project.clone(), + node.clone(), + public_key, + "node:attach", + ); + self.persist_durable_state()?; + Ok(CoordinatorResponse::NodeAttached { + node, + tenant, + project, + }) + } + + pub(super) fn handle_create_node_enrollment_grant( + &mut self, + tenant: String, + project: String, + actor_user: String, + ttl_seconds: u64, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + self.coordinator.ensure_tenant_active(&tenant)?; + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator + .upsert_user(tenant.clone(), actor, CredentialKind::CliDeviceSession); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), "local"); + self.coordinator.upsert_source_provider_config( + tenant.clone(), + project.clone(), + SourceProviderKind::Filesystem, + Digest::sha256("local-filesystem"), + ); + let now_epoch_seconds = self.current_epoch_seconds()?; + self.enrollment_grants.retain(|_, grant| { + !grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds + }); + if self + .enrollment_grants + .values() + .filter(|grant| grant.tenant == tenant && grant.project == project) + .count() + >= super::MAX_ENROLLMENT_GRANTS_PER_PROJECT + { + return Err(CoordinatorServiceError::Protocol( + "node enrollment grant limit reached for this project; consume a grant or wait for one to expire" + .to_owned(), + )); + } + let grant = + generate_opaque_token("node_grant").map_err(CoordinatorServiceError::Protocol)?; + let ttl_seconds = bounded_ttl(ttl_seconds, self.admission.max_node_enrollment_ttl_seconds); + let scope = "node:attach".to_owned(); + let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds); + let enrollment = self.coordinator.create_node_enrollment_grant( + tenant.clone(), + project.clone(), + grant.clone(), + scope.clone(), + expires_at_epoch_seconds, + ); + self.enrollment_grants + .insert(enrollment_grant_key(&tenant, &project, &grant), enrollment); + self.persist_durable_state()?; + Ok(CoordinatorResponse::NodeEnrollmentGrantCreated { + tenant, + project, + grant, + scope, + expires_at_epoch_seconds, + }) + } + + pub(super) fn handle_exchange_node_enrollment_grant( + &mut self, + tenant: String, + project: String, + node: String, + public_key: String, + enrollment_grant: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let now_epoch_seconds = self.current_epoch_seconds()?; + self.enrollment_grants.retain(|_, grant| { + !grant.consumed && grant.expires_at_epoch_seconds >= now_epoch_seconds + }); + self.coordinator.ensure_tenant_active(&tenant)?; + let grant_key = enrollment_grant_key(&tenant, &project, &enrollment_grant); + let grant = + self.enrollment_grants + .get_mut(&grant_key) + .ok_or(CoordinatorError::Enrollment( + disasmer_core::EnrollmentError::Expired, + ))?; + let credential = self.coordinator.exchange_node_enrollment_grant( + grant, + node.clone(), + &public_key, + "node:attach", + now_epoch_seconds, + )?; + self.enrollment_grants.remove(&grant_key); + self.persist_durable_state()?; + Ok(CoordinatorResponse::NodeEnrollmentExchanged { + node, + tenant, + project, + credential, + }) + } + + pub(super) fn handle_node_heartbeat( + &mut self, + node: String, + node_signature: Option, + payload_digest: &Digest, + ) -> Result { + let node = NodeId::new(node); + self.authenticate_node_request(&node, node_signature, "node_heartbeat", payload_digest)?; + Ok(CoordinatorResponse::NodeHeartbeat { + node, + epoch: self.coordinator.coordinator_epoch(), + }) + } + + pub(super) fn handle_report_node_capabilities( + &mut self, + tenant: String, + project: String, + node: String, + capabilities: NodeCapabilities, + cached_environment_digests: Vec, + dependency_cache_digests: Vec, + source_snapshots: Vec, + artifact_locations: Vec, + direct_connectivity: bool, + online: bool, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let identity = self + .coordinator + .node_identity(&node) + .ok_or(CoordinatorError::UnknownNode)?; + if identity.tenant != tenant || identity.project != project { + return Err(CoordinatorError::Unauthorized( + "node capability report is outside the enrolled tenant/project scope".to_owned(), + ) + .into()); + } + capabilities.validate_public_report()?; + for (kind, count) in [ + ("cached environments", cached_environment_digests.len()), + ("dependency caches", dependency_cache_digests.len()), + ("source snapshots", source_snapshots.len()), + ("artifact locations", artifact_locations.len()), + ] { + if count > super::MAX_NODE_REPORTED_OBJECTS_PER_KIND { + return Err(CoordinatorServiceError::Protocol(format!( + "node capability report contains {count} {kind}; limit is {}", + super::MAX_NODE_REPORTED_OBJECTS_PER_KIND + ))); + } + } + if cached_environment_digests + .iter() + .chain(&dependency_cache_digests) + .chain(&source_snapshots) + .any(|digest| !digest.is_valid_sha256()) + { + return Err(CoordinatorServiceError::Protocol( + "node capability report contains an invalid digest".to_owned(), + )); + } + if artifact_locations.iter().any(|artifact| { + artifact.trim().is_empty() + || artifact.len() > 256 + || artifact + .chars() + .any(|character| matches!(character, '/' | '\\' | '\0')) + }) { + return Err(CoordinatorServiceError::Protocol( + "node capability report contains an invalid artifact id".to_owned(), + )); + } + let artifact_locations = artifact_locations + .into_iter() + .map(ArtifactId::new) + .collect::>(); + self.artifact_registry + .reconcile_node_retention(&node, &artifact_locations); + + self.node_descriptors.insert( + node.clone(), + NodeDescriptor { + id: node.clone(), + tenant, + project, + capabilities, + cached_environments: cached_environment_digests.into_iter().collect(), + dependency_caches: dependency_cache_digests.into_iter().collect(), + source_snapshots: source_snapshots.into_iter().collect(), + artifact_locations, + direct_connectivity, + online, + }, + ); + Ok(CoordinatorResponse::NodeCapabilitiesRecorded { + node, + node_descriptors: self.node_descriptors.len(), + }) + } + + pub(super) fn handle_list_node_descriptors( + &mut self, + tenant: String, + project: String, + actor_user: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let descriptors = self + .node_descriptors + .values() + .filter(|descriptor| descriptor.tenant == tenant && descriptor.project == project) + .cloned() + .collect(); + Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor }) + } + + pub(super) fn handle_revoke_node_credential( + &mut self, + tenant: String, + project: String, + actor_user: String, + node: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let node = NodeId::new(node); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: project.clone(), + actor: Actor::User(actor.clone()), + }; + self.coordinator.revoke_node_credential(&context, &node)?; + let descriptor_removed = self.node_descriptors.remove(&node).is_some(); + self.artifact_registry.garbage_collect_node(&node); + let queued_assignments_removed = self + .task_assignments + .remove(&(tenant.clone(), project.clone(), node.clone())) + .map_or(0, |assignments| assignments.len()); + self.active_tasks + .retain(|(task_tenant, task_project, _, task_node, _)| { + task_tenant != &tenant || task_project != &project || task_node != &node + }); + self.task_cancellations + .retain(|(task_tenant, task_project, _, task_node, _)| { + task_tenant != &tenant || task_project != &project || task_node != &node + }); + self.task_aborts + .retain(|(task_tenant, task_project, _, task_node, _)| { + task_tenant != &tenant || task_project != &project || task_node != &node + }); + self.task_placements + .retain(|(task_tenant, task_project, _, task_node, _), _| { + task_tenant != &tenant || task_project != &project || task_node != &node + }); + self.persist_durable_state()?; + Ok(CoordinatorResponse::NodeCredentialRevoked { + node, + tenant, + project, + actor, + descriptor_removed, + queued_assignments_removed, + }) + } + + pub(super) fn authenticate_node_request( + &mut self, + node: &NodeId, + node_signature: Option, + request_kind: &str, + payload_digest: &Digest, + ) -> Result<(), CoordinatorServiceError> { + let identity = self + .coordinator + .node_identity(node) + .ok_or(CoordinatorError::UnknownNode)?; + let signature = node_signature.ok_or_else(|| { + CoordinatorError::Unauthorized( + "node request requires a signed proof of enrolled private-key possession" + .to_owned(), + ) + })?; + if signature.nonce.trim().is_empty() || signature.nonce.len() > 256 { + return Err(CoordinatorError::Unauthorized( + "node signed request nonce is missing or invalid".to_owned(), + ) + .into()); + } + let now_epoch_seconds = unix_timestamp_seconds(); + if signature + .issued_at_epoch_seconds + .abs_diff(now_epoch_seconds) + > super::NODE_SIGNATURE_WINDOW_SECONDS + { + return Err(CoordinatorError::Unauthorized( + "node signed request is expired or outside the allowed clock skew".to_owned(), + ) + .into()); + } + let replay_key = (node.clone(), signature.nonce.clone()); + self.node_replay_nonces.retain(|_, accepted_at| { + now_epoch_seconds <= accepted_at.saturating_add(super::NODE_SIGNATURE_WINDOW_SECONDS) + }); + if self.node_replay_nonces.contains_key(&replay_key) { + return Err(CoordinatorError::Unauthorized( + "node signed request nonce has already been used".to_owned(), + ) + .into()); + } + verify_node_request_signature( + &identity.public_key, + node, + request_kind, + payload_digest, + &signature, + ) + .map_err(CoordinatorError::Unauthorized)?; + if self + .node_replay_nonces + .keys() + .filter(|(retained_node, _)| retained_node == node) + .count() + >= super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY + { + return Err(CoordinatorError::Unauthorized( + "node signed request replay window is full; retry after the bounded signature window advances" + .to_owned(), + ) + .into()); + } + self.node_replay_nonces + .insert(replay_key, now_epoch_seconds); + Ok(()) + } +} + +fn unix_timestamp_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} diff --git a/crates/disasmer-coordinator/src/service/panels.rs b/crates/disasmer-coordinator/src/service/panels.rs new file mode 100644 index 0000000..9de25bd --- /dev/null +++ b/crates/disasmer-coordinator/src/service/panels.rs @@ -0,0 +1,307 @@ +use disasmer_core::{ + Actor, DownloadPolicy, PanelEvent, PanelEventKind, PanelState, PanelWidget, PanelWidgetKind, + ProcessId, ProjectId, RateLimit, TenantId, UserId, +}; + +use crate::CoordinatorError; + +use super::artifact_id_from_path; +use super::keys::panel_stop_key; +use super::{CoordinatorResponse, CoordinatorService, CoordinatorServiceError}; + +impl CoordinatorService { + pub(super) fn clear_operator_panel_state( + &mut self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) { + let stop_key = panel_stop_key(tenant, project, process); + self.panel_snapshots.remove(&stop_key); + self.stopped_panels.remove(&stop_key); + self.panel_event_limits + .retain(|(event_tenant, event_project, event_process, _), _| { + event_tenant != tenant || event_project != project || event_process != process + }); + } + + pub(super) fn handle_render_operator_panel( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + max_download_bytes: u64, + stopped: bool, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let stop_key = panel_stop_key(&tenant, &project, &process); + + let panel = if stopped { + self.ensure_operator_panel_scope(&tenant, &project, &process)?; + self.stopped_panels.insert(stop_key); + let stop_key = panel_stop_key(&tenant, &project, &process); + let panel = match self.panel_snapshots.get(&stop_key).cloned() { + Some(panel) => stopped_panel_snapshot(panel), + None => self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::new(actor_user), + max_download_bytes, + true, + )?, + }; + self.panel_snapshots.insert(stop_key, panel.clone()); + panel + } else { + self.stopped_panels.remove(&stop_key); + let panel = self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::new(actor_user), + max_download_bytes, + false, + )?; + self.panel_snapshots.insert(stop_key, panel.clone()); + panel + }; + Ok(CoordinatorResponse::OperatorPanel { panel }) + } + + pub(super) fn handle_submit_panel_event( + &mut self, + tenant: String, + project: String, + process: String, + widget_id: String, + kind: PanelEventKind, + max_events: u64, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let stop_key = panel_stop_key(&tenant, &project, &process); + let stopped = self.stopped_panels.contains(&stop_key); + let panel = if stopped { + match self.panel_snapshots.get(&stop_key).cloned() { + Some(panel) => stopped_panel_snapshot(panel), + None => { + let panel = self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::from("panel-user"), + self.quota.download_limit(), + true, + )?; + self.panel_snapshots.insert(stop_key.clone(), panel.clone()); + panel + } + } + } else { + let panel = self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::from("panel-user"), + self.quota.download_limit(), + false, + )?; + self.panel_snapshots.insert(stop_key, panel.clone()); + panel + }; + let event = PanelEvent { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + widget_id: widget_id.clone(), + kind, + }; + let limit_key = (tenant, project, process, widget_id); + let mut limit = self + .panel_event_limits + .get(&limit_key) + .cloned() + .unwrap_or(RateLimit { + max_events, + used_events: 0, + }); + limit.max_events = max_events; + panel.accept_event(&event, &mut limit)?; + self.panel_event_limits.insert(limit_key, limit.clone()); + Ok(CoordinatorResponse::PanelEventAccepted { + used_events: limit.used_events, + max_events: limit.max_events, + }) + } + + fn render_operator_panel( + &self, + tenant: TenantId, + project: ProjectId, + process: ProcessId, + actor_user: UserId, + max_download_bytes: u64, + stopped: bool, + ) -> Result { + self.ensure_operator_panel_scope(&tenant, &project, &process)?; + + let events = self + .task_events + .iter() + .filter(|event| { + event.tenant == tenant && event.project == project && event.process == process + }) + .collect::>(); + let completed = events + .iter() + .filter(|event| event.status_code == Some(0)) + .count() as u64; + let total = events.len().max(1) as u64; + let stdout_bytes = events.iter().map(|event| event.stdout_bytes).sum::(); + let stderr_bytes = events.iter().map(|event| event.stderr_bytes).sum::(); + let last_task = events.last().map(|event| event.task.clone()); + + let mut panel = PanelState::new(tenant.clone(), project.clone(), process.clone()); + if stopped { + panel.freeze_program_ui_events(); + } + panel.add_widget(PanelWidget { + id: "process-status".to_owned(), + label: "Process Status".to_owned(), + kind: PanelWidgetKind::Text { + value: if stopped { + "stopped".to_owned() + } else { + "running".to_owned() + }, + }, + })?; + panel.add_widget(PanelWidget { + id: "task-progress".to_owned(), + label: "Tasks".to_owned(), + kind: PanelWidgetKind::Progress { + current: completed, + total, + }, + })?; + panel.add_widget(PanelWidget { + id: "task-summary".to_owned(), + label: "Task Summary".to_owned(), + kind: PanelWidgetKind::Text { + value: if events.is_empty() { + "no task events recorded".to_owned() + } else { + events + .iter() + .map(|event| { + format!( + "{} [{}]:{:?}:{}", + event.task_definition, event.task, event.status_code, event.node + ) + }) + .collect::>() + .join(", ") + }, + }, + })?; + panel.add_widget(PanelWidget { + id: "recent-logs".to_owned(), + label: "Recent Logs".to_owned(), + kind: PanelWidgetKind::Text { + value: format!("stdout={stdout_bytes} stderr={stderr_bytes}"), + }, + })?; + panel.add_widget(PanelWidget { + id: "debug-process".to_owned(), + label: "Debug Process".to_owned(), + kind: PanelWidgetKind::Button { + action: "debug-process".to_owned(), + }, + })?; + panel.add_widget(PanelWidget { + id: "cancel-process".to_owned(), + label: "Cancel Process".to_owned(), + kind: PanelWidgetKind::Button { + action: "cancel-process".to_owned(), + }, + })?; + if last_task.is_some() { + panel.add_widget(PanelWidget { + id: "restart-selected-task".to_owned(), + label: "Restart Selected Task".to_owned(), + kind: PanelWidgetKind::Button { + action: "restart-task".to_owned(), + }, + })?; + } + + let mut actions = vec![ + disasmer_core::ControlPlaneAction::DebugProcess, + disasmer_core::ControlPlaneAction::CancelProcess, + ]; + if let Some(task) = last_task.clone() { + actions.push(disasmer_core::ControlPlaneAction::RestartTask(task)); + } + panel.set_control_plane_actions(actions); + + if let Some(path) = events + .iter() + .rev() + .find_map(|event| event.artifact_path.as_ref()) + { + let artifact = artifact_id_from_path(path); + let context = disasmer_core::AuthContext { + tenant, + project, + actor: Actor::User(actor_user), + }; + panel.add_download_widget_from_action( + "download-artifact", + "Download Artifact", + self.artifact_registry.download_action( + &context, + &artifact, + &DownloadPolicy { + max_bytes: max_download_bytes, + }, + ), + )?; + } + + Ok(panel) + } + + fn ensure_operator_panel_scope( + &self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> Result<(), CoordinatorServiceError> { + let active = self + .coordinator + .active_process(tenant, project, process) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "operator panel requires an active virtual process".to_owned(), + ) + })?; + debug_assert_eq!(active.tenant, *tenant); + debug_assert_eq!(active.project, *project); + Ok(()) + } +} + +fn stopped_panel_snapshot(mut panel: PanelState) -> PanelState { + panel.freeze_program_ui_events(); + if let Some(status) = panel.widgets.get_mut("process-status") { + status.kind = PanelWidgetKind::Text { + value: "stopped".to_owned(), + }; + } + panel +} diff --git a/crates/disasmer-coordinator/src/service/process_launch.rs b/crates/disasmer-coordinator/src/service/process_launch.rs new file mode 100644 index 0000000..30cd228 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/process_launch.rs @@ -0,0 +1,555 @@ +use std::collections::BTreeMap; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::{ + AgentSignedRequest, ArtifactId, CheckpointBoundary, CredentialKind, DefaultScheduler, Digest, + NodeId, PlacementRequest, ProcessId, ProjectId, Scheduler, TaskCheckpoint, TaskDispatch, + TaskInstanceId, TaskSpec, TenantId, VfsManifest, VfsObject, VfsPath, WasmTaskInvocation, +}; + +use crate::CoordinatorError; + +use super::keys::{process_control_key, task_control_key, task_restart_key}; +use super::{ + CoordinatorResponse, CoordinatorService, CoordinatorServiceError, TaskAssignment, WorkflowActor, +}; + +use super::processes::*; + +impl CoordinatorService { + pub(super) fn capture_task_restart_checkpoint( + &mut self, + assignment: &TaskAssignment, + ) -> Result<(), CoordinatorServiceError> { + let task_spec = &assignment.task_spec; + let environment_digest = task_spec.environment_digest.clone().unwrap_or_else(|| { + task_spec.environment.as_ref().map_or_else( + || Digest::sha256("disasmer.environment.unconstrained.v1"), + |environment| { + Digest::sha256( + serde_json::to_vec(environment) + .expect("serializable environment requirements"), + ) + }, + ) + }); + let task_entrypoint = match &task_spec.dispatch { + disasmer_core::TaskDispatch::CoordinatorNodeWasm { export, .. } => export + .clone() + .or_else(|| assignment_task_descriptor(assignment)?.get("export")?.as_str().map(str::to_owned)) + .ok_or_else(|| { + CoordinatorServiceError::Protocol(format!( + "cannot capture restart checkpoint for task `{}`: bundle descriptor omitted its Wasm export", + task_spec.task_definition + )) + })?, + }; + let mut objects = BTreeMap::new(); + let mut missing_required_artifact = false; + for artifact in &task_spec.required_artifacts { + let Some(metadata) = self.artifact_registry.metadata(artifact) else { + missing_required_artifact = true; + continue; + }; + if metadata.tenant != assignment.tenant + || metadata.project != assignment.project + || metadata.retaining_nodes.is_empty() + { + missing_required_artifact = true; + continue; + } + let path = VfsPath::new(format!("/vfs/artifacts/{artifact}")) + .map_err(|error| CoordinatorServiceError::InvalidArtifactPath(error.to_string()))?; + objects.insert( + path.clone(), + VfsObject { + path, + digest: metadata.digest.clone(), + size: metadata.size, + producer: metadata.producer_task.clone(), + node: metadata.producer_node.clone(), + }, + ); + } + let checkpoint = TaskCheckpoint { + task: assignment.task.clone(), + boundary: CheckpointBoundary { + task_entrypoint, + serialized_args: Digest::sha256(serde_json::to_vec(&task_spec.args)?), + environment_digest, + vfs_epoch: task_spec.vfs_epoch, + task_abi: assignment_task_compatibility(assignment) + .unwrap_or(Digest::sha256(serde_json::to_vec(&task_spec.dispatch)?)), + }, + vfs_manifest: VfsManifest { + epoch: task_spec.vfs_epoch, + producer: assignment.task.clone(), + node: assignment.node.clone(), + objects, + large_bytes_uploaded: false, + }, + depends_on_live_stack: false, + depends_on_live_socket: false, + depends_on_ephemeral_artifact_durability: missing_required_artifact, + }; + let key = task_restart_key( + &assignment.tenant, + &assignment.project, + &assignment.process, + &assignment.task, + ); + self.task_restart_checkpoint_order + .retain(|retained| retained != &key); + self.task_restart_checkpoints.insert( + key.clone(), + TaskRestartCheckpoint { + checkpoint, + assignment: assignment.clone(), + }, + ); + self.task_restart_checkpoint_order.push_back(key); + while self + .task_restart_checkpoint_order + .iter() + .filter(|(tenant, project, process, _)| { + tenant == &assignment.tenant + && project == &assignment.project + && process == &assignment.process + }) + .count() + > super::MAX_RESTART_CHECKPOINTS_PER_PROCESS + { + let Some(index) = self.task_restart_checkpoint_order.iter().position( + |(tenant, project, process, _)| { + tenant == &assignment.tenant + && project == &assignment.project + && process == &assignment.process + }, + ) else { + break; + }; + if let Some(expired) = self.task_restart_checkpoint_order.remove(index) { + self.task_restart_checkpoints.remove(&expired); + } + } + Ok(()) + } + + pub(super) fn handle_schedule_task( + &mut self, + tenant: String, + project: String, + environment: Option, + environment_digest: Option, + required_capabilities: Vec, + dependency_cache: Option, + source_snapshot: Option, + required_artifacts: Vec, + prefer_node: Option, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let now_epoch_seconds = self.current_epoch_seconds()?; + let request = PlacementRequest { + tenant: tenant.clone(), + project: project.clone(), + environment, + environment_digest, + required_capabilities: required_capabilities.into_iter().collect(), + dependency_cache, + source_snapshot, + required_artifacts: required_artifacts + .into_iter() + .map(ArtifactId::new) + .collect(), + quota_available: self + .quota + .can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds) + .is_ok(), + policy_allowed: self.admission.workflow_placement_allowed, + prefer_node: prefer_node.map(NodeId::new), + }; + let nodes = self.node_descriptors.values().cloned().collect::>(); + let placement = DefaultScheduler.place(&nodes, &request)?; + Ok(CoordinatorResponse::TaskPlacement { placement }) + } + + pub(super) fn handle_launch_task( + &mut self, + tenant: String, + project: String, + actor_user: Option, + actor_agent: Option, + agent_public_key_fingerprint: Option, + agent_signature: Option, + request_payload_digest: Option<&Digest>, + task_spec: TaskSpec, + wait_for_node: bool, + artifact_path: String, + wasm_module_base64: String, + ) -> Result { + if matches!( + &task_spec.dispatch, + TaskDispatch::CoordinatorNodeWasm { + abi: disasmer_core::WasmExportAbi::EntrypointV1, + .. + } + ) { + return self.handle_launch_coordinator_main( + tenant, + project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + request_payload_digest, + task_spec, + wasm_module_base64, + ); + } + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = task_spec.process.clone(); + let task = task_spec.task_instance.clone(); + let actor = self.workflow_actor( + &tenant, + &project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + request_payload_digest, + "launch_task", + &process, + Some(&task), + )?; + self.handle_launch_task_with_actor( + tenant, + project, + actor, + task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn handle_launch_child_task( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + parent_task: String, + task_spec: TaskSpec, + wait_for_node: bool, + artifact_path: String, + wasm_module_base64: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let parent_task = TaskInstanceId::new(parent_task); + if task_spec.process != process { + return Err(CoordinatorError::Unauthorized( + "child task must remain in its parent virtual process".to_owned(), + ) + .into()); + } + self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?; + let parent_key = task_control_key(&tenant, &project, &process, &node, &parent_task); + if !self.active_tasks.contains(&parent_key) { + return Err(CoordinatorError::Unauthorized( + "child task launch requires a currently active parent task on the signed node" + .to_owned(), + ) + .into()); + } + let actor = WorkflowActor { + kind: "task".to_owned(), + user: None, + agent: None, + credential_kind: CredentialKind::TaskCredential, + public_key_fingerprint: None, + authenticated_without_browser: true, + scopes: vec!["process:spawn-child".to_owned()], + }; + self.handle_launch_task_with_actor( + tenant, + project, + actor, + task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + ) + } + + pub(super) fn handle_launch_task_with_actor( + &mut self, + tenant: TenantId, + project: ProjectId, + actor: WorkflowActor, + task_spec: TaskSpec, + wait_for_node: bool, + artifact_path: String, + wasm_module_base64: String, + ) -> Result { + if task_spec.tenant != tenant || task_spec.project != project { + return Err(CoordinatorError::Unauthorized( + "task specification is outside the authenticated tenant/project scope".to_owned(), + ) + .into()); + } + if !task_spec.product_mode_uses_remote_dispatch() { + return Err(CoordinatorError::Unauthorized( + "task specification must use the Wasm coordinator/node dispatch ABI".to_owned(), + ) + .into()); + } + if task_spec + .environment_id + .as_deref() + .is_some_and(|environment| environment.trim().is_empty() || environment.len() > 128) + { + return Err(CoordinatorError::Unauthorized( + "task specification environment id is invalid".to_owned(), + ) + .into()); + } + let process = task_spec.process.clone(); + let task = task_spec.task_instance.clone(); + let active = self + .coordinator + .active_process(&tenant, &project, &process) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "task launch requires an active coordinator-side virtual process".to_owned(), + ) + })?; + debug_assert_eq!(active.tenant, tenant); + debug_assert_eq!(active.project, project); + if self + .process_cancellations + .contains(&process_control_key(&tenant, &project, &process)) + { + return Err(CoordinatorError::Unauthorized( + "task launch is blocked because the virtual process is cancelling".to_owned(), + ) + .into()); + } + if self.task_instance_exists(&tenant, &project, &process, &task) { + return Err(CoordinatorServiceError::Protocol(format!( + "task instance {task} already exists in virtual process {process}; every spawn must use a fresh task-instance id" + ))); + } + let in_flight = self + .active_tasks + .iter() + .filter(|(task_tenant, task_project, task_process, _, _)| { + task_tenant == &tenant && task_project == &project && task_process == &process + }) + .count() + + self + .pending_task_launches + .iter() + .filter(|pending| { + pending.tenant == tenant + && pending.project == project + && pending.process == process + }) + .count(); + if in_flight >= super::MAX_IN_FLIGHT_TASKS_PER_PROCESS { + return Err(CoordinatorServiceError::Protocol(format!( + "virtual process task limit of {} reached; join or cancel existing work before spawning more", + super::MAX_IN_FLIGHT_TASKS_PER_PROCESS + ))); + } + if task_spec.vfs_epoch != active.coordinator_epoch { + return Err(CoordinatorError::Unauthorized(format!( + "task specification VFS epoch {} does not match active process epoch {}", + task_spec.vfs_epoch, active.coordinator_epoch + )) + .into()); + } + let bundle_digest = task_spec.bundle_digest.as_ref().ok_or_else(|| { + CoordinatorError::Unauthorized( + "Wasm task specification is missing its bundle digest".to_owned(), + ) + })?; + if !bundle_digest.is_valid_sha256() { + return Err(CoordinatorError::Unauthorized( + "Wasm task specification has an invalid bundle digest".to_owned(), + ) + .into()); + } + let module = BASE64_STANDARD + .decode(&wasm_module_base64) + .map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "Wasm task module is not valid base64: {error}" + )) + })?; + let actual_digest = Digest::sha256(&module); + if &actual_digest != bundle_digest { + return Err(CoordinatorError::Unauthorized(format!( + "Wasm task module digest does not match bundle digest: expected {bundle_digest}, actual {actual_digest}" + )) + .into()); + } + WasmTaskInvocation::new( + task_spec.task_definition.clone(), + task.clone(), + task_spec.args.clone(), + ) + .validate() + .map_err(CoordinatorServiceError::Protocol)?; + for artifact in &task_spec.required_artifacts { + let metadata = self.artifact_registry.metadata(artifact).ok_or_else(|| { + CoordinatorError::Unauthorized(format!( + "required artifact {artifact} is unavailable or has expired" + )) + })?; + if metadata.tenant != tenant || metadata.project != project { + return Err(CoordinatorError::Unauthorized(format!( + "required artifact {artifact} is outside the task tenant/project scope" + )) + .into()); + } + if metadata.retaining_nodes.is_empty() { + return Err(CoordinatorError::Unauthorized(format!( + "required artifact {artifact} has no retaining node" + )) + .into()); + } + } + VfsPath::new(&artifact_path) + .map_err(|error| CoordinatorServiceError::InvalidArtifactPath(error.to_string()))?; + let now_epoch_seconds = self.current_epoch_seconds()?; + self.quota + .can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?; + let request = PlacementRequest { + tenant: tenant.clone(), + project: project.clone(), + environment: task_spec.environment.clone(), + environment_digest: task_spec.environment_digest.clone(), + required_capabilities: task_spec.required_capabilities.clone(), + dependency_cache: task_spec.dependency_cache.clone(), + source_snapshot: task_spec.source_snapshot.clone(), + required_artifacts: task_spec.required_artifacts.iter().cloned().collect(), + quota_available: self + .quota + .can_charge_workflow_spawn(&tenant, &project, now_epoch_seconds) + .is_ok(), + policy_allowed: self.admission.workflow_placement_allowed, + prefer_node: None, + }; + let nodes = self.node_descriptors.values().cloned().collect::>(); + let placement = match DefaultScheduler.place(&nodes, &request) { + Ok(placement) => placement, + Err(err) if wait_for_node => { + let reason = if err.message.is_empty() { + "waiting for any capable node".to_owned() + } else { + err.message + }; + let charged_spawns = + self.quota + .charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?; + self.pending_task_launches.push_back(PendingTaskLaunch { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + task: task.clone(), + request, + epoch: active.coordinator_epoch, + artifact_path, + task_spec, + wasm_module_base64, + }); + return Ok(CoordinatorResponse::TaskQueued { + process, + task, + actor, + reason, + charged_spawns, + queued_tasks: self.pending_task_launches.len(), + }); + } + Err(err) => return Err(err.into()), + }; + let charged_spawns = + self.quota + .charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?; + let assignment = TaskAssignment { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + task: task.clone(), + node: placement.node.clone(), + epoch: active.coordinator_epoch, + artifact_path, + task_spec, + wasm_module_base64, + }; + self.capture_task_restart_checkpoint(&assignment)?; + let task_key = task_control_key(&tenant, &project, &process, &placement.node, &task); + self.task_placements + .insert(task_key.clone(), placement.clone()); + self.active_tasks.insert(task_key); + self.task_assignments + .entry((tenant, project, placement.node.clone())) + .or_default() + .push_back(assignment.clone()); + Ok(CoordinatorResponse::TaskLaunched { + process, + task, + actor, + placement, + assignment: Box::new(assignment), + charged_spawns, + }) + } + + fn task_instance_exists( + &self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + task_instance: &disasmer_core::TaskInstanceId, + ) -> bool { + self.active_tasks.iter().any( + |(task_tenant, task_project, task_process, _, existing_instance)| { + task_tenant == tenant + && task_project == project + && task_process == process + && existing_instance == task_instance + }, + ) || self.pending_task_launches.iter().any(|pending| { + &pending.tenant == tenant + && &pending.project == project + && &pending.process == process + && &pending.task == task_instance + }) || self.task_events.iter().any(|event| { + &event.tenant == tenant + && &event.project == project + && &event.process == process + && &event.task == task_instance + }) + } +} + +fn assignment_task_compatibility(assignment: &TaskAssignment) -> Option { + let descriptor = assignment_task_descriptor(assignment)?; + serde_json::from_value(descriptor.get("restart_compatibility_hash")?.clone()).ok() +} + +fn assignment_task_descriptor(assignment: &TaskAssignment) -> Option { + let module = BASE64_STANDARD + .decode(&assignment.wasm_module_base64) + .ok()?; + let mut descriptors = super::main_runtime::task_descriptors(&module).ok()?; + descriptors.remove(assignment.task_spec.task_definition.as_str()) +} diff --git a/crates/disasmer-coordinator/src/service/processes.rs b/crates/disasmer-coordinator/src/service/processes.rs new file mode 100644 index 0000000..ecfe973 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/processes.rs @@ -0,0 +1,788 @@ +use std::collections::{BTreeSet, VecDeque}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{ + AgentId, AgentSignedRequest, CredentialKind, DefaultScheduler, Digest, NodeId, + PlacementRequest, ProcessId, ProjectId, RendezvousRequest, Scheduler, SourcePreparation, + TaskCheckpoint, TaskInstanceId, TaskSpec, TenantId, UserId, +}; + +use crate::CoordinatorError; + +use super::keys::{process_control_key, task_control_key}; +use super::{ + CoordinatorResponse, CoordinatorService, CoordinatorServiceError, SourcePreparationDisposition, + SourcePreparationStatus, TaskAssignment, TaskCancellationTarget, VirtualProcessStatus, + WorkflowActor, +}; + +#[derive(Clone, Debug)] +pub(super) struct PendingTaskLaunch { + pub(super) tenant: TenantId, + pub(super) project: ProjectId, + pub(super) process: ProcessId, + pub(super) task: TaskInstanceId, + pub(super) request: PlacementRequest, + pub(super) epoch: u64, + pub(super) artifact_path: String, + pub(super) task_spec: TaskSpec, + pub(super) wasm_module_base64: String, +} + +#[derive(Clone, Debug)] +pub(super) struct TaskRestartCheckpoint { + pub(super) checkpoint: TaskCheckpoint, + pub(super) assignment: TaskAssignment, +} + +impl CoordinatorService { + pub(super) fn handle_poll_task_assignment( + &mut self, + tenant: String, + project: String, + node: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let identity = self + .coordinator + .node_identity(&node) + .ok_or(CoordinatorError::UnknownNode)?; + if identity.tenant != tenant || identity.project != project { + return Err(CoordinatorError::Unauthorized( + "task assignment poll is outside the enrolled tenant/project scope".to_owned(), + ) + .into()); + } + let assignment_key = (tenant.clone(), project.clone(), node.clone()); + let assignment = self + .task_assignments + .get_mut(&assignment_key) + .and_then(VecDeque::pop_front); + if assignment.is_some() { + return Ok(CoordinatorResponse::TaskAssignment { + assignment: assignment.map(Box::new), + }); + } + let assignment = self.assign_pending_task_to_node(&tenant, &project, &node)?; + Ok(CoordinatorResponse::TaskAssignment { + assignment: assignment.map(Box::new), + }) + } + + fn assign_pending_task_to_node( + &mut self, + tenant: &TenantId, + project: &ProjectId, + node: &NodeId, + ) -> Result, CoordinatorServiceError> { + let Some(descriptor) = self.node_descriptors.get(node).cloned() else { + return Ok(None); + }; + let mut remaining = VecDeque::new(); + let mut selected = None; + + while let Some(pending) = self.pending_task_launches.pop_front() { + if selected.is_some() { + remaining.push_back(pending); + continue; + } + if &pending.tenant != tenant || &pending.project != project { + remaining.push_back(pending); + continue; + } + if self.process_cancellations.contains(&process_control_key( + &pending.tenant, + &pending.project, + &pending.process, + )) { + continue; + } + let Some(active) = self.coordinator.active_process( + &pending.tenant, + &pending.project, + &pending.process, + ) else { + continue; + }; + if active.tenant != pending.tenant + || active.project != pending.project + || active.coordinator_epoch != pending.epoch + { + continue; + } + let Ok(placement) = + DefaultScheduler.place(std::slice::from_ref(&descriptor), &pending.request) + else { + remaining.push_back(pending); + continue; + }; + let assignment = TaskAssignment { + tenant: pending.tenant.clone(), + project: pending.project.clone(), + process: pending.process.clone(), + task: pending.task.clone(), + node: placement.node.clone(), + epoch: pending.epoch, + artifact_path: pending.artifact_path, + task_spec: pending.task_spec, + wasm_module_base64: pending.wasm_module_base64, + }; + self.capture_task_restart_checkpoint(&assignment)?; + let task_key = task_control_key( + &pending.tenant, + &pending.project, + &pending.process, + &placement.node, + &pending.task, + ); + self.task_placements.insert(task_key.clone(), placement); + self.active_tasks.insert(task_key); + selected = Some(assignment); + } + + self.pending_task_launches = remaining; + Ok(selected) + } + + pub(super) fn handle_request_rendezvous( + &mut self, + scope: disasmer_core::DataPlaneScope, + source: disasmer_core::NodeEndpoint, + destination: disasmer_core::NodeEndpoint, + direct_connectivity: bool, + failure_reason: String, + ) -> Result { + let now_epoch_seconds = self.current_epoch_seconds()?; + let charged_rendezvous_attempts = self.quota.charge_rendezvous_attempt( + &scope.tenant, + &scope.project, + now_epoch_seconds, + )?; + let plan = self.transport.plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope, + source, + destination, + }, + direct_connectivity, + failure_reason, + )?; + Ok(CoordinatorResponse::RendezvousPlan { + plan, + charged_rendezvous_attempts, + }) + } + + pub(super) fn handle_request_source_preparation( + &mut self, + tenant: String, + project: String, + provider: disasmer_core::SourceProviderKind, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let preparation = SourcePreparation::node_task(tenant.clone(), project.clone(), provider); + let request = PlacementRequest { + tenant, + project, + environment: None, + environment_digest: None, + required_capabilities: preparation.required_capabilities.clone(), + dependency_cache: None, + source_snapshot: None, + required_artifacts: Default::default(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + let nodes = self.node_descriptors.values().cloned().collect::>(); + let disposition = match DefaultScheduler.place(&nodes, &request) { + Ok(placement) => SourcePreparationDisposition::Assigned { + node: placement.node, + }, + Err(err) => SourcePreparationDisposition::Pending { + reason: if err.message.is_empty() { + "waiting for any capable node to prepare source".to_owned() + } else { + err.message + }, + }, + }; + Ok(CoordinatorResponse::SourcePreparation { + status: SourcePreparationStatus { + preparation, + disposition, + }, + }) + } + + pub(super) fn handle_complete_source_preparation( + &mut self, + tenant: String, + project: String, + node: String, + provider: disasmer_core::SourceProviderKind, + source_snapshot: Digest, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let identity = self + .coordinator + .node_identity(&node) + .ok_or(CoordinatorError::UnknownNode)?; + if identity.tenant != tenant || identity.project != project { + return Err(CoordinatorError::Unauthorized( + "source preparation completion is outside the enrolled tenant/project scope" + .to_owned(), + ) + .into()); + } + let descriptor = self.node_descriptors.get_mut(&node).ok_or_else(|| { + CoordinatorError::Unauthorized( + "source preparation completion requires a node capability report".to_owned(), + ) + })?; + if !descriptor.source_snapshots.contains(&source_snapshot) + && descriptor.source_snapshots.len() >= super::MAX_NODE_REPORTED_OBJECTS_PER_KIND + { + return Err(CoordinatorServiceError::Protocol(format!( + "node source snapshot retention limit of {} reached; refresh the node capability report", + super::MAX_NODE_REPORTED_OBJECTS_PER_KIND + ))); + } + descriptor.source_snapshots.insert(source_snapshot.clone()); + Ok(CoordinatorResponse::SourcePreparationCompleted { + node, + provider, + source_snapshot, + }) + } + + pub(super) fn handle_start_process( + &mut self, + tenant: String, + project: String, + actor_user: Option, + actor_agent: Option, + agent_public_key_fingerprint: Option, + agent_signature: Option, + request_payload_digest: Option<&Digest>, + process: String, + restart: bool, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + self.coordinator.ensure_tenant_active(&tenant)?; + let actor = self.workflow_actor( + &tenant, + &project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + request_payload_digest, + "start_process", + &process, + None, + )?; + let replacing_existing = if let Some(active) = self + .coordinator + .active_process_for_project(&tenant, &project) + { + if active.id != process || !restart { + return Err(CoordinatorError::Unauthorized(format!( + "project already has active virtual process {}; attach to or restart it, request cooperative cancellation, abort it, or use another Coordinator Project", + active.id + )) + .into()); + } + true + } else { + false + }; + if replacing_existing { + self.main_runtime.interrupt_process( + &tenant, + &project, + &process, + "virtual process incarnation replaced", + ); + self.main_runtime + .controls + .remove(&process_control_key(&tenant, &project, &process)); + } + let now_epoch_seconds = self.current_epoch_seconds()?; + let charged_spawns = + self.quota + .charge_workflow_spawn(&tenant, &project, now_epoch_seconds)?; + self.process_cancellations + .remove(&process_control_key(&tenant, &project, &process)); + self.process_aborts + .remove(&process_control_key(&tenant, &project, &process)); + self.clear_debug_state_for_process(&tenant, &project, &process); + self.clear_operator_panel_state(&tenant, &project, &process); + self.task_cancellations + .retain(|(task_tenant, task_project, task_process, _, _)| { + task_tenant != &tenant || task_project != &project || task_process != &process + }); + self.task_aborts + .retain(|(task_tenant, task_project, task_process, _, _)| { + task_tenant != &tenant || task_project != &project || task_process != &process + }); + self.active_tasks + .retain(|(task_tenant, task_project, task_process, _, _)| { + task_tenant != &tenant || task_project != &project || task_process != &process + }); + self.task_placements + .retain(|(task_tenant, task_project, task_process, _, _), _| { + task_tenant != &tenant || task_project != &project || task_process != &process + }); + self.task_assignments.retain(|_, assignments| { + assignments.retain(|assignment| { + assignment.tenant != tenant + || assignment.project != project + || assignment.process != process + }); + !assignments.is_empty() + }); + self.pending_task_launches.retain(|pending| { + pending.tenant != tenant || pending.project != project || pending.process != process + }); + self.task_restart_checkpoints.retain( + |(checkpoint_tenant, checkpoint_project, checkpoint_process, _), _| { + checkpoint_tenant != &tenant + || checkpoint_project != &project + || checkpoint_process != &process + }, + ); + self.task_restart_checkpoint_order.retain( + |(checkpoint_tenant, checkpoint_project, checkpoint_process, _)| { + checkpoint_tenant != &tenant + || checkpoint_project != &project + || checkpoint_process != &process + }, + ); + self.task_events.retain(|event| { + event.tenant != tenant || event.project != project || event.process != process + }); + self.debug_audit_events.retain(|event| { + event.tenant != tenant || event.project != project || event.process != process + }); + self.coordinator + .start_process(tenant, project, process.clone()); + Ok(CoordinatorResponse::ProcessStarted { + process, + epoch: self.coordinator.coordinator_epoch(), + actor, + charged_spawns, + }) + } + + pub(super) fn handle_reconnect_node( + &mut self, + node: String, + process: String, + epoch: u64, + ) -> Result { + let node = NodeId::new(node); + let process = ProcessId::new(process); + self.coordinator + .reconnect_node(&node, Some((&process, epoch)))?; + Ok(CoordinatorResponse::NodeReconnected { node, process }) + } + + pub(super) fn handle_cancel_task( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskInstanceId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + let active = self + .coordinator + .active_process(&tenant, &project, &process) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "task cancellation requires an active virtual process".to_owned(), + ) + })?; + if !active.connected_nodes.contains(&node) { + return Err(CoordinatorError::Unauthorized( + "task cancellation target node is not connected to the virtual process".to_owned(), + ) + .into()); + } + self.task_cancellations + .insert(task_control_key(&tenant, &project, &process, &node, &task)); + Ok(CoordinatorResponse::TaskCancellationRequested { + process, + task, + node, + }) + } + + pub(super) fn handle_cancel_process( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let _actor_user = actor_user; + let active = self + .coordinator + .active_process(&tenant, &project, &process) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "process cancellation requires an active virtual process".to_owned(), + ) + })?; + debug_assert_eq!(active.tenant, tenant); + debug_assert_eq!(active.project, project); + self.process_cancellations + .insert(process_control_key(&tenant, &project, &process)); + self.main_runtime.interrupt_process( + &tenant, + &project, + &process, + "virtual process cancellation requested", + ); + self.clear_debug_state_for_process(&tenant, &project, &process); + self.pending_task_launches.retain(|pending| { + pending.tenant != tenant || pending.project != project || pending.process != process + }); + let mut cancelled_tasks = Vec::new(); + let mut affected_nodes = BTreeSet::new(); + for (task_tenant, task_project, task_process, node, task) in self.active_tasks.iter() { + if task_tenant == &tenant && task_project == &project && task_process == &process { + self.task_cancellations + .insert(task_control_key(&tenant, &project, &process, node, task)); + affected_nodes.insert(node.clone()); + cancelled_tasks.push(TaskCancellationTarget { + process: process.clone(), + task: task.clone(), + node: node.clone(), + }); + } + } + Ok(CoordinatorResponse::ProcessCancellationRequested { + process, + cancelled_tasks, + affected_nodes: affected_nodes.into_iter().collect(), + }) + } + + pub(super) fn handle_abort_process( + &mut self, + tenant: String, + project: String, + actor_user: String, + process: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let _actor_user = actor_user; + let active = self + .coordinator + .active_process(&tenant, &project, &process) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "process abort requires an active virtual process".to_owned(), + ) + })?; + debug_assert_eq!(active.tenant, tenant); + debug_assert_eq!(active.project, project); + + let process_key = process_control_key(&tenant, &project, &process); + self.process_cancellations.remove(&process_key); + self.task_cancellations + .retain(|(task_tenant, task_project, task_process, _, _)| { + task_tenant != &tenant || task_project != &project || task_process != &process + }); + self.process_aborts.insert(process_key); + self.main_runtime + .interrupt_process(&tenant, &project, &process, "virtual process aborted"); + self.main_runtime + .controls + .remove(&process_control_key(&tenant, &project, &process)); + self.clear_debug_state_for_process(&tenant, &project, &process); + self.clear_operator_panel_state(&tenant, &project, &process); + self.pending_task_launches.retain(|pending| { + pending.tenant != tenant || pending.project != project || pending.process != process + }); + self.task_assignments.retain(|_, assignments| { + assignments.retain(|assignment| { + assignment.tenant != tenant + || assignment.project != project + || assignment.process != process + }); + !assignments.is_empty() + }); + let mut aborted_tasks = Vec::new(); + let mut affected_nodes = BTreeSet::new(); + for (task_tenant, task_project, task_process, node, task) in self.active_tasks.iter() { + if task_tenant == &tenant && task_project == &project && task_process == &process { + self.task_aborts + .insert(task_control_key(&tenant, &project, &process, node, task)); + affected_nodes.insert(node.clone()); + aborted_tasks.push(TaskCancellationTarget { + process: process.clone(), + task: task.clone(), + node: node.clone(), + }); + } + } + + self.coordinator + .abort_process(&tenant, &project, &process)?; + let active_restart_tasks = aborted_tasks + .iter() + .map(|target| target.task.clone()) + .collect::>(); + self.task_restart_checkpoints.retain( + |(checkpoint_tenant, checkpoint_project, checkpoint_process, checkpoint_task), _| { + checkpoint_tenant != &tenant + || checkpoint_project != &project + || checkpoint_process != &process + || active_restart_tasks.contains(checkpoint_task) + }, + ); + self.task_restart_checkpoint_order.retain( + |(checkpoint_tenant, checkpoint_project, checkpoint_process, checkpoint_task)| { + checkpoint_tenant != &tenant + || checkpoint_project != &project + || checkpoint_process != &process + || active_restart_tasks.contains(checkpoint_task) + }, + ); + if aborted_tasks.is_empty() { + self.process_aborts + .remove(&process_control_key(&tenant, &project, &process)); + } + Ok(CoordinatorResponse::ProcessAborted { + process, + aborted_tasks, + affected_nodes: affected_nodes.into_iter().collect(), + }) + } + + pub(super) fn handle_list_processes( + &mut self, + tenant: String, + project: String, + actor_user: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let processes = self + .coordinator + .active_processes_for_project(&tenant, &project) + .into_iter() + .map(|active| { + let process_key = process_control_key(&active.tenant, &active.project, &active.id); + let main = self.main_runtime.controls.get(&process_key); + let state = if self.process_cancellations.contains(&process_key) { + "cancelling" + } else { + main.map_or("running", |main| main.state.as_str()) + }; + let main_wait_state = main.and_then(|main| { + if main.state != "running" { + return None; + } + if self.pending_task_launches.iter().any(|pending| { + pending.tenant == active.tenant + && pending.project == active.project + && pending.process == active.id + }) { + Some("waiting_for_node".to_owned()) + } else if self.main_runtime.is_waiting_for_task( + &active.tenant, + &active.project, + &active.id, + ) { + Some("waiting_for_task".to_owned()) + } else { + Some("executing".to_owned()) + } + }); + VirtualProcessStatus { + process: active.id, + state: state.to_owned(), + main_task_definition: main.map(|main| main.task_definition.clone()), + main_task_instance: main.map(|main| main.task_instance.clone()), + main_state: main.map(|main| main.state.clone()), + main_wait_state, + main_debug_epoch: main.and_then(|main| main.debug.requested_epoch()), + connected_nodes: active.connected_nodes.into_iter().collect(), + coordinator_epoch: active.coordinator_epoch, + } + }) + .collect(); + Ok(CoordinatorResponse::ProcessStatuses { processes, actor }) + } + + pub(super) fn handle_poll_task_control( + &mut self, + tenant: String, + project: String, + process: String, + node: String, + task: String, + ) -> Result { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskInstanceId::new(task); + self.authorize_node_for_process_or_termination(&node, &tenant, &project, &process)?; + let cancel_requested = self + .task_cancellations + .contains(&task_control_key(&tenant, &project, &process, &node, &task)) + || self + .process_cancellations + .contains(&process_control_key(&tenant, &project, &process)); + let abort_requested = self + .task_aborts + .contains(&task_control_key(&tenant, &project, &process, &node, &task)) + || self + .process_aborts + .contains(&process_control_key(&tenant, &project, &process)); + Ok(CoordinatorResponse::TaskControl { + process, + task, + cancel_requested, + abort_requested, + }) + } + + pub(super) fn workflow_actor( + &mut self, + tenant: &TenantId, + project: &ProjectId, + actor_user: Option, + actor_agent: Option, + agent_public_key_fingerprint: Option, + agent_signature: Option, + request_payload_digest: Option<&Digest>, + request_kind: &str, + process: &ProcessId, + task: Option<&TaskInstanceId>, + ) -> Result { + if let Some(agent) = actor_agent { + let agent = AgentId::new(agent); + let signature = agent_signature.ok_or_else(|| { + CoordinatorError::Unauthorized( + "agent workflow dispatch requires a signed request proving private-key possession" + .to_owned(), + ) + })?; + let request_payload_digest = request_payload_digest.ok_or_else(|| { + CoordinatorError::Unauthorized( + "agent workflow dispatch requires a canonical signed request payload" + .to_owned(), + ) + })?; + if signature.nonce.trim().is_empty() || signature.nonce.len() > 256 { + return Err(CoordinatorError::Unauthorized( + "agent signed request nonce is missing or invalid".to_owned(), + ) + .into()); + } + let now_epoch_seconds = unix_timestamp_seconds(); + let replay_key = ( + tenant.clone(), + project.clone(), + agent.clone(), + signature.nonce.clone(), + ); + const AGENT_SIGNATURE_WINDOW_SECONDS: u64 = 300; + self.agent_replay_nonces.retain(|_, issued_at| { + now_epoch_seconds <= issued_at.saturating_add(AGENT_SIGNATURE_WINDOW_SECONDS) + }); + if self.agent_replay_nonces.contains_key(&replay_key) { + return Err(CoordinatorError::Unauthorized( + "agent signed request nonce has already been used".to_owned(), + ) + .into()); + } + let record = self.coordinator.authorize_agent_project_run( + disasmer_core::AgentWorkflowScope { + tenant, + project, + agent: &agent, + request_kind, + process, + task, + }, + agent_public_key_fingerprint.as_ref(), + request_payload_digest, + &signature, + now_epoch_seconds, + )?; + if self + .agent_replay_nonces + .keys() + .filter(|(retained_tenant, retained_project, retained_agent, _)| { + retained_tenant == tenant + && retained_project == project + && retained_agent == &agent + }) + .count() + >= super::MAX_REPLAY_NONCES_PER_AUTHORITY + { + return Err(CoordinatorError::Unauthorized( + "agent signed request replay window is full; retry after the bounded signature window advances" + .to_owned(), + ) + .into()); + } + self.agent_replay_nonces + .insert(replay_key, signature.issued_at_epoch_seconds); + return Ok(WorkflowActor { + kind: "agent".to_owned(), + user: Some(record.user), + agent: Some(agent), + credential_kind: CredentialKind::PublicKey, + public_key_fingerprint: Some(record.public_key_fingerprint), + authenticated_without_browser: true, + scopes: record.scopes, + }); + } + + let actor = UserId::new(actor_user.unwrap_or_else(|| "user".to_owned())); + Ok(WorkflowActor { + kind: "user".to_owned(), + user: Some(actor), + agent: None, + credential_kind: CredentialKind::BrowserSession, + public_key_fingerprint: None, + authenticated_without_browser: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + }) + } +} + +fn unix_timestamp_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} diff --git a/crates/disasmer-coordinator/src/service/protocol.rs b/crates/disasmer-coordinator/src/service/protocol.rs new file mode 100644 index 0000000..ac9fdf2 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/protocol.rs @@ -0,0 +1,644 @@ +use std::collections::BTreeMap; + +use disasmer_core::{ + AgentId, AgentSignedRequest, ArtifactId, Authorization, Capability, CredentialKind, + DataPlaneScope, Digest, DirectBulkTransferPlan, DownloadLink, EnvironmentRequirements, + LimitKind, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, NodeSignedRequest, + PanelEventKind, PanelState, Placement, ProcessId, ProjectId, ResourceLimits, SourcePreparation, + SourceProviderKind, TaskBoundaryValue, TaskInstanceId, TaskJoinResult, TaskSpec, TenantId, + UserId, VfsPath, +}; +use serde::{Deserialize, Serialize}; + +mod responses; +pub use responses::*; + +use crate::{AgentPublicKeyRecord, ProjectRecord}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TaskReplacementBundle { + pub bundle_digest: Digest, + pub wasm_module_base64: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum CoordinatorRequest { + Ping, + Authenticated { + session_secret: String, + request: AuthenticatedCoordinatorRequest, + }, + AuthStatus { + tenant: String, + project: String, + actor_user: String, + }, + AdminStatus { + tenant: String, + actor_user: String, + admin_proof: Digest, + admin_nonce: String, + issued_at_epoch_seconds: u64, + }, + SuspendTenant { + tenant: String, + actor_user: String, + target_tenant: String, + admin_proof: Digest, + admin_nonce: String, + issued_at_epoch_seconds: u64, + }, + CreateProject { + tenant: String, + actor_user: String, + project: String, + name: String, + }, + SelectProject { + tenant: String, + actor_user: String, + project: String, + }, + ListProjects { + tenant: String, + actor_user: String, + }, + RegisterAgentPublicKey { + tenant: String, + project: String, + user: String, + agent: String, + public_key: String, + }, + ListAgentPublicKeys { + tenant: String, + project: String, + user: String, + }, + RotateAgentPublicKey { + tenant: String, + project: String, + user: String, + agent: String, + public_key: String, + }, + RevokeAgentPublicKey { + tenant: String, + project: String, + user: String, + agent: String, + }, + AttachNode { + tenant: String, + project: String, + node: String, + public_key: String, + }, + CreateNodeEnrollmentGrant { + tenant: String, + project: String, + actor_user: String, + #[serde(default = "default_node_enrollment_ttl_seconds")] + ttl_seconds: u64, + }, + ExchangeNodeEnrollmentGrant { + tenant: String, + project: String, + node: String, + public_key: String, + enrollment_grant: String, + }, + NodeHeartbeat { + node: String, + #[serde(default)] + node_signature: Option, + }, + SignedNode { + node: String, + node_signature: NodeSignedRequest, + request: Box, + }, + ReportNodeCapabilities { + tenant: String, + project: String, + node: String, + capabilities: NodeCapabilities, + cached_environment_digests: Vec, + #[serde(default)] + dependency_cache_digests: Vec, + source_snapshots: Vec, + artifact_locations: Vec, + direct_connectivity: bool, + online: bool, + }, + ListNodeDescriptors { + tenant: String, + project: String, + actor_user: String, + }, + RevokeNodeCredential { + tenant: String, + project: String, + actor_user: String, + node: String, + }, + ScheduleTask { + tenant: String, + project: String, + environment: Option, + environment_digest: Option, + required_capabilities: Vec, + #[serde(default)] + dependency_cache: Option, + source_snapshot: Option, + required_artifacts: Vec, + prefer_node: Option, + }, + LaunchTask { + tenant: String, + project: String, + #[serde(default)] + actor_user: Option, + #[serde(default)] + actor_agent: Option, + #[serde(default)] + agent_public_key_fingerprint: Option, + #[serde(default)] + agent_signature: Option, + task_spec: TaskSpec, + #[serde(default)] + wait_for_node: bool, + artifact_path: String, + wasm_module_base64: String, + }, + LaunchChildTask { + tenant: String, + project: String, + process: String, + node: String, + parent_task: String, + task_spec: TaskSpec, + #[serde(default)] + wait_for_node: bool, + artifact_path: String, + wasm_module_base64: String, + }, + JoinChildTask { + tenant: String, + project: String, + process: String, + node: String, + parent_task: String, + task: String, + }, + PollTaskAssignment { + tenant: String, + project: String, + node: String, + }, + PollArtifactTransfer { + tenant: String, + project: String, + node: String, + }, + UploadArtifactTransferChunk { + tenant: String, + project: String, + node: String, + transfer_id: String, + artifact: String, + offset: u64, + content_base64: String, + chunk_digest: Digest, + eof: bool, + }, + FailArtifactTransfer { + tenant: String, + project: String, + node: String, + transfer_id: String, + artifact: String, + message: String, + }, + RequestRendezvous { + scope: DataPlaneScope, + source: NodeEndpoint, + destination: NodeEndpoint, + direct_connectivity: bool, + failure_reason: String, + }, + RequestSourcePreparation { + tenant: String, + project: String, + provider: SourceProviderKind, + }, + CompleteSourcePreparation { + tenant: String, + project: String, + node: String, + provider: SourceProviderKind, + source_snapshot: Digest, + }, + StartProcess { + tenant: String, + project: String, + #[serde(default)] + actor_user: Option, + #[serde(default)] + actor_agent: Option, + #[serde(default)] + agent_public_key_fingerprint: Option, + #[serde(default)] + agent_signature: Option, + process: String, + #[serde(default)] + restart: bool, + }, + ReconnectNode { + node: String, + process: String, + epoch: u64, + }, + CancelTask { + tenant: String, + project: String, + process: String, + node: String, + task: String, + }, + CancelProcess { + tenant: String, + project: String, + actor_user: String, + process: String, + }, + AbortProcess { + tenant: String, + project: String, + actor_user: String, + process: String, + }, + ListProcesses { + tenant: String, + project: String, + actor_user: String, + }, + QuotaStatus { + tenant: String, + project: String, + actor_user: String, + }, + PollTaskControl { + tenant: String, + project: String, + process: String, + node: String, + task: String, + }, + RestartTask { + tenant: String, + project: String, + actor_user: String, + process: String, + task: String, + #[serde(default)] + replacement_bundle: Option, + }, + DebugAttach { + tenant: String, + project: String, + actor_user: String, + process: String, + }, + SetDebugBreakpoints { + tenant: String, + project: String, + actor_user: String, + process: String, + probe_symbols: Vec, + }, + InspectDebugBreakpoints { + tenant: String, + project: String, + actor_user: String, + process: String, + }, + CreateDebugEpoch { + tenant: String, + project: String, + actor_user: String, + process: String, + stopped_task: String, + reason: String, + }, + ResumeDebugEpoch { + tenant: String, + project: String, + actor_user: String, + process: String, + epoch: u64, + }, + InspectDebugEpoch { + tenant: String, + project: String, + actor_user: String, + process: String, + epoch: u64, + }, + PollDebugCommand { + tenant: String, + project: String, + process: String, + node: String, + task: String, + }, + ReportDebugState { + tenant: String, + project: String, + process: String, + node: String, + task: String, + epoch: u64, + state: DebugAcknowledgementState, + #[serde(default)] + stack_frames: Vec, + #[serde(default)] + local_values: Vec<(String, String)>, + #[serde(default)] + task_args: Vec<(String, String)>, + #[serde(default)] + handles: Vec<(String, String)>, + #[serde(default)] + command_status: Option, + #[serde(default)] + recent_output: Vec, + #[serde(default)] + message: Option, + }, + ReportDebugProbeHit { + tenant: String, + project: String, + process: String, + node: String, + task: String, + probe_symbol: String, + }, + ReportTaskLog { + tenant: String, + project: String, + process: String, + node: String, + task: String, + stdout_bytes: u64, + stderr_bytes: u64, + #[serde(default)] + stdout_tail: String, + #[serde(default)] + stderr_tail: String, + stdout_truncated: bool, + stderr_truncated: bool, + backpressured: bool, + }, + ReportVfsMetadata { + tenant: String, + project: String, + process: String, + node: String, + task: String, + artifact_path: Option, + artifact_digest: Option, + artifact_size_bytes: Option, + large_bytes_uploaded: bool, + }, + TaskCompleted { + tenant: String, + project: String, + process: String, + node: String, + task: String, + #[serde(default)] + terminal_state: Option, + status_code: Option, + stdout_bytes: u64, + stderr_bytes: u64, + #[serde(default)] + stdout_tail: String, + #[serde(default)] + stderr_tail: String, + #[serde(default)] + stdout_truncated: bool, + #[serde(default)] + stderr_truncated: bool, + artifact_path: Option, + artifact_digest: Option, + artifact_size_bytes: Option, + #[serde(default)] + result: Option, + }, + ListTaskEvents { + tenant: String, + project: String, + actor_user: String, + #[serde(default)] + process: Option, + }, + JoinTask { + tenant: String, + project: String, + actor_user: String, + process: String, + task: String, + }, + RenderOperatorPanel { + tenant: String, + project: String, + process: String, + actor_user: String, + max_download_bytes: u64, + stopped: bool, + }, + SubmitPanelEvent { + tenant: String, + project: String, + process: String, + widget_id: String, + kind: PanelEventKind, + max_events: u64, + }, + CreateArtifactDownloadLink { + tenant: String, + project: String, + actor_user: String, + artifact: String, + max_bytes: u64, + #[serde(default = "default_download_ttl_seconds")] + ttl_seconds: u64, + }, + OpenArtifactDownloadStream { + tenant: String, + project: String, + actor_user: String, + artifact: String, + max_bytes: u64, + token_digest: Digest, + chunk_bytes: u64, + }, + RevokeArtifactDownloadLink { + tenant: String, + project: String, + actor_user: String, + artifact: String, + token_digest: Digest, + }, + ExportArtifactToNode { + tenant: String, + project: String, + actor_user: String, + artifact: String, + receiver_node: String, + direct_connectivity: bool, + failure_reason: String, + }, +} + +impl CoordinatorRequest { + pub fn operation(&self) -> Result { + serde_json::to_value(self) + .map_err(|err| format!("failed to encode coordinator request operation: {err}")) + .map(|value| disasmer_core::coordinator_payload_operation(&value)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum AuthenticatedCoordinatorRequest { + AuthStatus, + RevokeCliSession, + CreateProject { + project: String, + name: String, + }, + SelectProject { + project: String, + }, + ListProjects, + RegisterAgentPublicKey { + agent: String, + public_key: String, + }, + ListAgentPublicKeys, + RotateAgentPublicKey { + agent: String, + public_key: String, + }, + RevokeAgentPublicKey { + agent: String, + }, + CreateNodeEnrollmentGrant { + #[serde(default = "default_node_enrollment_ttl_seconds")] + ttl_seconds: u64, + }, + ListNodeDescriptors, + RevokeNodeCredential { + node: String, + }, + StartProcess { + process: String, + #[serde(default)] + restart: bool, + }, + ScheduleTask { + environment: Option, + environment_digest: Option, + required_capabilities: Vec, + #[serde(default)] + dependency_cache: Option, + source_snapshot: Option, + required_artifacts: Vec, + prefer_node: Option, + }, + LaunchTask { + task_spec: Box, + #[serde(default)] + wait_for_node: bool, + artifact_path: String, + wasm_module_base64: String, + }, + CancelProcess { + process: String, + }, + AbortProcess { + process: String, + }, + ListProcesses, + QuotaStatus, + RestartTask { + process: String, + task: String, + #[serde(default)] + replacement_bundle: Option, + }, + DebugAttach { + process: String, + }, + SetDebugBreakpoints { + process: String, + probe_symbols: Vec, + }, + InspectDebugBreakpoints { + process: String, + }, + CreateDebugEpoch { + process: String, + stopped_task: String, + reason: String, + }, + ResumeDebugEpoch { + process: String, + epoch: u64, + }, + InspectDebugEpoch { + process: String, + epoch: u64, + }, + ListTaskEvents { + #[serde(default)] + process: Option, + }, + JoinTask { + process: String, + task: String, + }, + CreateArtifactDownloadLink { + artifact: String, + max_bytes: u64, + #[serde(default = "default_download_ttl_seconds")] + ttl_seconds: u64, + }, + OpenArtifactDownloadStream { + artifact: String, + max_bytes: u64, + token_digest: Digest, + chunk_bytes: u64, + }, + RevokeArtifactDownloadLink { + artifact: String, + token_digest: Digest, + }, + ExportArtifactToNode { + artifact: String, + receiver_node: String, + direct_connectivity: bool, + failure_reason: String, + }, +} + +fn default_download_ttl_seconds() -> u64 { + 900 +} + +fn default_node_enrollment_ttl_seconds() -> u64 { + 900 +} diff --git a/crates/disasmer-coordinator/src/service/protocol/responses.rs b/crates/disasmer-coordinator/src/service/protocol/responses.rs new file mode 100644 index 0000000..a107abc --- /dev/null +++ b/crates/disasmer-coordinator/src/service/protocol/responses.rs @@ -0,0 +1,492 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskTerminalState { + Completed, + Failed, + Cancelled, +} + +impl TaskTerminalState { + pub(crate) fn from_status_code(status_code: Option) -> Self { + match status_code { + Some(0) => Self::Completed, + _ => Self::Failed, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskExecutor { + CoordinatorMain, + Node, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskCompletionEvent { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub node: NodeId, + pub executor: TaskExecutor, + pub task_definition: disasmer_core::TaskDefinitionId, + pub task: TaskInstanceId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placement: Option, + pub terminal_state: TaskTerminalState, + pub status_code: Option, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub stdout_tail: String, + pub stderr_tail: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub artifact_path: Option, + pub artifact_digest: Option, + pub artifact_size_bytes: Option, + pub result: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebugAuditEvent { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub task: Option, + pub actor: UserId, + pub operation: String, + pub allowed: bool, + pub reason: String, + pub charged_debug_read_bytes: u64, + pub used_debug_read_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowActor { + pub kind: String, + pub user: Option, + pub agent: Option, + pub credential_kind: CredentialKind, + pub public_key_fingerprint: Option, + pub authenticated_without_browser: bool, + pub scopes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskAssignment { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub task: TaskInstanceId, + pub node: NodeId, + pub epoch: u64, + pub artifact_path: String, + pub task_spec: TaskSpec, + pub wasm_module_base64: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactTransferAssignment { + pub transfer_id: String, + pub artifact: ArtifactId, + pub expected_digest: Digest, + pub expected_size_bytes: u64, + pub offset: u64, + pub max_chunk_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskCancellationTarget { + pub process: ProcessId, + pub task: TaskInstanceId, + pub node: NodeId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DebugAcknowledgementState { + Frozen, + Running, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebugParticipantAcknowledgement { + pub node: NodeId, + pub task_definition: disasmer_core::TaskDefinitionId, + pub task: TaskInstanceId, + pub epoch: u64, + pub state: DebugAcknowledgementState, + pub stack_frames: Vec, + pub local_values: Vec<(String, String)>, + pub task_args: Vec<(String, String)>, + pub handles: Vec<(String, String)>, + pub command_status: Option, + pub recent_output: Vec, + pub message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VirtualProcessStatus { + pub process: ProcessId, + pub state: String, + pub main_task_definition: Option, + pub main_task_instance: Option, + pub main_state: Option, + pub main_wait_state: Option, + pub main_debug_epoch: Option, + pub connected_nodes: Vec, + pub coordinator_epoch: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourcePreparationDisposition { + Pending { reason: String }, + Assigned { node: NodeId }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePreparationStatus { + pub preparation: SourcePreparation, + pub disposition: SourcePreparationDisposition, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CoordinatorResponse { + Pong { + epoch: u64, + }, + AuthStatus { + tenant: TenantId, + project: ProjectId, + actor: UserId, + authenticated: bool, + account_status: String, + suspended: bool, + disabled: bool, + deleted: bool, + manual_review: bool, + sanitized_reason: Option, + next_actions: Vec, + private_moderation_details_exposed: bool, + signup_failure_details_exposed: bool, + }, + AdminStatus { + tenant: TenantId, + actor: UserId, + suspended: bool, + safe_default: String, + }, + TenantSuspended { + tenant: TenantId, + actor: UserId, + policy: crate::ServicePolicyRecord, + }, + ProjectCreated { + project: ProjectRecord, + actor: UserId, + }, + ProjectSelected { + project: ProjectRecord, + actor: UserId, + }, + Projects { + projects: Vec, + actor: UserId, + }, + CliSessionRevoked { + tenant: TenantId, + project: ProjectId, + actor: UserId, + }, + AgentPublicKey { + record: AgentPublicKeyRecord, + actor: UserId, + }, + AgentPublicKeys { + records: Vec, + actor: UserId, + }, + NodeAttached { + node: NodeId, + tenant: TenantId, + project: ProjectId, + }, + NodeEnrollmentGrantCreated { + tenant: TenantId, + project: ProjectId, + grant: String, + scope: String, + expires_at_epoch_seconds: u64, + }, + NodeEnrollmentExchanged { + node: NodeId, + tenant: TenantId, + project: ProjectId, + credential: disasmer_core::NodeCredential, + }, + NodeHeartbeat { + node: NodeId, + epoch: u64, + }, + NodeCapabilitiesRecorded { + node: NodeId, + node_descriptors: usize, + }, + NodeDescriptors { + descriptors: Vec, + actor: UserId, + }, + NodeCredentialRevoked { + node: NodeId, + tenant: TenantId, + project: ProjectId, + actor: UserId, + descriptor_removed: bool, + queued_assignments_removed: usize, + }, + TaskPlacement { + placement: Placement, + }, + TaskLaunched { + process: ProcessId, + task: TaskInstanceId, + actor: WorkflowActor, + placement: Placement, + assignment: Box, + charged_spawns: u64, + }, + MainLaunched { + process: ProcessId, + task_definition: disasmer_core::TaskDefinitionId, + task_instance: TaskInstanceId, + actor: WorkflowActor, + state: String, + }, + TaskQueued { + process: ProcessId, + task: TaskInstanceId, + actor: WorkflowActor, + reason: String, + charged_spawns: u64, + queued_tasks: usize, + }, + TaskAssignment { + assignment: Option>, + }, + ArtifactTransferAssignment { + transfer: Option, + }, + ArtifactTransferChunkAccepted { + transfer_id: String, + next_offset: u64, + complete: bool, + }, + ArtifactTransferFailed { + transfer_id: String, + }, + RendezvousPlan { + plan: DirectBulkTransferPlan, + charged_rendezvous_attempts: u64, + }, + SourcePreparation { + status: SourcePreparationStatus, + }, + SourcePreparationCompleted { + node: NodeId, + provider: SourceProviderKind, + source_snapshot: Digest, + }, + ProcessStarted { + process: ProcessId, + epoch: u64, + actor: WorkflowActor, + charged_spawns: u64, + }, + NodeReconnected { + node: NodeId, + process: ProcessId, + }, + TaskCancellationRequested { + process: ProcessId, + task: TaskInstanceId, + node: NodeId, + }, + ProcessCancellationRequested { + process: ProcessId, + cancelled_tasks: Vec, + affected_nodes: Vec, + }, + ProcessAborted { + process: ProcessId, + aborted_tasks: Vec, + affected_nodes: Vec, + }, + ProcessStatuses { + processes: Vec, + actor: UserId, + }, + QuotaStatus { + tenant: TenantId, + project: ProjectId, + actor: UserId, + #[serde(default, skip_serializing_if = "Option::is_none")] + policy_label: Option, + limits: ResourceLimits, + window_seconds: BTreeMap, + usage: BTreeMap, + window_started_epoch_seconds: BTreeMap, + }, + TaskControl { + process: ProcessId, + task: TaskInstanceId, + cancel_requested: bool, + abort_requested: bool, + }, + TaskRestart { + process: ProcessId, + task: TaskInstanceId, + restarted_task_instance: Option, + actor: UserId, + accepted: bool, + clean_boundary_available: bool, + active_task: bool, + completed_event_observed: bool, + requires_whole_process_restart: bool, + message: String, + audit_event: DebugAuditEvent, + charged_debug_read_bytes: u64, + used_debug_read_bytes: u64, + }, + DebugCommand { + process: ProcessId, + task: TaskInstanceId, + epoch: Option, + command: Option, + }, + DebugStateRecorded { + process: ProcessId, + node: NodeId, + task: TaskInstanceId, + epoch: u64, + state: DebugAcknowledgementState, + }, + DebugAttach { + process: ProcessId, + actor: UserId, + authorization: Authorization, + audit_event: DebugAuditEvent, + charged_debug_read_bytes: u64, + used_debug_read_bytes: u64, + }, + DebugBreakpoints { + process: ProcessId, + actor: UserId, + probe_symbols: Vec, + hit_epoch: Option, + hit_task: Option, + hit_probe_symbol: Option, + audit_event: DebugAuditEvent, + charged_debug_read_bytes: u64, + used_debug_read_bytes: u64, + }, + DebugProbeHit { + process: ProcessId, + node: NodeId, + task: TaskInstanceId, + probe_symbol: String, + breakpoint_matched: bool, + debug_epoch: Option, + }, + DebugEpoch { + process: ProcessId, + actor: UserId, + epoch: u64, + command: String, + affected_tasks: Vec, + all_stop_requested: bool, + audit_event: DebugAuditEvent, + charged_debug_read_bytes: u64, + used_debug_read_bytes: u64, + }, + DebugEpochStatus { + process: ProcessId, + actor: UserId, + epoch: u64, + command: String, + expected_tasks: Vec, + acknowledgements: Vec, + fully_frozen: bool, + fully_resumed: bool, + failed: bool, + failure_messages: Vec, + audit_event: DebugAuditEvent, + charged_debug_read_bytes: u64, + used_debug_read_bytes: u64, + }, + TaskLogRecorded { + process: ProcessId, + task: TaskInstanceId, + stdout_bytes: u64, + stderr_bytes: u64, + stdout_tail: String, + stderr_tail: String, + backpressured: bool, + }, + VfsMetadataRecorded { + process: ProcessId, + task: TaskInstanceId, + artifact_path: Option, + large_bytes_uploaded: bool, + }, + TaskRecorded { + process: ProcessId, + task: TaskInstanceId, + events_recorded: usize, + }, + TaskEvents { + events: Vec, + }, + TaskJoined { + join: TaskJoinResult, + }, + OperatorPanel { + panel: PanelState, + }, + PanelEventAccepted { + used_events: u64, + max_events: u64, + }, + ArtifactDownloadLink { + link: DownloadLink, + }, + ArtifactDownloadLinkRevoked { + link: DownloadLink, + }, + ArtifactDownloadStream { + link: DownloadLink, + streamed_bytes: u64, + charged_download_bytes: u64, + content_bytes_available: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_offset: Option, + #[serde(default)] + content_eof: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_base64: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_source: Option, + }, + ArtifactExportPlan { + plan: DirectBulkTransferPlan, + source_node: NodeId, + receiver_node: NodeId, + artifact_size_bytes: u64, + }, + Error { + message: String, + }, +} diff --git a/crates/disasmer-coordinator/src/service/quota.rs b/crates/disasmer-coordinator/src/service/quota.rs new file mode 100644 index 0000000..a265dd3 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/quota.rs @@ -0,0 +1,421 @@ +use std::collections::BTreeMap; + +use disasmer_core::{LimitError, LimitKind, ProjectId, ResourceLimits, ResourceMeter, TenantId}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CoordinatorQuotaConfiguration { + pub limits: ResourceLimits, + pub window_seconds: BTreeMap, + pub policy_label: Option, +} + +pub(super) struct CoordinatorQuotaStatus { + pub(super) policy_label: Option, + pub(super) limits: ResourceLimits, + pub(super) window_seconds: BTreeMap, + pub(super) usage: BTreeMap, + pub(super) window_started_epoch_seconds: BTreeMap, +} + +impl CoordinatorQuotaConfiguration { + pub fn new( + limits: ResourceLimits, + window_seconds: impl IntoIterator, + ) -> Result { + let window_seconds = window_seconds.into_iter().collect::>(); + if window_seconds.values().any(|seconds| *seconds == 0) { + return Err("quota windows must be at least one second".to_owned()); + } + Ok(Self { + limits, + window_seconds, + policy_label: None, + }) + } + + pub fn with_policy_label(mut self, label: impl Into) -> Self { + let label = label.into(); + self.policy_label = (!label.trim().is_empty()).then_some(label); + self + } + + pub fn unlimited() -> Self { + Self { + limits: ResourceLimits::unlimited(), + window_seconds: LimitKind::ALL + .into_iter() + .map(|kind| (kind, u64::MAX)) + .collect(), + policy_label: None, + } + } + + pub fn window_seconds(&self, kind: LimitKind) -> u64 { + self.window_seconds + .get(&kind) + .copied() + .unwrap_or(u64::MAX) + .max(1) + } +} + +impl Default for CoordinatorQuotaConfiguration { + fn default() -> Self { + Self::unlimited() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct ProjectQuotaScope { + tenant: TenantId, + project: ProjectId, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct MeterKey { + scope: ProjectQuotaScope, + kind: LimitKind, + window: u64, +} + +#[derive(Clone, Debug)] +pub(super) struct CoordinatorQuota { + configuration: CoordinatorQuotaConfiguration, + meters: BTreeMap, +} + +impl Default for CoordinatorQuota { + fn default() -> Self { + Self::new(CoordinatorQuotaConfiguration::default()) + } +} + +impl CoordinatorQuota { + pub(super) fn new(configuration: CoordinatorQuotaConfiguration) -> Self { + Self { + configuration, + meters: BTreeMap::new(), + } + } + + fn key( + &self, + tenant: &TenantId, + project: &ProjectId, + kind: LimitKind, + now_epoch_seconds: u64, + ) -> MeterKey { + MeterKey { + scope: ProjectQuotaScope { + tenant: tenant.clone(), + project: project.clone(), + }, + kind, + window: now_epoch_seconds / self.configuration.window_seconds(kind), + } + } + + fn meter( + &self, + tenant: &TenantId, + project: &ProjectId, + kind: LimitKind, + now_epoch_seconds: u64, + ) -> Option<&ResourceMeter> { + self.meters + .get(&self.key(tenant, project, kind, now_epoch_seconds)) + } + + fn meter_mut( + &mut self, + tenant: &TenantId, + project: &ProjectId, + kind: LimitKind, + now_epoch_seconds: u64, + ) -> &mut ResourceMeter { + let key = self.key(tenant, project, kind, now_epoch_seconds); + self.meters.retain(|existing, _| { + existing.scope != key.scope || existing.kind != kind || existing.window == key.window + }); + self.meters.entry(key).or_default() + } + + fn can_charge( + &self, + tenant: &TenantId, + project: &ProjectId, + kind: LimitKind, + amount: u64, + now_epoch_seconds: u64, + ) -> Result<(), LimitError> { + self.meter(tenant, project, kind, now_epoch_seconds) + .cloned() + .unwrap_or_default() + .can_charge(&self.configuration.limits, kind, amount) + } + + fn charge( + &mut self, + tenant: &TenantId, + project: &ProjectId, + kind: LimitKind, + amount: u64, + now_epoch_seconds: u64, + ) -> Result { + let limits = self.configuration.limits.clone(); + let meter = self.meter_mut(tenant, project, kind, now_epoch_seconds); + meter.charge(&limits, kind, amount)?; + Ok(meter.used(&kind)) + } + + fn used( + &self, + tenant: &TenantId, + project: &ProjectId, + kind: LimitKind, + now_epoch_seconds: u64, + ) -> u64 { + self.meter(tenant, project, kind, now_epoch_seconds) + .map_or(0, |meter| meter.used(&kind)) + } + + pub(super) fn can_charge_workflow_spawn( + &self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> Result<(), LimitError> { + self.can_charge(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds) + } + + pub(super) fn charge_api_call( + &mut self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> Result { + self.charge(tenant, project, LimitKind::ApiCall, 1, now_epoch_seconds) + } + + pub(super) fn can_charge_log_bytes( + &self, + tenant: &TenantId, + project: &ProjectId, + bytes: u64, + now_epoch_seconds: u64, + ) -> Result<(), LimitError> { + self.can_charge( + tenant, + project, + LimitKind::LogBytes, + bytes, + now_epoch_seconds, + ) + } + + pub(super) fn charge_log_bytes( + &mut self, + tenant: &TenantId, + project: &ProjectId, + bytes: u64, + now_epoch_seconds: u64, + ) -> Result { + self.charge( + tenant, + project, + LimitKind::LogBytes, + bytes, + now_epoch_seconds, + ) + } + + pub(super) fn charge_workflow_spawn( + &mut self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> Result { + self.charge(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds) + } + + #[cfg(test)] + pub(super) fn used_workflow_spawns( + &self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> u64 { + self.used(tenant, project, LimitKind::Spawn, now_epoch_seconds) + } + + #[cfg(test)] + pub(super) fn used_api_calls( + &self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> u64 { + self.used(tenant, project, LimitKind::ApiCall, now_epoch_seconds) + } + + #[cfg(test)] + pub(super) fn used_log_bytes( + &self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> u64 { + self.used(tenant, project, LimitKind::LogBytes, now_epoch_seconds) + } + + #[cfg(test)] + pub(super) fn active_meter_count(&self) -> usize { + self.meters.len() + } + + pub(super) fn charge_rendezvous_attempt( + &mut self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> Result { + self.charge( + tenant, + project, + LimitKind::RendezvousAttempt, + 1, + now_epoch_seconds, + ) + } + + pub(super) fn can_charge_download( + &self, + tenant: &TenantId, + project: &ProjectId, + bytes: u64, + now_epoch_seconds: u64, + ) -> Result<(), LimitError> { + self.can_charge( + tenant, + project, + LimitKind::ArtifactDownloadBytes, + bytes, + now_epoch_seconds, + ) + } + + pub(super) fn charge_download( + &mut self, + tenant: &TenantId, + project: &ProjectId, + bytes: u64, + now_epoch_seconds: u64, + ) -> Result { + self.charge( + tenant, + project, + LimitKind::ArtifactDownloadBytes, + bytes, + now_epoch_seconds, + ) + } + + pub(super) fn download_limit(&self) -> u64 { + self.configuration + .limits + .limit(&LimitKind::ArtifactDownloadBytes) + } + + pub(super) fn used_download_bytes( + &self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> u64 { + self.used( + tenant, + project, + LimitKind::ArtifactDownloadBytes, + now_epoch_seconds, + ) + } + + pub(super) fn charge_debug_read( + &mut self, + tenant: &TenantId, + project: &ProjectId, + bytes: u64, + now_epoch_seconds: u64, + ) -> Result { + self.charge( + tenant, + project, + LimitKind::DebugReadBytes, + bytes, + now_epoch_seconds, + ) + } + + pub(super) fn used_debug_read_bytes( + &self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> u64 { + self.used( + tenant, + project, + LimitKind::DebugReadBytes, + now_epoch_seconds, + ) + } + + pub(super) fn project_status( + &self, + tenant: &TenantId, + project: &ProjectId, + now_epoch_seconds: u64, + ) -> CoordinatorQuotaStatus { + let mut usage = BTreeMap::new(); + let mut window_starts = BTreeMap::new(); + for kind in LimitKind::ALL { + let seconds = self.configuration.window_seconds(kind); + usage.insert(kind, self.used(tenant, project, kind, now_epoch_seconds)); + window_starts.insert(kind, (now_epoch_seconds / seconds).saturating_mul(seconds)); + } + CoordinatorQuotaStatus { + policy_label: self.configuration.policy_label.clone(), + limits: self.configuration.limits.clone(), + window_seconds: self.configuration.window_seconds.clone(), + usage, + window_started_epoch_seconds: window_starts, + } + } + + #[cfg(test)] + pub(super) fn set_workflow_limits(&mut self, limits: ResourceLimits) { + self.configuration + .limits + .limits + .insert(LimitKind::Spawn, limits.limit(&LimitKind::Spawn)); + self.meters.clear(); + } + + #[cfg(test)] + pub(super) fn set_download_limits(&mut self, limits: ResourceLimits) { + self.configuration.limits.limits.insert( + LimitKind::ArtifactDownloadBytes, + limits.limit(&LimitKind::ArtifactDownloadBytes), + ); + self.meters.clear(); + } + + #[cfg(test)] + pub(super) fn set_rendezvous_limits(&mut self, limits: ResourceLimits) { + self.configuration.limits.limits.insert( + LimitKind::RendezvousAttempt, + limits.limit(&LimitKind::RendezvousAttempt), + ); + self.meters.clear(); + } +} diff --git a/crates/disasmer-coordinator/src/service/routing.rs b/crates/disasmer-coordinator/src/service/routing.rs new file mode 100644 index 0000000..52da137 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/routing.rs @@ -0,0 +1,778 @@ +use super::*; + +impl CoordinatorService { + pub fn handle_request( + &mut self, + request: CoordinatorRequest, + ) -> Result { + self.pump_main_runtime_commands(); + let request_payload = serde_json::to_value(&request).map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "failed to canonicalize coordinator request for authentication: {error}" + )) + })?; + let request_payload_digest = disasmer_core::signed_request_payload_digest(&request_payload); + match request { + CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong { + epoch: self.coordinator.coordinator_epoch(), + }), + CoordinatorRequest::Authenticated { + session_secret, + request, + } => self.handle_authenticated_request(session_secret, request), + CoordinatorRequest::AuthStatus { + tenant, + project, + actor_user, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let account_state = self.coordinator.account_policy_state(&tenant); + Ok(CoordinatorResponse::AuthStatus { + tenant, + project, + actor, + authenticated: true, + account_status: account_state.account_status, + suspended: account_state.suspended, + disabled: account_state.disabled, + deleted: account_state.deleted, + manual_review: account_state.manual_review, + sanitized_reason: account_state.sanitized_reason, + next_actions: account_state.next_actions, + private_moderation_details_exposed: false, + signup_failure_details_exposed: false, + }) + } + CoordinatorRequest::AdminStatus { + tenant, + actor_user, + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + } => self.handle_admin_status( + tenant, + actor_user, + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + ), + CoordinatorRequest::SuspendTenant { + tenant, + actor_user, + target_tenant, + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + } => self.handle_suspend_tenant( + tenant, + actor_user, + target_tenant, + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + ), + CoordinatorRequest::CreateProject { + tenant, + actor_user, + project, + name, + } => { + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let project = ProjectId::new(project); + self.coordinator.ensure_tenant_active(&tenant)?; + if let Some(existing) = self.coordinator.project(&project) { + if existing.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "project id is outside the signed-in tenant scope".to_owned(), + ) + .into()); + } + } + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator.upsert_user( + tenant.clone(), + actor.clone(), + CredentialKind::BrowserSession, + ); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), name); + self.coordinator.grant_project_debug( + tenant.clone(), + project.clone(), + actor.clone(), + ); + self.persist_durable_state()?; + let project = self + .coordinator + .project(&project) + .expect("project was just created") + .clone(); + Ok(CoordinatorResponse::ProjectCreated { project, actor }) + } + CoordinatorRequest::SelectProject { + tenant, + actor_user, + project, + } => { + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let project_id = ProjectId::new(project); + let project = self + .coordinator + .project(&project_id) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "project is not visible to the signed-in user".to_owned(), + ) + })? + .clone(); + if project.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "project is outside the signed-in tenant scope".to_owned(), + ) + .into()); + } + self.coordinator + .upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession); + self.persist_durable_state()?; + Ok(CoordinatorResponse::ProjectSelected { project, actor }) + } + CoordinatorRequest::ListProjects { tenant, actor_user } => { + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: ProjectId::from("__project_listing__"), + actor: Actor::User(actor.clone()), + }; + self.coordinator + .upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession); + self.persist_durable_state()?; + Ok(CoordinatorResponse::Projects { + projects: self.coordinator.list_projects(&context), + actor, + }) + } + CoordinatorRequest::RegisterAgentPublicKey { + tenant, + project, + user, + agent, + public_key, + } + | CoordinatorRequest::RotateAgentPublicKey { + tenant, + project, + user, + agent, + public_key, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(user); + let agent = AgentId::new(agent); + self.coordinator.ensure_tenant_active(&tenant)?; + if let Some(existing) = self.coordinator.project(&project) { + if existing.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "project id is outside the signed-in tenant scope".to_owned(), + ) + .into()); + } + } + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator.upsert_user( + tenant.clone(), + actor.clone(), + CredentialKind::CliDeviceSession, + ); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), "local"); + let record = self.coordinator.register_agent_public_key( + tenant, + project, + actor.clone(), + agent, + public_key, + ); + self.persist_durable_state()?; + Ok(CoordinatorResponse::AgentPublicKey { record, actor }) + } + CoordinatorRequest::ListAgentPublicKeys { + tenant, + project, + user, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(user); + let context = disasmer_core::AuthContext { + tenant, + project, + actor: Actor::User(actor.clone()), + }; + Ok(CoordinatorResponse::AgentPublicKeys { + records: self.coordinator.list_agent_public_keys(&context), + actor, + }) + } + CoordinatorRequest::RevokeAgentPublicKey { + tenant, + project, + user, + agent, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(user); + let agent = AgentId::new(agent); + let context = disasmer_core::AuthContext { + tenant, + project, + actor: Actor::User(actor.clone()), + }; + let record = self.coordinator.revoke_agent_public_key(&context, &agent)?; + self.persist_durable_state()?; + Ok(CoordinatorResponse::AgentPublicKey { record, actor }) + } + CoordinatorRequest::AttachNode { + tenant, + project, + node, + public_key, + } => self.handle_attach_node(tenant, project, node, public_key), + CoordinatorRequest::CreateNodeEnrollmentGrant { + tenant, + project, + actor_user, + ttl_seconds, + } => self.handle_create_node_enrollment_grant(tenant, project, actor_user, ttl_seconds), + CoordinatorRequest::ExchangeNodeEnrollmentGrant { + tenant, + project, + node, + public_key, + enrollment_grant, + } => self.handle_exchange_node_enrollment_grant( + tenant, + project, + node, + public_key, + enrollment_grant, + ), + CoordinatorRequest::NodeHeartbeat { + node, + node_signature, + } => self.handle_node_heartbeat(node, node_signature, &request_payload_digest), + CoordinatorRequest::SignedNode { + node, + node_signature, + request, + } => self.handle_signed_node_request(node, node_signature, *request), + CoordinatorRequest::ReportNodeCapabilities { .. } => { + self.reject_unsigned_node_request() + } + CoordinatorRequest::ListNodeDescriptors { + tenant, + project, + actor_user, + } => self.handle_list_node_descriptors(tenant, project, actor_user), + CoordinatorRequest::RevokeNodeCredential { + tenant, + project, + actor_user, + node, + } => self.handle_revoke_node_credential(tenant, project, actor_user, node), + CoordinatorRequest::ScheduleTask { + tenant, + project, + environment, + environment_digest, + required_capabilities, + dependency_cache, + source_snapshot, + required_artifacts, + prefer_node, + } => self.handle_schedule_task( + tenant, + project, + environment, + environment_digest, + required_capabilities, + dependency_cache, + source_snapshot, + required_artifacts, + prefer_node, + ), + CoordinatorRequest::LaunchTask { + tenant, + project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + } => self.handle_launch_task( + tenant, + project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + Some(&request_payload_digest), + task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + ), + CoordinatorRequest::LaunchChildTask { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::JoinChildTask { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::PollTaskAssignment { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::RequestRendezvous { + scope, + source, + destination, + direct_connectivity, + failure_reason, + } => self.handle_request_rendezvous( + scope, + source, + destination, + direct_connectivity, + failure_reason, + ), + CoordinatorRequest::RequestSourcePreparation { + tenant, + project, + provider, + } => self.handle_request_source_preparation(tenant, project, provider), + CoordinatorRequest::CompleteSourcePreparation { .. } => { + self.reject_unsigned_node_request() + } + CoordinatorRequest::StartProcess { + tenant, + project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + process, + restart, + } => self.handle_start_process( + tenant, + project, + actor_user, + actor_agent, + agent_public_key_fingerprint, + agent_signature, + Some(&request_payload_digest), + process, + restart, + ), + CoordinatorRequest::ReconnectNode { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::CancelTask { + tenant, + project, + process, + node, + task, + } => self.handle_cancel_task(tenant, project, process, node, task), + CoordinatorRequest::CancelProcess { + tenant, + project, + actor_user, + process, + } => self.handle_cancel_process(tenant, project, actor_user, process), + CoordinatorRequest::AbortProcess { + tenant, + project, + actor_user, + process, + } => self.handle_abort_process(tenant, project, actor_user, process), + CoordinatorRequest::ListProcesses { + tenant, + project, + actor_user, + } => self.handle_list_processes(tenant, project, actor_user), + CoordinatorRequest::QuotaStatus { + tenant, + project, + actor_user, + } => self.handle_quota_status(tenant, project, actor_user), + CoordinatorRequest::PollTaskControl { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::PollArtifactTransfer { .. } + | CoordinatorRequest::UploadArtifactTransferChunk { .. } + | CoordinatorRequest::FailArtifactTransfer { .. } => { + self.reject_unsigned_node_request() + } + CoordinatorRequest::RestartTask { + tenant, + project, + actor_user, + process, + task, + replacement_bundle, + } => self.handle_restart_task( + tenant, + project, + actor_user, + process, + task, + replacement_bundle, + ), + request @ (CoordinatorRequest::DebugAttach { .. } + | CoordinatorRequest::SetDebugBreakpoints { .. } + | CoordinatorRequest::InspectDebugBreakpoints { .. } + | CoordinatorRequest::CreateDebugEpoch { .. } + | CoordinatorRequest::ResumeDebugEpoch { .. } + | CoordinatorRequest::InspectDebugEpoch { .. }) => self.handle_debug_request(request), + CoordinatorRequest::PollDebugCommand { .. } + | CoordinatorRequest::ReportDebugState { .. } + | CoordinatorRequest::ReportDebugProbeHit { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::ReportTaskLog { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::ReportVfsMetadata { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::TaskCompleted { .. } => self.reject_unsigned_node_request(), + CoordinatorRequest::ListTaskEvents { + tenant, + project, + actor_user, + process, + } => self.handle_list_task_events(tenant, project, actor_user, process), + CoordinatorRequest::JoinTask { + tenant, + project, + actor_user, + process, + task, + } => self.handle_join_task(tenant, project, actor_user, process, task), + CoordinatorRequest::RenderOperatorPanel { + tenant, + project, + process, + actor_user, + max_download_bytes, + stopped, + } => self.handle_render_operator_panel( + tenant, + project, + actor_user, + process, + max_download_bytes, + stopped, + ), + CoordinatorRequest::SubmitPanelEvent { + tenant, + project, + process, + widget_id, + kind, + max_events, + } => self + .handle_submit_panel_event(tenant, project, process, widget_id, kind, max_events), + CoordinatorRequest::CreateArtifactDownloadLink { + tenant, + project, + actor_user, + artifact, + max_bytes, + ttl_seconds, + } => self.handle_create_artifact_download_link( + tenant, + project, + actor_user, + artifact, + max_bytes, + ttl_seconds, + ), + CoordinatorRequest::OpenArtifactDownloadStream { + tenant, + project, + actor_user, + artifact, + max_bytes, + token_digest, + chunk_bytes, + } => self.handle_open_artifact_download_stream( + tenant, + project, + actor_user, + artifact, + max_bytes, + token_digest, + chunk_bytes, + ), + CoordinatorRequest::RevokeArtifactDownloadLink { + tenant, + project, + actor_user, + artifact, + token_digest, + } => self.handle_revoke_artifact_download_link( + tenant, + project, + actor_user, + artifact, + token_digest, + ), + CoordinatorRequest::ExportArtifactToNode { + tenant, + project, + actor_user, + artifact, + receiver_node, + direct_connectivity, + failure_reason, + } => self.handle_export_artifact_to_node( + tenant, + project, + actor_user, + artifact, + receiver_node, + direct_connectivity, + failure_reason, + ), + } + } + + fn handle_authenticated_request( + &mut self, + session_secret: String, + request: AuthenticatedCoordinatorRequest, + ) -> Result { + let context = self.coordinator.authenticate_cli_session(&session_secret)?; + let authorized = authorize_authenticated_user_operation(&context, &request)?; + let now_epoch_seconds = self.current_epoch_seconds()?; + self.quota + .charge_api_call(&context.tenant, &context.project, now_epoch_seconds)?; + let _authorized_operation = authorized.operation; + let actor = authorized.actor; + match request { + AuthenticatedCoordinatorRequest::AuthStatus => { + let account_state = self.coordinator.account_policy_state(&context.tenant); + Ok(CoordinatorResponse::AuthStatus { + tenant: context.tenant, + project: context.project, + actor, + authenticated: true, + account_status: account_state.account_status, + suspended: account_state.suspended, + disabled: account_state.disabled, + deleted: account_state.deleted, + manual_review: account_state.manual_review, + sanitized_reason: account_state.sanitized_reason, + next_actions: account_state.next_actions, + private_moderation_details_exposed: false, + signup_failure_details_exposed: false, + }) + } + AuthenticatedCoordinatorRequest::RevokeCliSession => { + self.coordinator.revoke_cli_session(&session_secret)?; + self.persist_durable_state()?; + Ok(CoordinatorResponse::CliSessionRevoked { + tenant: context.tenant, + project: context.project, + actor, + }) + } + AuthenticatedCoordinatorRequest::CreateProject { project, name } => { + let project = ProjectId::new(project); + self.coordinator.ensure_tenant_active(&context.tenant)?; + if let Some(existing) = self.coordinator.project(&project) { + if existing.tenant != context.tenant { + return Err(CoordinatorError::Unauthorized( + "project id is outside the authenticated tenant scope".to_owned(), + ) + .into()); + } + } + self.coordinator + .upsert_project(context.tenant.clone(), project.clone(), name); + self.coordinator.grant_project_debug( + context.tenant.clone(), + project.clone(), + actor.clone(), + ); + self.persist_durable_state()?; + let project = self + .coordinator + .project(&project) + .expect("project was just created") + .clone(); + Ok(CoordinatorResponse::ProjectCreated { project, actor }) + } + AuthenticatedCoordinatorRequest::SelectProject { project } => { + let project_id = ProjectId::new(project); + let project = self + .coordinator + .project(&project_id) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "project is not visible to the authenticated user".to_owned(), + ) + })? + .clone(); + if project.tenant != context.tenant { + return Err(CoordinatorError::Unauthorized( + "project is outside the authenticated tenant scope".to_owned(), + ) + .into()); + } + Ok(CoordinatorResponse::ProjectSelected { project, actor }) + } + AuthenticatedCoordinatorRequest::ListProjects => Ok(CoordinatorResponse::Projects { + projects: self.coordinator.list_projects(&context), + actor, + }), + request @ (AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { .. } + | AuthenticatedCoordinatorRequest::ListAgentPublicKeys + | AuthenticatedCoordinatorRequest::RotateAgentPublicKey { .. } + | AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { .. }) => { + self.handle_authenticated_agent_key_request(&context, &actor, request) + } + AuthenticatedCoordinatorRequest::CreateNodeEnrollmentGrant { ttl_seconds } => self + .handle_create_node_enrollment_grant( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + ttl_seconds, + ), + AuthenticatedCoordinatorRequest::ListNodeDescriptors => self + .handle_list_node_descriptors( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + ), + AuthenticatedCoordinatorRequest::RevokeNodeCredential { node } => self + .handle_revoke_node_credential( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + node, + ), + AuthenticatedCoordinatorRequest::StartProcess { process, restart } => self + .handle_start_process( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + Some(actor.as_str().to_owned()), + None, + None, + None, + None, + process, + restart, + ), + request @ AuthenticatedCoordinatorRequest::ScheduleTask { .. } => { + self.handle_authenticated_schedule_task(&context, request) + } + request @ AuthenticatedCoordinatorRequest::LaunchTask { .. } => { + self.handle_authenticated_launch_task(&context, &actor, request) + } + AuthenticatedCoordinatorRequest::CancelProcess { process } => self + .handle_cancel_process( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + ), + AuthenticatedCoordinatorRequest::AbortProcess { process } => self.handle_abort_process( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + ), + AuthenticatedCoordinatorRequest::ListProcesses => self.handle_list_processes( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + ), + AuthenticatedCoordinatorRequest::QuotaStatus => self.handle_quota_status( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + ), + AuthenticatedCoordinatorRequest::RestartTask { + process, + task, + replacement_bundle, + } => self.handle_restart_task( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + task, + replacement_bundle, + ), + request @ (AuthenticatedCoordinatorRequest::DebugAttach { .. } + | AuthenticatedCoordinatorRequest::SetDebugBreakpoints { .. } + | AuthenticatedCoordinatorRequest::InspectDebugBreakpoints { .. } + | AuthenticatedCoordinatorRequest::CreateDebugEpoch { .. } + | AuthenticatedCoordinatorRequest::ResumeDebugEpoch { .. } + | AuthenticatedCoordinatorRequest::InspectDebugEpoch { .. }) => self + .handle_authenticated_debug_request( + &context.tenant, + &context.project, + &actor, + request, + ), + AuthenticatedCoordinatorRequest::ListTaskEvents { process } => self + .handle_list_task_events( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + ), + AuthenticatedCoordinatorRequest::JoinTask { process, task } => self.handle_join_task( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + process, + task, + ), + AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink { + artifact, + max_bytes, + ttl_seconds, + } => self.handle_create_artifact_download_link( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + artifact, + max_bytes, + ttl_seconds, + ), + AuthenticatedCoordinatorRequest::OpenArtifactDownloadStream { + artifact, + max_bytes, + token_digest, + chunk_bytes, + } => self.handle_open_artifact_download_stream( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + artifact, + max_bytes, + token_digest, + chunk_bytes, + ), + AuthenticatedCoordinatorRequest::RevokeArtifactDownloadLink { + artifact, + token_digest, + } => self.handle_revoke_artifact_download_link( + context.tenant.as_str().to_owned(), + context.project.as_str().to_owned(), + actor.as_str().to_owned(), + artifact, + token_digest, + ), + request @ AuthenticatedCoordinatorRequest::ExportArtifactToNode { .. } => { + self.handle_authenticated_artifact_export(&context, &actor, request) + } + } + } +} diff --git a/crates/disasmer-coordinator/src/service/signed_nodes.rs b/crates/disasmer-coordinator/src/service/signed_nodes.rs new file mode 100644 index 0000000..c81f91b --- /dev/null +++ b/crates/disasmer-coordinator/src/service/signed_nodes.rs @@ -0,0 +1,365 @@ +use disasmer_core::{NodeId, NodeSignedRequest}; + +use crate::CoordinatorError; + +use super::{CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError}; + +impl CoordinatorService { + pub(super) fn handle_signed_node_request( + &mut self, + signed_node: String, + node_signature: NodeSignedRequest, + request: CoordinatorRequest, + ) -> Result { + let request_kind = signed_node_request_kind(&request)?; + let request_node = signed_node_request_node(&request)?; + let request_payload = serde_json::to_value(&request).map_err(|error| { + CoordinatorServiceError::Protocol(format!( + "failed to canonicalize signed node request: {error}" + )) + })?; + let payload_digest = disasmer_core::signed_request_payload_digest(&request_payload); + let signed_node = NodeId::new(signed_node); + if request_node != signed_node { + return Err(CoordinatorError::Unauthorized( + "signed node request node does not match the wrapped request node".to_owned(), + ) + .into()); + } + self.authenticate_node_request( + &signed_node, + Some(node_signature), + request_kind, + &payload_digest, + )?; + match request { + CoordinatorRequest::ReportNodeCapabilities { + tenant, + project, + node, + capabilities, + cached_environment_digests, + dependency_cache_digests, + source_snapshots, + artifact_locations, + direct_connectivity, + online, + } => self.handle_report_node_capabilities( + tenant, + project, + node, + capabilities, + cached_environment_digests, + dependency_cache_digests, + source_snapshots, + artifact_locations, + direct_connectivity, + online, + ), + CoordinatorRequest::PollTaskAssignment { + tenant, + project, + node, + } => self.handle_poll_task_assignment(tenant, project, node), + CoordinatorRequest::PollArtifactTransfer { + tenant, + project, + node, + } => self.handle_poll_artifact_transfer(tenant, project, node), + CoordinatorRequest::UploadArtifactTransferChunk { + tenant, + project, + node, + transfer_id, + artifact, + offset, + content_base64, + chunk_digest, + eof, + } => self.handle_upload_artifact_transfer_chunk( + tenant, + project, + node, + transfer_id, + artifact, + offset, + content_base64, + chunk_digest, + eof, + ), + CoordinatorRequest::FailArtifactTransfer { + tenant, + project, + node, + transfer_id, + artifact, + message, + } => self.handle_fail_artifact_transfer( + tenant, + project, + node, + transfer_id, + artifact, + message, + ), + CoordinatorRequest::LaunchChildTask { + tenant, + project, + process, + node, + parent_task, + task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + } => self.handle_launch_child_task( + tenant, + project, + process, + node, + parent_task, + task_spec, + wait_for_node, + artifact_path, + wasm_module_base64, + ), + CoordinatorRequest::JoinChildTask { + tenant, + project, + process, + node, + parent_task, + task, + } => self.handle_join_child_task(tenant, project, process, node, parent_task, task), + CoordinatorRequest::CompleteSourcePreparation { + tenant, + project, + node, + provider, + source_snapshot, + } => self.handle_complete_source_preparation( + tenant, + project, + node, + provider, + source_snapshot, + ), + CoordinatorRequest::ReconnectNode { + node, + process, + epoch, + } => self.handle_reconnect_node(node, process, epoch), + CoordinatorRequest::PollTaskControl { + tenant, + project, + process, + node, + task, + } => self.handle_poll_task_control(tenant, project, process, node, task), + CoordinatorRequest::PollDebugCommand { + tenant, + project, + process, + node, + task, + } => self.handle_poll_debug_command(tenant, project, process, node, task), + CoordinatorRequest::ReportDebugState { + tenant, + project, + process, + node, + task, + epoch, + state, + stack_frames, + local_values, + task_args, + handles, + command_status, + recent_output, + message, + } => self.handle_report_debug_state( + tenant, + project, + process, + node, + task, + epoch, + state, + stack_frames, + local_values, + task_args, + handles, + command_status, + recent_output, + message, + ), + CoordinatorRequest::ReportDebugProbeHit { + tenant, + project, + process, + node, + task, + probe_symbol, + } => self.handle_report_debug_probe_hit( + tenant, + project, + process, + node, + task, + probe_symbol, + ), + CoordinatorRequest::ReportTaskLog { + tenant, + project, + process, + node, + task, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + backpressured, + } => self.handle_report_task_log( + tenant, + project, + process, + node, + task, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + backpressured, + ), + CoordinatorRequest::ReportVfsMetadata { + tenant, + project, + process, + node, + task, + artifact_path, + artifact_digest, + artifact_size_bytes, + large_bytes_uploaded, + } => self.handle_report_vfs_metadata( + tenant, + project, + process, + node, + task, + artifact_path, + artifact_digest, + artifact_size_bytes, + large_bytes_uploaded, + ), + CoordinatorRequest::TaskCompleted { + tenant, + project, + process, + node, + task, + terminal_state, + status_code, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + artifact_path, + artifact_digest, + artifact_size_bytes, + result, + } => self.handle_task_completed( + tenant, + project, + process, + node, + task, + terminal_state, + status_code, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + artifact_path, + artifact_digest, + artifact_size_bytes, + result, + ), + _ => self.reject_unsigned_node_request(), + } + } + + pub(super) fn reject_unsigned_node_request( + &self, + ) -> Result { + Err(CoordinatorError::Unauthorized( + "node-originated request requires signed_node envelope proof".to_owned(), + ) + .into()) + } +} + +fn signed_node_request_kind( + request: &CoordinatorRequest, +) -> Result<&'static str, CoordinatorServiceError> { + match request { + CoordinatorRequest::ReportNodeCapabilities { .. } => Ok("report_node_capabilities"), + CoordinatorRequest::PollTaskAssignment { .. } => Ok("poll_task_assignment"), + CoordinatorRequest::PollArtifactTransfer { .. } => Ok("poll_artifact_transfer"), + CoordinatorRequest::UploadArtifactTransferChunk { .. } => { + Ok("upload_artifact_transfer_chunk") + } + CoordinatorRequest::FailArtifactTransfer { .. } => Ok("fail_artifact_transfer"), + CoordinatorRequest::LaunchChildTask { .. } => Ok("launch_child_task"), + CoordinatorRequest::JoinChildTask { .. } => Ok("join_child_task"), + CoordinatorRequest::CompleteSourcePreparation { .. } => Ok("complete_source_preparation"), + CoordinatorRequest::ReconnectNode { .. } => Ok("reconnect_node"), + CoordinatorRequest::PollTaskControl { .. } => Ok("poll_task_control"), + CoordinatorRequest::PollDebugCommand { .. } => Ok("poll_debug_command"), + CoordinatorRequest::ReportDebugState { .. } => Ok("report_debug_state"), + CoordinatorRequest::ReportDebugProbeHit { .. } => Ok("report_debug_probe_hit"), + CoordinatorRequest::ReportTaskLog { .. } => Ok("report_task_log"), + CoordinatorRequest::ReportVfsMetadata { .. } => Ok("report_vfs_metadata"), + CoordinatorRequest::TaskCompleted { .. } => Ok("task_completed"), + _ => Err(CoordinatorError::Unauthorized( + "signed_node envelope only accepts node-originated coordinator requests".to_owned(), + ) + .into()), + } +} + +fn signed_node_request_node( + request: &CoordinatorRequest, +) -> Result { + match request { + CoordinatorRequest::ReportNodeCapabilities { node, .. } + | CoordinatorRequest::PollTaskAssignment { node, .. } + | CoordinatorRequest::PollArtifactTransfer { node, .. } + | CoordinatorRequest::UploadArtifactTransferChunk { node, .. } + | CoordinatorRequest::FailArtifactTransfer { node, .. } + | CoordinatorRequest::LaunchChildTask { node, .. } + | CoordinatorRequest::JoinChildTask { node, .. } + | CoordinatorRequest::CompleteSourcePreparation { node, .. } + | CoordinatorRequest::ReconnectNode { node, .. } + | CoordinatorRequest::PollTaskControl { node, .. } + | CoordinatorRequest::PollDebugCommand { node, .. } + | CoordinatorRequest::ReportDebugState { node, .. } + | CoordinatorRequest::ReportDebugProbeHit { node, .. } + | CoordinatorRequest::ReportTaskLog { node, .. } + | CoordinatorRequest::ReportVfsMetadata { node, .. } + | CoordinatorRequest::TaskCompleted { node, .. } => Ok(NodeId::new(node.clone())), + _ => Err(CoordinatorError::Unauthorized( + "signed_node envelope only accepts node-originated coordinator requests".to_owned(), + ) + .into()), + } +} diff --git a/crates/disasmer-coordinator/src/service/tcp.rs b/crates/disasmer-coordinator/src/service/tcp.rs new file mode 100644 index 0000000..d519f34 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/tcp.rs @@ -0,0 +1,200 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +use super::{CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClientAuthorityMode { + Strict, + LocalTrustedLoopback, +} + +impl CoordinatorService { + pub fn serve_tcp(self, listener: TcpListener) -> Result<(), CoordinatorServiceError> { + if !listener.local_addr()?.ip().is_loopback() { + return Err(CoordinatorServiceError::Protocol( + "the native coordinator transport is plaintext and restricted to loopback; expose a remote coordinator only through a secure transport" + .to_owned(), + )); + } + self.serve_tcp_with_authority(listener, ClientAuthorityMode::Strict) + } + + pub fn serve_tcp_local_trusted( + self, + listener: TcpListener, + ) -> Result<(), CoordinatorServiceError> { + if !listener.local_addr()?.ip().is_loopback() { + return Err(CoordinatorServiceError::Protocol( + "local trusted request mode is restricted to a loopback listener".to_owned(), + )); + } + self.serve_tcp_with_authority(listener, ClientAuthorityMode::LocalTrustedLoopback) + } + + fn serve_tcp_with_authority( + self, + listener: TcpListener, + authority_mode: ClientAuthorityMode, + ) -> Result<(), CoordinatorServiceError> { + let shared = Arc::new(Mutex::new(self)); + for stream in listener.incoming() { + let stream = stream?; + let service = Arc::clone(&shared); + std::thread::spawn(move || { + if let Err(err) = handle_shared_stream(service, stream, authority_mode) { + eprintln!("coordinator stream failed: {err}"); + } + }); + } + Ok(()) + } + + pub fn handle_stream(&mut self, stream: TcpStream) -> Result<(), CoordinatorServiceError> { + self.handle_stream_with_authority(stream, ClientAuthorityMode::Strict) + } + + #[cfg(test)] + pub(super) fn handle_stream_local_trusted( + &mut self, + stream: TcpStream, + ) -> Result<(), CoordinatorServiceError> { + self.handle_stream_with_authority(stream, ClientAuthorityMode::LocalTrustedLoopback) + } + + fn handle_stream_with_authority( + &mut self, + stream: TcpStream, + authority_mode: ClientAuthorityMode, + ) -> Result<(), CoordinatorServiceError> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + loop { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(()); + } + if line.trim().is_empty() { + continue; + } + let response = match decode_wire_request(&line) { + Ok(request) => match authorize_client_request(&request, authority_mode) + .and_then(|()| self.handle_request(request)) + { + Ok(response) => response, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }; + serde_json::to_writer(&mut writer, &response)?; + writer.write_all(b"\n")?; + writer.flush()?; + } + } +} + +fn handle_shared_stream( + service: Arc>, + stream: TcpStream, + authority_mode: ClientAuthorityMode, +) -> Result<(), CoordinatorServiceError> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + loop { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(()); + } + if line.trim().is_empty() { + continue; + } + let response = match decode_wire_request(&line) { + Ok(request) => match authorize_client_request(&request, authority_mode) { + Ok(()) => match service.lock() { + Ok(mut service) => match service.handle_request(request) { + Ok(response) => response, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }, + Err(_) => CoordinatorResponse::Error { + message: "coordinator service lock poisoned".to_owned(), + }, + }, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }; + serde_json::to_writer(&mut writer, &response)?; + writer.write_all(b"\n")?; + writer.flush()?; + } +} + +pub fn bind_listener(addr: &str) -> Result<(TcpListener, SocketAddr), CoordinatorServiceError> { + let listener = TcpListener::bind(addr)?; + let addr = listener.local_addr()?; + Ok((listener, addr)) +} + +fn decode_wire_request(line: &str) -> Result { + serde_json::from_str::(line)? + .into_request() + .map_err(CoordinatorServiceError::Protocol) +} + +fn authorize_client_request( + request: &CoordinatorRequest, + authority_mode: ClientAuthorityMode, +) -> Result<(), CoordinatorServiceError> { + if authority_mode == ClientAuthorityMode::LocalTrustedLoopback { + return Ok(()); + } + match request { + CoordinatorRequest::Ping + | CoordinatorRequest::Authenticated { .. } + | CoordinatorRequest::ExchangeNodeEnrollmentGrant { .. } + | CoordinatorRequest::SignedNode { .. } + | CoordinatorRequest::NodeHeartbeat { + node_signature: Some(_), + .. + } + | CoordinatorRequest::StartProcess { + actor_agent: Some(_), + agent_signature: Some(_), + .. + } + | CoordinatorRequest::LaunchTask { + actor_agent: Some(_), + agent_signature: Some(_), + .. + } + | CoordinatorRequest::AdminStatus { .. } + | CoordinatorRequest::SuspendTenant { .. } => Ok(()), + _ => Err(CoordinatorServiceError::Protocol( + "strict Core Client authority requires an authenticated CLI session, signed Agent, signed Node, enrollment grant exchange, or admin credential; request-body identity fields are not authority" + .to_owned(), + )), + } +} + +#[cfg(test)] +mod transport_boundary_tests { + use super::*; + + #[test] + fn native_plaintext_service_refuses_non_loopback_listener() { + let (listener, _) = bind_listener("0.0.0.0:0").unwrap(); + let error = CoordinatorService::new(1).serve_tcp(listener).unwrap_err(); + assert!(error.to_string().contains("restricted to loopback")); + } +} diff --git a/crates/disasmer-coordinator/src/service/tests.rs b/crates/disasmer-coordinator/src/service/tests.rs new file mode 100644 index 0000000..9c226a3 --- /dev/null +++ b/crates/disasmer-coordinator/src/service/tests.rs @@ -0,0 +1,6323 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::{ + admin_request_proof, agent_ed25519_public_key_from_private_key, coordinator_wire_request, + derive_ed25519_private_key_from_seed, node_ed25519_public_key_from_private_key, + sign_agent_workflow_request, sign_node_request, signed_request_payload_digest, + AgentSignedRequest, AgentWorkflowScope, ArtifactFlush, ArtifactHandle, ArtifactId, Capability, + DataPlaneObject, DataPlaneScope, Digest, EnvironmentBackend, EnvironmentRequirements, + LimitKind, NodeCapabilities, NodeEndpoint, NodeSignedRequest, Os, ResourceLimits, + SourceProviderKind, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskJoinState, TaskSpec, + VfsPath, WasmExportAbi, +}; +use serde_json::json; + +use crate::FallibleDurableStore; + +use super::keys::{process_control_key, task_control_key}; +use super::*; + +fn test_admin_request( + token: &str, + operation: &str, + tenant: &str, + actor_user: &str, + target_tenant: &str, + nonce: &str, +) -> (Digest, String, u64) { + let issued_at_epoch_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + ( + admin_request_proof( + token, + operation, + tenant, + actor_user, + target_tenant, + nonce, + issued_at_epoch_seconds, + ), + nonce.to_owned(), + issued_at_epoch_seconds, + ) +} + +#[test] +fn runtime_service_uses_memory_only_when_database_url_is_absent() { + let service = CoordinatorService::new_with_database_url(1, None).unwrap(); + assert_eq!(service.durable_store_kind(), "in_memory"); + + let error = CoordinatorService::new_with_database_url(1, Some("not-a-postgres-url")) + .err() + .expect("an invalid configured DATABASE_URL must fail closed"); + assert!(error + .to_string() + .contains("durable coordinator state failed")); +} + +#[test] +fn postgres_backed_runtime_service_survives_restart_without_live_process_state() { + let Ok(database_url) = std::env::var("DISASMER_TEST_POSTGRES_SERVICE") else { + return; + }; + let mut clean_store = crate::PostgresDurableStore::connect(&database_url).unwrap(); + clean_store + .save_state(&crate::DurableState::default()) + .unwrap(); + + let session_secret = "postgres-runtime-service-session"; + let mut first = CoordinatorService::new_with_database_url(41, Some(&database_url)).unwrap(); + assert_eq!(first.durable_store_kind(), "postgres"); + first + .issue_cli_session( + TenantId::from("tenant-pg"), + ProjectId::from("project-pg"), + UserId::from("user-pg"), + session_secret, + None, + ) + .unwrap(); + first + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-pg".to_owned(), + project: "project-pg".to_owned(), + node: "node-pg".to_owned(), + public_key: test_node_public_key("node-pg"), + }) + .unwrap(); + first + .handle_request(CoordinatorRequest::Authenticated { + session_secret: session_secret.to_owned(), + request: AuthenticatedCoordinatorRequest::StartProcess { + process: "process-ephemeral".to_owned(), + restart: false, + }, + }) + .unwrap(); + drop(first); + + let mut restarted = CoordinatorService::new_with_database_url(42, Some(&database_url)).unwrap(); + assert_eq!(restarted.durable_store_kind(), "postgres"); + let auth = restarted + .handle_request(CoordinatorRequest::Authenticated { + session_secret: session_secret.to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap(); + assert!(matches!( + auth, + CoordinatorResponse::AuthStatus { + authenticated: true, + .. + } + )); + let heartbeat = restarted + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node-pg".to_owned(), + node_signature: Some(signed_node_heartbeat( + "node-pg", + "postgres-restart-heartbeat", + )), + }) + .unwrap(); + assert!(matches!( + heartbeat, + CoordinatorResponse::NodeHeartbeat { epoch: 42, .. } + )); + let CoordinatorResponse::ProcessStatuses { processes, .. } = restarted + .handle_request(CoordinatorRequest::Authenticated { + session_secret: session_secret.to_owned(), + request: AuthenticatedCoordinatorRequest::ListProcesses, + }) + .unwrap() + else { + panic!("expected process list"); + }; + assert!(processes.is_empty()); +} + +fn linux_capabilities() -> NodeCapabilities { + NodeCapabilities { + os: Os::Linux, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([ + Capability::Command, + Capability::Containers, + Capability::RootlessPodman, + Capability::VfsArtifacts, + ]), + environment_backends: BTreeSet::from([EnvironmentBackend::Container]), + source_providers: BTreeSet::from(["filesystem".to_owned()]), + } +} + +fn windows_capabilities() -> NodeCapabilities { + NodeCapabilities { + os: Os::Windows, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([ + Capability::Command, + Capability::WindowsCommandDev, + Capability::VfsArtifacts, + ]), + environment_backends: BTreeSet::from([EnvironmentBackend::WindowsCommandDev]), + source_providers: BTreeSet::from(["filesystem".to_owned()]), + } +} + +fn endpoint(name: &str) -> NodeEndpoint { + NodeEndpoint { + node: NodeId::from(name), + advertised_addr: format!("{name}.mesh.invalid:4433"), + public_key_fingerprint: Digest::sha256(format!("{name}-public-key")), + } +} + +fn data_plane_scope(project: &str) -> DataPlaneScope { + DataPlaneScope { + tenant: TenantId::from("tenant"), + project: ProjectId::from(project), + process: ProcessId::from("process"), + object: DataPlaneObject::Artifact(ArtifactId::from("artifact")), + authorization_subject: "node-a-to-node-b".to_owned(), + } +} + +fn assert_agent_workflow_actor(actor: &WorkflowActor, fingerprint: &Digest) { + assert_eq!(actor.kind, "agent"); + assert_eq!(actor.user, Some(UserId::from("user"))); + assert_eq!(actor.agent, Some(AgentId::from("agent-ci"))); + assert_eq!(actor.credential_kind, CredentialKind::PublicKey); + assert_eq!(actor.public_key_fingerprint.as_ref(), Some(fingerprint)); + assert!(actor.authenticated_without_browser); + assert!(actor.scopes.iter().any(|scope| scope == "project:run")); +} + +const TEST_AGENT_PRIVATE_KEY: &str = "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; +static TEST_NODE_REQUEST_NONCE: AtomicU64 = AtomicU64::new(1); +const TEST_WASM_MODULE: &[u8] = b"disasmer-test-wasm-module"; + +fn test_wasm_module_base64() -> String { + BASE64_STANDARD.encode(TEST_WASM_MODULE) +} + +fn append_test_custom_section(module: &mut Vec, name: &str, data: &[u8]) { + fn leb(mut value: usize, output: &mut Vec) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + output.push(byte); + if value == 0 { + break; + } + } + } + + let mut payload = Vec::new(); + leb(name.len(), &mut payload); + payload.extend_from_slice(name.as_bytes()); + payload.extend_from_slice(data); + module.push(0); + leb(payload.len(), module); + module.extend_from_slice(&payload); +} + +fn test_edited_task_bundle(compatibility: &Digest, edit_marker: &str) -> (String, Digest) { + let descriptor = serde_json::to_vec(&serde_json::json!({ + "kind": "task", + "name": "compile", + "function": "compile", + "export": "compile_export", + "stable_id": Digest::sha256("compile-stable"), + "argument_schema": "u32", + "result_schema": "u32", + "required_capabilities": [], + "restart_compatibility_hash": compatibility, + "abi_version": disasmer_core::WASM_TASK_ABI_VERSION, + "probe_symbol": "disasmer.probe.compile", + })) + .unwrap(); + let mut module = b"\0asm\x01\0\0\0".to_vec(); + append_test_custom_section(&mut module, "disasmer.tasks", &descriptor); + append_test_custom_section(&mut module, "disasmer.edit", edit_marker.as_bytes()); + let digest = Digest::sha256(&module); + (BASE64_STANDARD.encode(module), digest) +} + +fn test_task_spec( + tenant: &str, + project: &str, + process: &str, + task: &str, + epoch: u64, + required_capabilities: impl IntoIterator, +) -> TaskSpec { + test_task_spec_instance( + tenant, + project, + process, + task, + task, + epoch, + required_capabilities, + ) +} + +fn test_task_spec_instance( + tenant: &str, + project: &str, + process: &str, + task_definition: &str, + task_instance: &str, + epoch: u64, + required_capabilities: impl IntoIterator, +) -> TaskSpec { + TaskSpec { + tenant: TenantId::from(tenant), + project: ProjectId::from(project), + process: ProcessId::from(process), + task_definition: disasmer_core::TaskDefinitionId::from(task_definition), + task_instance: disasmer_core::TaskInstanceId::from(task_instance), + dispatch: TaskDispatch::CoordinatorNodeWasm { + export: Some(task_definition.to_owned()), + abi: WasmExportAbi::TaskV1, + }, + environment_id: None, + environment: None, + environment_digest: None, + required_capabilities: required_capabilities.into_iter().collect(), + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + args: Vec::new(), + vfs_epoch: epoch, + bundle_digest: Some(Digest::sha256(TEST_WASM_MODULE)), + } +} + +fn register_test_task_assignment( + service: &mut CoordinatorService, + tenant: &str, + project: &str, + process: &str, + node: &str, + task_definition: &str, + task_instance: &str, + epoch: u64, +) { + let task_spec = test_task_spec_instance( + tenant, + project, + process, + task_definition, + task_instance, + epoch, + [], + ); + let assignment = TaskAssignment { + tenant: TenantId::from(tenant), + project: ProjectId::from(project), + process: ProcessId::from(process), + task: TaskInstanceId::from(task_instance), + node: NodeId::from(node), + epoch, + artifact_path: format!("/vfs/artifacts/{task_instance}.bin"), + task_spec, + wasm_module_base64: test_wasm_module_base64(), + }; + service + .capture_task_restart_checkpoint(&assignment) + .unwrap(); + service.active_tasks.insert(super::keys::task_control_key( + &assignment.tenant, + &assignment.project, + &assignment.process, + &assignment.node, + &assignment.task, + )); +} + +fn test_agent_public_key() -> String { + agent_ed25519_public_key_from_private_key(TEST_AGENT_PRIVATE_KEY).unwrap() +} + +fn signed_agent_workflow_request( + request: &CoordinatorRequest, + request_kind: &str, + process: &str, + task: Option<&str>, + nonce: &str, +) -> AgentSignedRequest { + let task = task.map(TaskInstanceId::from); + let payload = serde_json::to_value(request).unwrap(); + let payload_digest = signed_request_payload_digest(&payload); + sign_agent_workflow_request( + TEST_AGENT_PRIVATE_KEY, + AgentWorkflowScope { + tenant: &TenantId::from("tenant"), + project: &ProjectId::from("project"), + agent: &AgentId::from("agent-ci"), + request_kind, + process: &ProcessId::from(process), + task: task.as_ref(), + }, + &payload_digest, + nonce.to_owned(), + unix_timestamp_seconds_for_tests(), + ) + .unwrap() +} + +fn with_signed_agent_workflow( + mut request: CoordinatorRequest, + request_kind: &str, + process: &str, + task: Option<&str>, + nonce: &str, +) -> CoordinatorRequest { + let signature = signed_agent_workflow_request(&request, request_kind, process, task, nonce); + match &mut request { + CoordinatorRequest::StartProcess { + agent_signature, .. + } + | CoordinatorRequest::LaunchTask { + agent_signature, .. + } => *agent_signature = Some(signature), + _ => panic!("agent signing helper only accepts agent workflow requests"), + } + request +} + +fn test_node_private_key(node: &str) -> String { + derive_ed25519_private_key_from_seed(&format!("test-node-key:{node}")) +} + +fn test_node_public_key(node: &str) -> String { + node_ed25519_public_key_from_private_key(&test_node_private_key(node)).unwrap() +} + +fn signed_node_heartbeat(node: &str, nonce: &str) -> NodeSignedRequest { + let payload = json!({"type": "node_heartbeat", "node": node}); + signed_node_request_with_private_key( + node, + &test_node_private_key(node), + "node_heartbeat", + &signed_request_payload_digest(&payload), + nonce, + ) +} + +fn signed_node_heartbeat_with_private_key( + node: &str, + private_key: &str, + nonce: &str, +) -> NodeSignedRequest { + let payload = json!({"type": "node_heartbeat", "node": node}); + signed_node_request_with_private_key( + node, + private_key, + "node_heartbeat", + &signed_request_payload_digest(&payload), + nonce, + ) +} + +fn signed_node_request_with_private_key( + node: &str, + private_key: &str, + request_kind: &str, + payload_digest: &Digest, + nonce: &str, +) -> NodeSignedRequest { + sign_node_request( + private_key, + &NodeId::from(node), + request_kind, + payload_digest, + nonce.to_owned(), + unix_timestamp_seconds_for_tests(), + ) + .unwrap() +} + +fn signed_node_request_auto(request: CoordinatorRequest) -> CoordinatorRequest { + let payload_digest = signed_request_payload_digest(&serde_json::to_value(&request).unwrap()); + let (node, request_kind) = match &request { + CoordinatorRequest::ReportNodeCapabilities { node, .. } => { + (node.clone(), "report_node_capabilities") + } + CoordinatorRequest::PollTaskAssignment { node, .. } => { + (node.clone(), "poll_task_assignment") + } + CoordinatorRequest::PollArtifactTransfer { node, .. } => { + (node.clone(), "poll_artifact_transfer") + } + CoordinatorRequest::UploadArtifactTransferChunk { node, .. } => { + (node.clone(), "upload_artifact_transfer_chunk") + } + CoordinatorRequest::FailArtifactTransfer { node, .. } => { + (node.clone(), "fail_artifact_transfer") + } + CoordinatorRequest::LaunchChildTask { node, .. } => (node.clone(), "launch_child_task"), + CoordinatorRequest::JoinChildTask { node, .. } => (node.clone(), "join_child_task"), + CoordinatorRequest::CompleteSourcePreparation { node, .. } => { + (node.clone(), "complete_source_preparation") + } + CoordinatorRequest::ReconnectNode { node, .. } => (node.clone(), "reconnect_node"), + CoordinatorRequest::PollTaskControl { node, .. } => (node.clone(), "poll_task_control"), + CoordinatorRequest::PollDebugCommand { node, .. } => (node.clone(), "poll_debug_command"), + CoordinatorRequest::ReportDebugState { node, .. } => (node.clone(), "report_debug_state"), + CoordinatorRequest::ReportDebugProbeHit { node, .. } => { + (node.clone(), "report_debug_probe_hit") + } + CoordinatorRequest::ReportTaskLog { node, .. } => (node.clone(), "report_task_log"), + CoordinatorRequest::ReportVfsMetadata { node, .. } => (node.clone(), "report_vfs_metadata"), + CoordinatorRequest::TaskCompleted { node, .. } => (node.clone(), "task_completed"), + _ => panic!("test helper only signs node-originated requests"), + }; + let nonce = TEST_NODE_REQUEST_NONCE + .fetch_add(1, Ordering::Relaxed) + .to_string(); + CoordinatorRequest::SignedNode { + node: node.clone(), + node_signature: signed_node_request_with_private_key( + &node, + &test_node_private_key(&node), + request_kind, + &payload_digest, + &format!("node-request-{nonce}"), + ), + request: Box::new(request), + } +} + +trait SignedNodeRequestTestExt { + fn handle_signed_node_request_auto( + &mut self, + request: CoordinatorRequest, + ) -> Result; +} + +impl SignedNodeRequestTestExt for CoordinatorService { + fn handle_signed_node_request_auto( + &mut self, + request: CoordinatorRequest, + ) -> Result { + self.handle_request(signed_node_request_auto(request)) + } +} + +fn upload_pending_artifact_transfer( + service: &mut CoordinatorService, + node: &str, + bytes: &[u8], +) -> ArtifactTransferAssignment { + let mut first = None; + loop { + let CoordinatorResponse::ArtifactTransferAssignment { + transfer: Some(transfer), + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollArtifactTransfer { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: node.to_owned(), + }) + .unwrap() + else { + panic!("expected pending artifact reverse transfer"); + }; + assert_eq!(transfer.expected_digest, Digest::sha256(bytes)); + assert_eq!(transfer.expected_size_bytes, bytes.len() as u64); + let start = transfer.offset as usize; + let end = start + .saturating_add(transfer.max_chunk_bytes as usize) + .min(bytes.len()); + let content = &bytes[start..end]; + let response = service + .handle_signed_node_request_auto(CoordinatorRequest::UploadArtifactTransferChunk { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: node.to_owned(), + transfer_id: transfer.transfer_id.clone(), + artifact: transfer.artifact.as_str().to_owned(), + offset: transfer.offset, + content_base64: BASE64_STANDARD.encode(content), + chunk_digest: Digest::sha256(content), + eof: end == bytes.len(), + }) + .unwrap(); + let complete = matches!( + response, + CoordinatorResponse::ArtifactTransferChunkAccepted { complete: true, .. } + ); + first.get_or_insert_with(|| transfer.clone()); + if complete { + return first.unwrap(); + } + } +} + +fn unix_timestamp_seconds_for_tests() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn write_coordinator_wire_request( + stream: &mut TcpStream, + request: &CoordinatorRequest, + request_id: &str, +) { + let payload = serde_json::to_value(request).unwrap(); + let wire_request = coordinator_wire_request(request_id, payload); + serde_json::to_writer(&mut *stream, &wire_request).unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); +} + +#[test] +fn service_creates_selects_and_lists_signed_in_user_projects() { + let mut service = CoordinatorService::new(7); + + let CoordinatorResponse::ProjectCreated { project, actor } = service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + project: "project-a".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap() + else { + panic!("expected project creation"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(project.tenant, TenantId::from("tenant-a")); + assert_eq!(project.id, ProjectId::from("project-a")); + assert_eq!(project.name, "Demo"); + + let CoordinatorResponse::ProjectSelected { project, actor } = service + .handle_request(CoordinatorRequest::SelectProject { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + project: "project-a".to_owned(), + }) + .unwrap() + else { + panic!("expected project selection"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(project.id, ProjectId::from("project-a")); + + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant-b".to_owned(), + actor_user: "user-b".to_owned(), + project: "project-b".to_owned(), + name: "Other".to_owned(), + }) + .unwrap(); + + let CoordinatorResponse::Projects { projects, actor } = service + .handle_request(CoordinatorRequest::ListProjects { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + }) + .unwrap() + else { + panic!("expected project list"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, ProjectId::from("project-a")); + + let cross_tenant = service + .handle_request(CoordinatorRequest::SelectProject { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + project: "project-b".to_owned(), + }) + .unwrap_err(); + assert!(cross_tenant.to_string().contains("tenant scope")); +} + +#[test] +fn authenticated_envelope_derives_user_scope_from_cli_session() { + let mut service = CoordinatorService::new(7); + service + .issue_cli_session( + TenantId::from("tenant-a"), + ProjectId::from("project-a"), + UserId::from("user-a"), + "cli-session-secret", + None, + ) + .unwrap(); + + let CoordinatorResponse::AuthStatus { + tenant, + project, + actor, + authenticated, + .. + } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap() + else { + panic!("expected authenticated auth status"); + }; + assert_eq!(tenant, TenantId::from("tenant-a")); + assert_eq!(project, ProjectId::from("project-a")); + assert_eq!(actor, UserId::from("user-a")); + assert!(authenticated); + + let CoordinatorResponse::ProjectCreated { project, actor } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::CreateProject { + project: "project-b".to_owned(), + name: "From Session".to_owned(), + }, + }) + .unwrap() + else { + panic!("expected authenticated project creation"); + }; + assert_eq!(project.tenant, TenantId::from("tenant-a")); + assert_eq!(project.id, ProjectId::from("project-b")); + assert_eq!(actor, UserId::from("user-a")); + + let CoordinatorResponse::Projects { projects, actor } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::ListProjects, + }) + .unwrap() + else { + panic!("expected authenticated project list"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(projects.len(), 2); + assert!(projects + .iter() + .any(|project| project.id == ProjectId::from("project-a"))); + assert!(projects + .iter() + .any(|project| project.id == ProjectId::from("project-b"))); + + let CoordinatorResponse::AgentPublicKey { record, actor } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::RegisterAgentPublicKey { + agent: "agent-session".to_owned(), + public_key: "agent-session-key-v1".to_owned(), + }, + }) + .unwrap() + else { + panic!("expected authenticated agent key registration"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(record.tenant, TenantId::from("tenant-a")); + assert_eq!(record.project, ProjectId::from("project-a")); + assert_eq!(record.user, UserId::from("user-a")); + assert_eq!(record.agent, AgentId::from("agent-session")); + + let CoordinatorResponse::AgentPublicKeys { records, actor } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::ListAgentPublicKeys, + }) + .unwrap() + else { + panic!("expected authenticated agent key list"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(records.len(), 1); + assert_eq!(records[0].project, ProjectId::from("project-a")); + + let CoordinatorResponse::AgentPublicKey { record, actor } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::RevokeAgentPublicKey { + agent: "agent-session".to_owned(), + }, + }) + .unwrap() + else { + panic!("expected authenticated agent key revocation"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert!(record.revoked); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "session-node".to_owned(), + public_key: test_node_public_key("session-node"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "session-node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: vec![], + dependency_cache_digests: vec![], + source_snapshots: vec![], + artifact_locations: vec![], + direct_connectivity: true, + online: true, + }) + .unwrap(); + let CoordinatorResponse::NodeDescriptors { descriptors, actor } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::ListNodeDescriptors, + }) + .unwrap() + else { + panic!("expected authenticated node descriptor list"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(descriptors.len(), 1); + assert_eq!(descriptors[0].id, NodeId::from("session-node")); + + let CoordinatorResponse::TaskPlacement { placement } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::ScheduleTask { + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: vec![], + prefer_node: Some("session-node".to_owned()), + }, + }) + .unwrap() + else { + panic!("expected authenticated task placement"); + }; + assert_eq!(placement.node, NodeId::from("session-node")); + + let CoordinatorResponse::ProcessStarted { process, actor, .. } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::StartProcess { + process: "vp-session".to_owned(), + restart: false, + }, + }) + .unwrap() + else { + panic!("expected authenticated process start"); + }; + assert_eq!(process, ProcessId::from("vp-session")); + assert_eq!(actor.user, Some(UserId::from("user-a"))); + + let CoordinatorResponse::TaskLaunched { + process, + task, + actor, + assignment, + .. + } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::LaunchTask { + task_spec: Box::new(test_task_spec( + "tenant-a", + "project-a", + "vp-session", + "task-session", + 7, + [Capability::Command], + )), + wait_for_node: false, + artifact_path: "/vfs/artifacts/session.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }, + }) + .unwrap() + else { + panic!("expected authenticated task launch"); + }; + assert_eq!(process, ProcessId::from("vp-session")); + assert_eq!(task, TaskInstanceId::from("task-session")); + assert_eq!(actor.user, Some(UserId::from("user-a"))); + assert_eq!(assignment.tenant, TenantId::from("tenant-a")); + assert_eq!(assignment.project, ProjectId::from("project-a")); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "session-node".to_owned(), + process: "vp-session".to_owned(), + epoch: 7, + }) + .unwrap(); + let artifact_bytes = "session artifact"; + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + process: "vp-session".to_owned(), + node: "session-node".to_owned(), + task: "task-session".to_owned(), + terminal_state: Some(TaskTerminalState::Completed), + status_code: Some(0), + stdout_bytes: artifact_bytes.len() as u64, + stderr_bytes: 0, + stdout_tail: artifact_bytes.to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/session.txt".to_owned()), + artifact_digest: Some(Digest::sha256(artifact_bytes)), + artifact_size_bytes: Some(artifact_bytes.len() as u64), + result: None, + }) + .unwrap(); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::ListTaskEvents { + process: Some("vp-session".to_owned()), + }, + }) + .unwrap() + else { + panic!("expected authenticated task event list"); + }; + assert_eq!(events.len(), 1); + assert_eq!(events[0].tenant, TenantId::from("tenant-a")); + assert_eq!(events[0].project, ProjectId::from("project-a")); + + service + .issue_cli_session( + TenantId::from("tenant-b"), + ProjectId::from("project-victim"), + UserId::from("user-b"), + "victim-cli-session-secret", + None, + ) + .unwrap(); + service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "victim-cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::StartProcess { + process: "vp-victim".to_owned(), + restart: false, + }, + }) + .unwrap(); + let cross_tenant_events = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::ListTaskEvents { + process: Some("vp-victim".to_owned()), + }, + }) + .unwrap_err(); + assert!(cross_tenant_events + .to_string() + .contains("outside the virtual process tenant/project scope")); + + let CoordinatorResponse::ArtifactDownloadLink { link } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink { + artifact: "session.txt".to_owned(), + max_bytes: 1024, + ttl_seconds: 60, + }, + }) + .unwrap() + else { + panic!("expected authenticated artifact link"); + }; + assert_eq!(link.tenant, TenantId::from("tenant-a")); + assert_eq!(link.project, ProjectId::from("project-a")); + assert_eq!(link.actor, Actor::User(UserId::from("user-a"))); + + let CoordinatorResponse::ProcessCancellationRequested { process, .. } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::CancelProcess { + process: "vp-session".to_owned(), + }, + }) + .unwrap() + else { + panic!("expected authenticated process cancellation"); + }; + assert_eq!(process, ProcessId::from("vp-session")); + + let CoordinatorResponse::DebugAttach { actor, .. } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::DebugAttach { + process: "vp-session".to_owned(), + }, + }) + .unwrap() + else { + panic!("expected authenticated debug attach"); + }; + assert_eq!(actor, UserId::from("user-a")); + + let CoordinatorResponse::NodeCredentialRevoked { + node, + tenant, + project, + actor, + descriptor_removed, + .. + } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::RevokeNodeCredential { + node: "session-node".to_owned(), + }, + }) + .unwrap() + else { + panic!("expected authenticated node credential revocation"); + }; + assert_eq!(node, NodeId::from("session-node")); + assert_eq!(tenant, TenantId::from("tenant-a")); + assert_eq!(project, ProjectId::from("project-a")); + assert_eq!(actor, UserId::from("user-a")); + assert!(descriptor_removed); + + let rejected = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "wrong-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap_err(); + assert!(rejected.to_string().contains("not recognized")); + + let expired = service + .issue_cli_session( + TenantId::from("tenant-a"), + ProjectId::from("project-b"), + UserId::from("user-a"), + "expired-cli-session-secret", + Some(1), + ) + .unwrap(); + assert_eq!(expired.expires_at_epoch_seconds, Some(1)); + let expired_rejected = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "expired-cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap_err(); + assert!(expired_rejected.to_string().contains("expired")); + + let CoordinatorResponse::CliSessionRevoked { + tenant, + project, + actor, + } = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::RevokeCliSession, + }) + .unwrap() + else { + panic!("expected CLI session revocation"); + }; + assert_eq!(tenant, TenantId::from("tenant-a")); + assert_eq!(project, ProjectId::from("project-a")); + assert_eq!(actor, UserId::from("user-a")); + + let revoked_rejected = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "cli-session-secret".to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap_err(); + assert!(revoked_rejected.to_string().contains("revoked")); +} + +#[test] +fn service_reports_and_enforces_public_admin_tenant_suspension() { + let mut service = CoordinatorService::new_with_admin_token(7, "admin-token"); + + let CoordinatorResponse::AuthStatus { + tenant, + project, + actor, + authenticated, + account_status, + suspended, + disabled, + sanitized_reason, + private_moderation_details_exposed, + signup_failure_details_exposed, + .. + } = service + .handle_request(CoordinatorRequest::AuthStatus { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected auth status"); + }; + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(project, ProjectId::from("project")); + assert_eq!(actor, UserId::from("user")); + assert!(authenticated); + assert_eq!(account_status, "active"); + assert!(!suspended); + assert!(!disabled); + assert!(sanitized_reason.is_none()); + assert!(!private_moderation_details_exposed); + assert!(!signup_failure_details_exposed); + + for (tenant, policy_name, expected_status, expected_reason) in [ + ( + "manual-tenant", + "tenant:manual_review", + "manual_review", + "account or tenant is pending hosted review", + ), + ( + "disabled-tenant", + "tenant:disabled", + "disabled", + "account or tenant is disabled by hosted policy", + ), + ( + "deleted-tenant", + "tenant:deleted", + "deleted", + "account or tenant is no longer active", + ), + ] { + service.coordinator.upsert_service_policy_record( + TenantId::from(tenant), + policy_name, + Digest::from_parts([tenant.as_bytes(), policy_name.as_bytes()]), + ); + let CoordinatorResponse::AuthStatus { + account_status, + suspended, + disabled, + deleted, + manual_review, + sanitized_reason, + next_actions, + private_moderation_details_exposed, + signup_failure_details_exposed, + .. + } = service + .handle_request(CoordinatorRequest::AuthStatus { + tenant: tenant.to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected inactive auth status"); + }; + assert_eq!(account_status, expected_status); + assert_eq!(suspended, expected_status == "suspended"); + assert_eq!(disabled, expected_status == "disabled"); + assert_eq!(deleted, expected_status == "deleted"); + assert_eq!(manual_review, expected_status == "manual_review"); + assert_eq!(sanitized_reason.as_deref(), Some(expected_reason)); + assert!(next_actions + .iter() + .any(|action| action.contains("hosted operator"))); + assert!(!private_moderation_details_exposed); + assert!(!signup_failure_details_exposed); + } + + let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request( + "admin-token", + "admin_status", + "tenant", + "admin", + "tenant", + "admin-status-1", + ); + let admin_status_request = CoordinatorRequest::AdminStatus { + tenant: "tenant".to_owned(), + actor_user: "admin".to_owned(), + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + }; + let CoordinatorResponse::AdminStatus { + tenant, + actor, + suspended, + safe_default, + } = service + .handle_request(admin_status_request.clone()) + .unwrap() + else { + panic!("expected admin status"); + }; + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(actor, UserId::from("admin")); + assert!(!suspended); + assert_eq!(safe_default, "read_only"); + let replayed_admin_status = service.handle_request(admin_status_request).unwrap_err(); + assert!(replayed_admin_status + .to_string() + .contains("nonce was already used")); + + let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request( + "admin-token", + "suspend_tenant", + "admin-tenant", + "admin", + "tenant", + "admin-suspend-1", + ); + let CoordinatorResponse::TenantSuspended { + tenant, + actor, + policy, + } = service + .handle_request(CoordinatorRequest::SuspendTenant { + tenant: "admin-tenant".to_owned(), + actor_user: "admin".to_owned(), + target_tenant: "tenant".to_owned(), + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + }) + .unwrap() + else { + panic!("expected tenant suspension"); + }; + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(actor, UserId::from("admin")); + assert_eq!(policy.name, "tenant:suspended"); + + let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request( + "admin-token", + "admin_status", + "tenant", + "admin", + "tenant", + "admin-status-2", + ); + let CoordinatorResponse::AdminStatus { suspended, .. } = service + .handle_request(CoordinatorRequest::AdminStatus { + tenant: "tenant".to_owned(), + actor_user: "admin".to_owned(), + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + }) + .unwrap() + else { + panic!("expected suspended admin status"); + }; + assert!(suspended); + + let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request( + "admin-token", + "admin_status", + "tenant", + "admin", + "tenant", + "admin-status-unconfigured", + ); + let missing_admin_credential = CoordinatorService::new(7) + .handle_request(CoordinatorRequest::AdminStatus { + tenant: "tenant".to_owned(), + actor_user: "admin".to_owned(), + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + }) + .unwrap_err(); + assert!(missing_admin_credential + .to_string() + .contains("admin credential is not configured")); + + let (admin_proof, admin_nonce, issued_at_epoch_seconds) = test_admin_request( + "wrong-token", + "suspend_tenant", + "admin-tenant", + "admin", + "other-tenant", + "admin-suspend-wrong", + ); + let invalid_admin_credential = service + .handle_request(CoordinatorRequest::SuspendTenant { + tenant: "admin-tenant".to_owned(), + actor_user: "admin".to_owned(), + target_tenant: "other-tenant".to_owned(), + admin_proof, + admin_nonce, + issued_at_epoch_seconds, + }) + .unwrap_err(); + assert!(invalid_admin_credential + .to_string() + .contains("admin request proof is invalid")); + + let CoordinatorResponse::AuthStatus { + account_status, + suspended, + disabled, + sanitized_reason, + next_actions, + private_moderation_details_exposed, + signup_failure_details_exposed, + .. + } = service + .handle_request(CoordinatorRequest::AuthStatus { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected suspended auth status"); + }; + assert_eq!(account_status, "suspended"); + assert!(suspended); + assert!(!disabled); + assert_eq!( + sanitized_reason.as_deref(), + Some("account or tenant is suspended by hosted policy") + ); + assert!(next_actions + .iter() + .any(|action| action.contains("hosted operator"))); + assert!(!private_moderation_details_exposed); + assert!(!signup_failure_details_exposed); + + let create = service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant".to_owned(), + actor_user: "user".to_owned(), + project: "project".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap_err(); + assert!(create.to_string().contains("tenant is suspended")); + + let start = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap_err(); + assert!(start.to_string().contains("tenant is suspended")); +} + +#[test] +fn service_manages_project_scoped_agent_public_keys() { + let mut service = CoordinatorService::new(7); + + let CoordinatorResponse::AgentPublicKey { record, actor } = service + .handle_request(CoordinatorRequest::RegisterAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v1".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key registration"); + }; + assert_eq!(actor, UserId::from("user")); + assert_eq!(record.tenant, TenantId::from("tenant")); + assert_eq!(record.project, ProjectId::from("project")); + assert_eq!(record.user, UserId::from("user")); + assert_eq!(record.agent, AgentId::from("agent-ci")); + assert_eq!(record.version, 1); + assert!(!record.revoked); + assert_eq!(record.scopes, vec!["project:read", "project:run"]); + assert!(!record.human_account_creation_privilege); + assert!(!record.browser_interaction_required_each_run); + + let CoordinatorResponse::AgentPublicKey { record, .. } = service + .handle_request(CoordinatorRequest::RegisterAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + public_key: "agent-key-v2".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key rotation by re-add"); + }; + assert_eq!(record.version, 2); + assert_eq!(record.public_key, "agent-key-v2"); + + let CoordinatorResponse::AgentPublicKeys { records, actor } = service + .handle_request(CoordinatorRequest::ListAgentPublicKeys { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key list"); + }; + assert_eq!(actor, UserId::from("user")); + assert_eq!(records.len(), 1); + assert_eq!(records[0].agent, AgentId::from("agent-ci")); + + let CoordinatorResponse::AgentPublicKeys { records, .. } = service + .handle_request(CoordinatorRequest::ListAgentPublicKeys { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "other-user".to_owned(), + }) + .unwrap() + else { + panic!("expected foreign user agent public key list"); + }; + assert!(records.is_empty()); + + let CoordinatorResponse::AgentPublicKeys { records, .. } = service + .handle_request(CoordinatorRequest::ListAgentPublicKeys { + tenant: "other-tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected foreign tenant agent public key list"); + }; + assert!(records.is_empty()); + + let foreign_user_revoke = service + .handle_request(CoordinatorRequest::RevokeAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "other-user".to_owned(), + agent: "agent-ci".to_owned(), + }) + .unwrap_err(); + assert!(foreign_user_revoke + .to_string() + .contains("signed-in user scope")); + + let CoordinatorResponse::AgentPublicKey { record, actor } = service + .handle_request(CoordinatorRequest::RevokeAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + }) + .unwrap() + else { + panic!("expected agent public key revocation"); + }; + assert_eq!(actor, UserId::from("user")); + assert!(record.revoked); +} + +#[test] +fn service_runs_agent_workflows_with_scoped_key_attribution() { + let mut service = CoordinatorService::new(7); + let agent_public_key = test_agent_public_key(); + let agent_fingerprint = Digest::sha256(&agent_public_key); + + service + .handle_request(CoordinatorRequest::RegisterAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + public_key: agent_public_key, + }) + .unwrap(); + + let fingerprint_only = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint.clone()), + agent_signature: None, + process: "vp-agent-fingerprint-only".to_owned(), + restart: false, + }) + .unwrap_err(); + assert!(fingerprint_only.to_string().contains("signed request")); + + let wrong_fingerprint = service + .handle_request(with_signed_agent_workflow( + CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(Digest::sha256("other-key")), + agent_signature: None, + process: "vp-agent-bad-key".to_owned(), + restart: false, + }, + "start_process", + "vp-agent-bad-key", + None, + "bad-fingerprint-nonce", + )) + .unwrap_err(); + assert!(wrong_fingerprint.to_string().contains("fingerprint")); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + public_key: test_node_public_key("worker-linux"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::ProcessStarted { + actor: start_actor, .. + } = service + .handle_request(with_signed_agent_workflow( + CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint.clone()), + agent_signature: None, + process: "vp-agent".to_owned(), + restart: false, + }, + "start_process", + "vp-agent", + None, + "start-nonce", + )) + .unwrap() + else { + panic!("expected agent-authenticated process start"); + }; + assert_agent_workflow_actor(&start_actor, &agent_fingerprint); + + let replay = service + .handle_request(with_signed_agent_workflow( + CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint.clone()), + agent_signature: None, + process: "vp-agent".to_owned(), + restart: true, + }, + "start_process", + "vp-agent", + None, + "start-nonce", + )) + .unwrap_err(); + assert!(replay.to_string().contains("nonce")); + + let CoordinatorResponse::TaskLaunched { + actor: launch_actor, + placement, + assignment, + .. + } = service + .handle_request(with_signed_agent_workflow( + CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "vp-agent", + "compile-linux", + 7, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint.clone()), + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }, + "launch_task", + "vp-agent", + Some("compile-linux"), + "launch-nonce", + )) + .unwrap() + else { + panic!("expected agent-authenticated task launch"); + }; + assert_agent_workflow_actor(&launch_actor, &agent_fingerprint); + assert_eq!(placement.node, NodeId::from("worker-linux")); + assert_eq!(assignment.node, NodeId::from("worker-linux")); +} + +#[test] +fn signed_node_and_agent_requests_reject_body_modification() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-bound-body".to_owned(), + public_key: test_node_public_key("worker-bound-body"), + }) + .unwrap(); + + let original_node_request = CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-bound-body".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }; + let original_node_digest = + signed_request_payload_digest(&serde_json::to_value(&original_node_request).unwrap()); + let node_signature = signed_node_request_with_private_key( + "worker-bound-body", + &test_node_private_key("worker-bound-body"), + "report_node_capabilities", + &original_node_digest, + "node-body-modification", + ); + let mut modified_node_request = original_node_request; + let CoordinatorRequest::ReportNodeCapabilities { online, .. } = &mut modified_node_request + else { + unreachable!(); + }; + *online = false; + let modified_node = service + .handle_request(CoordinatorRequest::SignedNode { + node: "worker-bound-body".to_owned(), + node_signature, + request: Box::new(modified_node_request), + }) + .unwrap_err(); + assert!(modified_node.to_string().contains("signature")); + + let agent_public_key = test_agent_public_key(); + let agent_fingerprint = Digest::sha256(&agent_public_key); + service + .handle_request(CoordinatorRequest::RegisterAgentPublicKey { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + agent: "agent-ci".to_owned(), + public_key: agent_public_key, + }) + .unwrap(); + let original_agent_request = CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: Some("agent-ci".to_owned()), + agent_public_key_fingerprint: Some(agent_fingerprint), + agent_signature: None, + process: "vp-agent-bound-body".to_owned(), + restart: false, + }; + let agent_signature = signed_agent_workflow_request( + &original_agent_request, + "start_process", + "vp-agent-bound-body", + None, + "agent-body-modification", + ); + let mut modified_agent_request = original_agent_request; + let CoordinatorRequest::StartProcess { + restart, + agent_signature: request_signature, + .. + } = &mut modified_agent_request + else { + unreachable!(); + }; + *restart = true; + *request_signature = Some(agent_signature); + let modified_agent = service.handle_request(modified_agent_request).unwrap_err(); + assert!(modified_agent.to_string().contains("signature")); +} + +#[test] +fn service_checks_spawn_quota_before_process_or_task_work_starts() { + let mut service = CoordinatorService::new(7); + service.quota.set_workflow_limits(ResourceLimits { + limits: BTreeMap::from([(LimitKind::Spawn, 2)]), + }); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + public_key: test_node_public_key("worker-linux"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::ProcessStarted { charged_spawns, .. } = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-quota".to_owned(), + restart: false, + }) + .unwrap() + else { + panic!("expected process start within spawn quota"); + }; + assert_eq!(charged_spawns, 1); + + let CoordinatorResponse::TaskLaunched { charged_spawns, .. } = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "vp-quota", + "compile-linux", + 7, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap() + else { + panic!("expected task launch within spawn quota"); + }; + assert_eq!(charged_spawns, 2); + + let denied_task = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "vp-quota", + "compile-linux-denied", + 7, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/denied.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap_err(); + assert!(denied_task.to_string().contains("Spawn")); + let now_epoch_seconds = service.current_epoch_seconds().unwrap(); + assert_eq!( + service.quota.used_workflow_spawns( + &TenantId::from("tenant"), + &ProjectId::from("project"), + now_epoch_seconds, + ), + 2 + ); + + let denied_task_key = task_control_key( + &TenantId::from("tenant"), + &ProjectId::from("project"), + &ProcessId::from("vp-quota"), + &NodeId::from("worker-linux"), + &TaskInstanceId::from("compile-linux-denied"), + ); + assert!(!service.active_tasks.contains(&denied_task_key)); + assert!(!service.task_placements.contains_key(&denied_task_key)); + assert_eq!( + service + .task_assignments + .get(&( + TenantId::from("tenant"), + ProjectId::from("project"), + NodeId::from("worker-linux"), + )) + .map(VecDeque::len), + Some(1) + ); + + let other_project_process = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "other-project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-denied".to_owned(), + restart: false, + }) + .unwrap(); + assert!(matches!( + other_project_process, + CoordinatorResponse::ProcessStarted { .. } + )); + assert_eq!( + service.quota.used_workflow_spawns( + &TenantId::from("tenant"), + &ProjectId::from("other-project"), + now_epoch_seconds, + ), + 1 + ); + assert_eq!( + service.quota.used_workflow_spawns( + &TenantId::from("tenant"), + &ProjectId::from("project"), + now_epoch_seconds, + ), + 2 + ); +} + +#[test] +fn project_quota_resets_at_the_configured_window_boundary() { + let mut limits = ResourceLimits::unlimited(); + limits.limits.insert(LimitKind::Spawn, 1); + let quota = CoordinatorQuotaConfiguration::new(limits, [(LimitKind::Spawn, 60)]).unwrap(); + let mut service = CoordinatorService::new_with_admin_token_database_url_and_quota( + 7, + "test-admin-token", + None, + quota, + ) + .unwrap(); + + service.set_server_time(59); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-window-one".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::AbortProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp-window-one".to_owned(), + }) + .unwrap(); + + let exhausted = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-still-window-one".to_owned(), + restart: false, + }) + .unwrap_err(); + assert!(exhausted.to_string().contains("Spawn")); + + service.set_server_time(60); + let started = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-window-two".to_owned(), + restart: false, + }) + .unwrap(); + assert!(matches!( + started, + CoordinatorResponse::ProcessStarted { + charged_spawns: 1, + .. + } + )); + assert_eq!(service.quota.active_meter_count(), 1); +} + +#[test] +fn authenticated_api_calls_are_metered_per_tenant_and_project_before_dispatch() { + let mut limits = ResourceLimits::unlimited(); + limits.limits.insert(LimitKind::ApiCall, 1); + let quota = CoordinatorQuotaConfiguration::new(limits, [(LimitKind::ApiCall, 60)]).unwrap(); + let mut service = CoordinatorService::new_with_admin_token_database_url_and_quota( + 7, + "test-admin-token", + None, + quota, + ) + .unwrap(); + service.set_server_time(30); + for (project, secret) in [("project-a", "session-a"), ("project-b", "session-b")] { + service + .issue_cli_session( + TenantId::from("tenant"), + ProjectId::from(project), + UserId::from("user"), + secret, + None, + ) + .unwrap(); + } + + service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "session-a".to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap(); + let denied = service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "session-a".to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap_err(); + assert!(denied.to_string().contains("ApiCall")); + + service + .handle_request(CoordinatorRequest::Authenticated { + session_secret: "session-b".to_owned(), + request: AuthenticatedCoordinatorRequest::AuthStatus, + }) + .unwrap(); + assert_eq!( + service + .quota + .used_api_calls(&TenantId::from("tenant"), &ProjectId::from("project-a"), 30,), + 1 + ); + assert_eq!( + service + .quota + .used_api_calls(&TenantId::from("tenant"), &ProjectId::from("project-b"), 30,), + 1 + ); +} + +#[test] +fn signed_node_log_ingestion_checks_scoped_quota_before_accepting_bytes() { + let mut limits = ResourceLimits::unlimited(); + limits.limits.insert(LimitKind::LogBytes, 4); + let quota = CoordinatorQuotaConfiguration::new(limits, [(LimitKind::LogBytes, 60)]).unwrap(); + let mut service = CoordinatorService::new_with_admin_token_database_url_and_quota( + 7, + "test-admin-token", + None, + quota, + ) + .unwrap(); + service.set_server_time(30); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportTaskLog { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "task".to_owned(), + stdout_bytes: 3, + stderr_bytes: 1, + stdout_tail: "out".to_owned(), + stderr_tail: "e".to_owned(), + stdout_truncated: false, + stderr_truncated: false, + backpressured: false, + }) + .unwrap(); + let denied = service + .handle_signed_node_request_auto(CoordinatorRequest::ReportTaskLog { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "task".to_owned(), + stdout_bytes: 1, + stderr_bytes: 0, + stdout_tail: "x".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + backpressured: true, + }) + .unwrap_err(); + assert!(denied.to_string().contains("LogBytes")); + assert_eq!( + service + .quota + .used_log_bytes(&TenantId::from("tenant"), &ProjectId::from("project"), 30,), + 4 + ); +} + +#[test] +fn service_attaches_node_starts_process_and_records_scoped_task_event() { + let mut service = CoordinatorService::new(7); + + let attached = service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + assert!(matches!(attached, CoordinatorResponse::NodeAttached { .. })); + + let started = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + assert_eq!( + started, + CoordinatorResponse::ProcessStarted { + process: ProcessId::from("process"), + epoch: 7, + actor: WorkflowActor { + kind: "user".to_owned(), + user: Some(UserId::from("user")), + agent: None, + credential_kind: CredentialKind::BrowserSession, + public_key_fingerprint: None, + authenticated_without_browser: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + }, + charged_spawns: 1, + } + ); + + let heartbeat = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + node_signature: Some(signed_node_heartbeat("node", "task-event-heartbeat")), + }) + .unwrap(); + assert_eq!( + heartbeat, + CoordinatorResponse::NodeHeartbeat { + node: NodeId::from("node"), + epoch: 7, + } + ); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + register_test_task_assignment( + &mut service, + "tenant", + "project", + "process", + "node", + "compile-linux", + "compile-linux", + 7, + ); + let recorded = service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 12, + stderr_bytes: 0, + stdout_tail: "build ok".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/app.txt".to_owned()), + artifact_digest: Some(Digest::sha256("artifact")), + artifact_size_bytes: Some(12), + result: None, + }) + .unwrap(); + + assert_eq!( + recorded, + CoordinatorResponse::TaskRecorded { + process: ProcessId::from("process"), + task: TaskInstanceId::from("compile-linux"), + events_recorded: 1 + } + ); + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert_eq!(events.len(), 1); + assert_eq!(events[0].node, NodeId::from("node")); + assert_eq!(events[0].stdout_tail, "build ok"); + assert_eq!(events[0].stderr_tail, ""); + assert!(!events[0].stdout_truncated); + + let oversized_tail = "x".repeat(MAX_TASK_LOG_TAIL_BYTES + 1); + let oversized_log = service + .handle_signed_node_request_auto(CoordinatorRequest::ReportTaskLog { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + stdout_bytes: oversized_tail.len() as u64, + stderr_bytes: 0, + stdout_tail: oversized_tail.clone(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + backpressured: false, + }) + .unwrap_err(); + assert!(oversized_log.to_string().contains("stdout_tail")); + + let oversized_completion = service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: oversized_tail.len() as u64, + stderr_bytes: 0, + stdout_tail: oversized_tail, + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: None, + }) + .unwrap_err(); + assert!(oversized_completion.to_string().contains("stdout_tail")); + + let cross_tenant = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "other".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap_err(); + assert!(cross_tenant + .to_string() + .contains("outside the virtual process tenant/project scope")); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("other-process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert!(events.is_empty()); +} + +#[test] +fn service_revokes_node_credentials_and_live_descriptors() { + let mut service = CoordinatorService::new(7); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: vec![], + dependency_cache_digests: vec![], + source_snapshots: vec![], + artifact_locations: vec![], + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::NodeCredentialRevoked { + node, + tenant, + project, + actor, + descriptor_removed, + queued_assignments_removed, + } = service + .handle_request(CoordinatorRequest::RevokeNodeCredential { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + node: "node".to_owned(), + }) + .unwrap() + else { + panic!("expected node credential revocation"); + }; + assert_eq!(node, NodeId::from("node")); + assert_eq!(tenant, TenantId::from("tenant")); + assert_eq!(project, ProjectId::from("project")); + assert_eq!(actor, UserId::from("user")); + assert!(descriptor_removed); + assert_eq!(queued_assignments_removed, 0); + + let heartbeat = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + node_signature: Some(signed_node_heartbeat("node", "revoked-heartbeat")), + }) + .unwrap_err(); + assert!(heartbeat.to_string().contains("not enrolled")); + + let CoordinatorResponse::NodeDescriptors { descriptors, .. } = service + .handle_request(CoordinatorRequest::ListNodeDescriptors { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected node descriptors"); + }; + assert!(descriptors.is_empty()); +} + +#[test] +fn service_delivers_cancellation_to_connected_node_and_records_terminal_state() { + let mut service = CoordinatorService::new(7); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + register_test_task_assignment( + &mut service, + "tenant", + "project", + "process", + "node", + "compile-linux", + "compile-linux", + 7, + ); + + let cancelled = service + .handle_request(CoordinatorRequest::CancelTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + cancelled, + CoordinatorResponse::TaskCancellationRequested { + process: ProcessId::from("process"), + task: TaskInstanceId::from("compile-linux"), + node: NodeId::from("node"), + } + ); + + let control = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + control, + CoordinatorResponse::TaskControl { + process: ProcessId::from("process"), + task: TaskInstanceId::from("compile-linux"), + cancel_requested: true, + abort_requested: false, + } + ); + + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: Some(TaskTerminalState::Cancelled), + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: None, + }) + .unwrap(); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert_eq!(events[0].terminal_state, TaskTerminalState::Cancelled); + + let control = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + control, + CoordinatorResponse::TaskControl { + process: ProcessId::from("process"), + task: TaskInstanceId::from("compile-linux"), + cancel_requested: false, + abort_requested: false, + } + ); +} + +#[test] +fn service_authorizes_debug_attach_through_public_api() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant".to_owned(), + actor_user: "user".to_owned(), + project: "project".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + + let CoordinatorResponse::DebugAttach { + process, + actor, + authorization, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, + } = service + .handle_request(CoordinatorRequest::DebugAttach { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected debug attach authorization"); + }; + assert_eq!(process, ProcessId::from("process")); + assert_eq!(actor, UserId::from("user")); + assert!(authorization.allowed); + assert_eq!(audit_event.operation, "debug_attach"); + assert_eq!(audit_event.actor, UserId::from("user")); + assert!(audit_event.allowed); + assert_eq!(charged_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + + let CoordinatorResponse::DebugAttach { + authorization, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, + .. + } = service + .handle_request(CoordinatorRequest::DebugAttach { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "other-user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected denied debug attach authorization"); + }; + assert!(!authorization.allowed); + assert!(authorization.reason.contains("explicit project permission")); + assert!(!audit_event.allowed); + assert_eq!(audit_event.charged_debug_read_bytes, 0); + assert_eq!(charged_debug_read_bytes, 0); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + + let CoordinatorResponse::DebugAttach { + authorization, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, + .. + } = service + .handle_request(CoordinatorRequest::DebugAttach { + tenant: "other-tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected cross-tenant debug attach denial"); + }; + assert!(!authorization.allowed); + assert!(authorization.reason.contains("tenant or project")); + assert!(!audit_event.allowed); + assert_eq!(charged_debug_read_bytes, 0); + assert_eq!(used_debug_read_bytes, 0); + assert_eq!(service.debug_audit_events.len(), 3); +} + +#[test] +fn debug_epoch_commands_are_polled_by_signed_active_task_nodes() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant".to_owned(), + actor_user: "user".to_owned(), + project: "project".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + public_key: test_node_public_key("worker"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: vec![], + dependency_cache_digests: vec![], + source_snapshots: vec![], + artifact_locations: vec![], + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "process", + "compile-linux", + 7, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/out.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap(); + service.main_runtime.controls.insert( + process_control_key( + &TenantId::from("tenant"), + &ProjectId::from("project"), + &ProcessId::from("process"), + ), + super::main_runtime::CoordinatorMainControl { + task_definition: disasmer_core::TaskDefinitionId::from("completed-main"), + task_instance: TaskInstanceId::from("ti:process:main"), + abort: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + debug: std::sync::Arc::new(disasmer_wasm_runtime::WasmDebugControl::default()), + state: "completed".to_owned(), + stopped_probe_symbol: None, + handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + launch_id: 1, + }, + ); + let CoordinatorResponse::DebugBreakpoints { + probe_symbols, + hit_epoch, + .. + } = service + .handle_request(CoordinatorRequest::SetDebugBreakpoints { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + probe_symbols: vec!["disasmer.probe.compile_linux".to_owned()], + }) + .unwrap() + else { + panic!("expected debug breakpoints response"); + }; + assert_eq!(probe_symbols, ["disasmer.probe.compile_linux"]); + assert_eq!(hit_epoch, None); + + let CoordinatorResponse::DebugProbeHit { + breakpoint_matched, + debug_epoch, + probe_symbol, + .. + } = service + .handle_signed_node_request_auto(CoordinatorRequest::ReportDebugProbeHit { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "worker".to_owned(), + task: "compile-linux".to_owned(), + probe_symbol: "disasmer.probe.compile_linux".to_owned(), + }) + .unwrap() + else { + panic!("expected executing Wasm probe hit response"); + }; + assert!(breakpoint_matched); + assert_eq!(debug_epoch, Some(1)); + assert_eq!(probe_symbol, "disasmer.probe.compile_linux"); + + let CoordinatorResponse::DebugBreakpoints { + hit_epoch, + hit_task, + hit_probe_symbol, + .. + } = service + .handle_request(CoordinatorRequest::InspectDebugBreakpoints { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected breakpoint hit status"); + }; + assert_eq!(hit_epoch, Some(1)); + assert_eq!(hit_task, Some(TaskInstanceId::from("compile-linux"))); + assert_eq!( + hit_probe_symbol.as_deref(), + Some("disasmer.probe.compile_linux") + ); + + let CoordinatorResponse::DebugCommand { + epoch: Some(1), + command: Some(command), + .. + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollDebugCommand { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "worker".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected signed node poll to receive freeze command"); + }; + assert_eq!(command, "freeze"); + + let CoordinatorResponse::DebugCommand { + epoch: None, + command: None, + .. + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollDebugCommand { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "worker".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap() + else { + panic!("debug commands should be consumed after poll"); + }; + + let CoordinatorResponse::DebugEpochStatus { + fully_frozen, + fully_resumed, + acknowledgements, + .. + } = service + .handle_request(CoordinatorRequest::InspectDebugEpoch { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + epoch: 1, + }) + .unwrap() + else { + panic!("expected pending debug epoch status"); + }; + assert!(!fully_frozen); + assert!(!fully_resumed); + assert!(acknowledgements.is_empty()); + let early_resume = service + .handle_request(CoordinatorRequest::ResumeDebugEpoch { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + epoch: 1, + }) + .unwrap_err(); + assert!(early_resume + .to_string() + .contains("have not acknowledged frozen state")); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportDebugState { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "worker".to_owned(), + task: "compile-linux".to_owned(), + epoch: 1, + state: DebugAcknowledgementState::Frozen, + stack_frames: vec!["compile_linux::wasm".to_owned()], + local_values: vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())], + task_args: vec![("target".to_owned(), "linux".to_owned())], + handles: vec![("artifact".to_owned(), "pending".to_owned())], + command_status: Some("frozen at Wasm safepoint".to_owned()), + recent_output: vec![], + message: None, + }) + .unwrap(); + let CoordinatorResponse::DebugEpochStatus { + fully_frozen, + fully_resumed, + acknowledgements, + .. + } = service + .handle_request(CoordinatorRequest::InspectDebugEpoch { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + epoch: 1, + }) + .unwrap() + else { + panic!("expected frozen debug epoch status"); + }; + assert!(fully_frozen); + assert!(!fully_resumed); + assert_eq!(acknowledgements.len(), 1); + assert_eq!(acknowledgements[0].state, DebugAcknowledgementState::Frozen); + + let CoordinatorResponse::DebugEpoch { + epoch, + command, + affected_tasks, + all_stop_requested, + audit_event, + .. + } = service + .handle_request(CoordinatorRequest::ResumeDebugEpoch { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + epoch: 1, + }) + .unwrap() + else { + panic!("expected debug epoch resume response"); + }; + assert_eq!(epoch, 1); + assert_eq!(command, "resume"); + assert!(!all_stop_requested); + assert_eq!(affected_tasks.len(), 1); + assert_eq!(audit_event.operation, "resume_debug_epoch"); + + let CoordinatorResponse::DebugCommand { + epoch: Some(1), + command: Some(command), + .. + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollDebugCommand { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "worker".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected signed node poll to receive resume command"); + }; + assert_eq!(command, "resume"); + + let CoordinatorResponse::DebugEpochStatus { fully_resumed, .. } = service + .handle_request(CoordinatorRequest::InspectDebugEpoch { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + epoch: 1, + }) + .unwrap() + else { + panic!("expected pending resume status"); + }; + assert!(!fully_resumed); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportDebugState { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "worker".to_owned(), + task: "compile-linux".to_owned(), + epoch: 1, + state: DebugAcknowledgementState::Running, + stack_frames: vec![], + local_values: vec![], + task_args: vec![], + handles: vec![], + command_status: Some("running".to_owned()), + recent_output: vec![], + message: None, + }) + .unwrap(); + let CoordinatorResponse::DebugEpochStatus { + fully_frozen, + fully_resumed, + .. + } = service + .handle_request(CoordinatorRequest::InspectDebugEpoch { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + epoch: 1, + }) + .unwrap() + else { + panic!("expected resumed debug epoch status"); + }; + assert!(!fully_frozen); + assert!(fully_resumed); +} + +#[test] +fn service_reports_task_restart_boundary_through_public_api() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant".to_owned(), + actor_user: "user".to_owned(), + project: "project".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + + let denied = service + .handle_request(CoordinatorRequest::RestartTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "other-user".to_owned(), + process: "process".to_owned(), + task: "task".to_owned(), + replacement_bundle: None, + }) + .unwrap_err(); + assert!(denied.to_string().contains("task restart denied")); + + let CoordinatorResponse::TaskRestart { + accepted, + clean_boundary_available, + active_task, + completed_event_observed, + requires_whole_process_restart, + restarted_task_instance, + message, + audit_event, + charged_debug_read_bytes, + used_debug_read_bytes, + .. + } = service + .handle_request(CoordinatorRequest::RestartTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + task: "task".to_owned(), + replacement_bundle: None, + }) + .unwrap() + else { + panic!("expected task restart response"); + }; + assert!(!accepted); + assert!(!clean_boundary_available); + assert!(!active_task); + assert!(!completed_event_observed); + assert!(restarted_task_instance.is_none()); + assert!(requires_whole_process_restart); + assert!(message.contains("not known")); + assert_eq!(audit_event.operation, "restart_task"); + assert_eq!(audit_event.task, Some(TaskInstanceId::from("task"))); + assert!(audit_event.allowed); + assert_eq!(charged_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + public_key: test_node_public_key("worker-linux"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "process", + "task", + 7, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/output.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap(); + let CoordinatorResponse::TaskAssignment { + assignment: Some(initial_assignment), + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected initial task assignment"); + }; + assert_eq!(initial_assignment.task, TaskInstanceId::from("task")); + + let CoordinatorResponse::TaskRestart { + accepted, + clean_boundary_available, + active_task, + completed_event_observed, + requires_whole_process_restart, + message, + audit_event, + used_debug_read_bytes, + .. + } = service + .handle_request(CoordinatorRequest::RestartTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + task: "task".to_owned(), + replacement_bundle: None, + }) + .unwrap() + else { + panic!("expected active task restart response"); + }; + assert!(!accepted); + assert!(!clean_boundary_available); + assert!(active_task); + assert!(!completed_event_observed); + assert!(requires_whole_process_restart); + assert!(message.contains("still active")); + assert_eq!( + audit_event.charged_debug_read_bytes, + DEBUG_CONTROL_READ_BYTES + ); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES * 2); + + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "worker-linux".to_owned(), + task: "task".to_owned(), + terminal_state: Some(TaskTerminalState::Completed), + status_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: None, + }) + .unwrap(); + + let CoordinatorResponse::TaskRestart { + accepted, + clean_boundary_available, + active_task, + completed_event_observed, + requires_whole_process_restart, + restarted_task_instance, + message, + audit_event, + used_debug_read_bytes, + .. + } = service + .handle_request(CoordinatorRequest::RestartTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + task: "task".to_owned(), + replacement_bundle: None, + }) + .unwrap() + else { + panic!("expected completed task restart response"); + }; + assert!(accepted); + assert!(clean_boundary_available); + assert!(!active_task); + assert!(completed_event_observed); + assert!(!requires_whole_process_restart); + let restarted_task_instance = restarted_task_instance.expect("restart must return new id"); + assert!(message.contains("from clean VFS entry boundary epoch 7")); + let CoordinatorResponse::TaskAssignment { + assignment: Some(restarted_assignment), + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected restarted task assignment"); + }; + assert_eq!(restarted_assignment.task, restarted_task_instance); + assert_ne!(restarted_assignment.task, TaskInstanceId::from("task")); + let mut expected_task_spec = initial_assignment.task_spec.clone(); + expected_task_spec.task_instance = restarted_task_instance; + assert_eq!(restarted_assignment.task_spec, expected_task_spec); + assert_eq!( + restarted_assignment.wasm_module_base64, + initial_assignment.wasm_module_base64 + ); + assert_eq!( + audit_event.charged_debug_read_bytes, + DEBUG_CONTROL_READ_BYTES + ); + assert_eq!(used_debug_read_bytes, DEBUG_CONTROL_READ_BYTES * 3); + assert_eq!(service.debug_audit_events.len(), 4); +} + +#[test] +fn service_cancels_whole_process_and_blocks_new_task_launches() { + let mut service = CoordinatorService::new(7); + for node in ["node-a", "node-b"] { + let capabilities = if node == "node-a" { + linux_capabilities() + } else { + windows_capabilities() + }; + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: node.to_owned(), + public_key: test_node_public_key(node), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: node.to_owned(), + capabilities, + cached_environment_digests: vec![], + dependency_cache_digests: vec![], + source_snapshots: vec![], + artifact_locations: vec![], + direct_connectivity: true, + online: true, + }) + .unwrap(); + } + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + for node in ["node-a", "node-b"] { + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: node.to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + } + + for (task, required_capability, preferred_node) in [ + ("compile-linux", Capability::Containers, "node-a"), + ("link-windows", Capability::WindowsCommandDev, "node-b"), + ] { + let response = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "process", + task, + 7, + [required_capability], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: format!("/vfs/artifacts/{task}.txt"), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap(); + let CoordinatorResponse::TaskLaunched { assignment, .. } = response else { + panic!("expected task launch"); + }; + assert_eq!(assignment.node, NodeId::from(preferred_node)); + } + + let CoordinatorResponse::ProcessCancellationRequested { + process, + cancelled_tasks, + affected_nodes, + } = service + .handle_request(CoordinatorRequest::CancelProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected process cancellation"); + }; + assert_eq!(process, ProcessId::from("process")); + assert_eq!(cancelled_tasks.len(), 2); + assert_eq!( + affected_nodes, + vec![NodeId::from("node-a"), NodeId::from("node-b")] + ); + + let control = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node-a".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + control, + CoordinatorResponse::TaskControl { + process: ProcessId::from("process"), + task: TaskInstanceId::from("compile-linux"), + cancel_requested: true, + abort_requested: false, + } + ); + + let blocked = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "process", + "package-linux", + 7, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/package-linux.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap_err(); + assert!(blocked + .to_string() + .contains("virtual process is cancelling")); + + service + .handle_request(CoordinatorRequest::AbortProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap(); + let abort_control = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node-a".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + abort_control, + CoordinatorResponse::TaskControl { + process: ProcessId::from("process"), + task: TaskInstanceId::from("compile-linux"), + cancel_requested: false, + abort_requested: true, + } + ); +} + +#[test] +fn service_rejects_second_active_process_unless_restarting_same_process() { + let mut service = CoordinatorService::new(7); + let started = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process-a".to_owned(), + restart: false, + }) + .unwrap(); + assert!(matches!( + started, + CoordinatorResponse::ProcessStarted { .. } + )); + + let same_without_restart = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process-a".to_owned(), + restart: false, + }) + .unwrap_err(); + assert!(same_without_restart + .to_string() + .contains("already has active virtual process")); + + let other_process = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process-b".to_owned(), + restart: true, + }) + .unwrap_err(); + assert!(other_process + .to_string() + .contains("already has active virtual process")); + + let retired_main_abort = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let process_key = process_control_key( + &TenantId::from("tenant"), + &ProjectId::from("project"), + &ProcessId::from("process-a"), + ); + service.main_runtime.controls.insert( + process_key.clone(), + super::main_runtime::CoordinatorMainControl { + task_definition: disasmer_core::TaskDefinitionId::from("build"), + task_instance: TaskInstanceId::from("ti:process-a:main"), + abort: std::sync::Arc::clone(&retired_main_abort), + debug: std::sync::Arc::new(disasmer_wasm_runtime::WasmDebugControl::default()), + state: "running".to_owned(), + stopped_probe_symbol: None, + handles: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + launch_id: 1, + }, + ); + let restarted = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process-a".to_owned(), + restart: true, + }) + .unwrap(); + assert_eq!( + restarted, + CoordinatorResponse::ProcessStarted { + process: ProcessId::from("process-a"), + epoch: 7, + actor: WorkflowActor { + kind: "user".to_owned(), + user: Some(UserId::from("user")), + agent: None, + credential_kind: CredentialKind::BrowserSession, + public_key_fingerprint: None, + authenticated_without_browser: false, + scopes: vec!["project:read".to_owned(), "project:run".to_owned()], + }, + charged_spawns: 2, + } + ); + assert!(retired_main_abort.load(std::sync::atomic::Ordering::Acquire)); + assert!(!service.main_runtime.controls.contains_key(&process_key)); +} + +#[test] +fn cooperative_cancel_keeps_process_identity_until_explicit_abort_releases_slot() { + let mut service = CoordinatorService::new(17); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process-a".to_owned(), + restart: false, + }) + .unwrap(); + service.record_task_completion_event(TaskCompletionEvent { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process-a"), + node: NodeId::from("node"), + executor: TaskExecutor::Node, + task_definition: disasmer_core::TaskDefinitionId::from("old-task"), + task: TaskInstanceId::from("ti:process-a:old"), + placement: None, + terminal_state: TaskTerminalState::Completed, + status_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: Some(TaskBoundaryValue::SmallJson(json!("old"))), + }); + service + .record_debug_audit_event( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process-a"), + None, + UserId::from("user"), + "old_debug_event", + false, + "old process incarnation", + ) + .unwrap(); + + service + .handle_request(CoordinatorRequest::CancelProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process-a".to_owned(), + }) + .unwrap(); + + let CoordinatorResponse::ProcessStatuses { processes, .. } = service + .handle_request(CoordinatorRequest::ListProcesses { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected live process statuses"); + }; + assert_eq!(processes.len(), 1); + assert_eq!(processes[0].process, ProcessId::from("process-a")); + assert_eq!(processes[0].state, "cancelling"); + + let blocked = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process-b".to_owned(), + restart: false, + }) + .unwrap_err(); + assert!(blocked + .to_string() + .contains("already has active virtual process process-a")); + + let CoordinatorResponse::ProcessAborted { process, .. } = service + .handle_request(CoordinatorRequest::AbortProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process-a".to_owned(), + }) + .unwrap() + else { + panic!("expected forced process abort"); + }; + assert_eq!(process, ProcessId::from("process-a")); + + let CoordinatorResponse::ProcessStatuses { processes, .. } = service + .handle_request(CoordinatorRequest::ListProcesses { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + }) + .unwrap() + else { + panic!("expected live process statuses"); + }; + assert!(processes.is_empty()); + + let started = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process-a".to_owned(), + restart: false, + }) + .unwrap(); + assert!(matches!( + started, + CoordinatorResponse::ProcessStarted { .. } + )); + assert!(service.task_events.iter().all(|event| { + event.tenant != TenantId::from("tenant") + || event.project != ProjectId::from("project") + || event.process != ProcessId::from("process-a") + })); + assert!(service.debug_audit_events.iter().all(|event| { + event.tenant != TenantId::from("tenant") + || event.project != ProjectId::from("project") + || event.process != ProcessId::from("process-a") + })); +} + +#[test] +fn aborted_process_accepts_signed_terminal_event_for_issued_task() { + let mut service = CoordinatorService::new(17); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 17, + }) + .unwrap(); + register_test_task_assignment( + &mut service, + "tenant", + "project", + "process", + "node", + "compile", + "compile-1", + 17, + ); + + let CoordinatorResponse::ProcessAborted { aborted_tasks, .. } = service + .handle_request(CoordinatorRequest::AbortProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "process".to_owned(), + }) + .unwrap() + else { + panic!("expected process abort"); + }; + assert_eq!(aborted_tasks.len(), 1); + + let recorded = service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-1".to_owned(), + terminal_state: Some(TaskTerminalState::Cancelled), + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: None, + }) + .unwrap(); + assert_eq!( + recorded, + CoordinatorResponse::TaskRecorded { + process: ProcessId::from("process"), + task: TaskInstanceId::from("compile-1"), + events_recorded: 1, + } + ); + assert_eq!( + service.task_events[0].task_definition, + disasmer_core::TaskDefinitionId::from("compile") + ); + assert!(service.task_restart_checkpoints.is_empty()); +} + +#[test] +fn service_download_links_are_scoped_and_streaming_is_metered() { + let mut service = CoordinatorService::new(7); + let artifact_bytes = b"0123456789abcdef0123456789abcdef"; + service.set_server_time(10); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: false, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + register_test_task_assignment( + &mut service, + "tenant", + "project", + "process", + "node", + "compile-linux", + "compile-linux", + 7, + ); + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 32, + stderr_bytes: 0, + stdout_tail: "artifact bytes".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/app.txt".to_owned()), + artifact_digest: Some(Digest::sha256(artifact_bytes)), + artifact_size_bytes: Some(artifact_bytes.len() as u64), + result: None, + }) + .unwrap(); + + service.quota.set_download_limits(ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 31)]), + }); + let quota_denied_link = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(quota_denied_link + .to_string() + .contains("ArtifactDownloadBytes")); + service.quota.set_download_limits(ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 64)]), + }); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: vec!["app.txt".to_owned()], + direct_connectivity: false, + online: true, + }) + .unwrap(); + let private_node_link = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + ttl_seconds: 60, + }) + .unwrap(); + assert!(matches!( + private_node_link, + CoordinatorResponse::ArtifactDownloadLink { .. } + )); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: vec!["app.txt".to_owned()], + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::ArtifactDownloadLink { link } = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + ttl_seconds: 60, + }) + .unwrap() + else { + panic!("expected download link"); + }; + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: vec!["app.txt".to_owned()], + direct_connectivity: false, + online: true, + }) + .unwrap(); + let private_node_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 1, + }) + .unwrap(); + assert!(matches!( + private_node_open, + CoordinatorResponse::ArtifactDownloadStream { + content_bytes_available: false, + .. + } + )); + let CoordinatorResponse::ArtifactTransferAssignment { + transfer: Some(pending_transfer), + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollArtifactTransfer { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + }) + .unwrap() + else { + panic!("expected reverse transfer assignment"); + }; + let bad_chunk = service + .handle_signed_node_request_auto(CoordinatorRequest::UploadArtifactTransferChunk { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + transfer_id: pending_transfer.transfer_id, + artifact: pending_transfer.artifact.as_str().to_owned(), + offset: 0, + content_base64: BASE64_STANDARD.encode(artifact_bytes), + chunk_digest: Digest::sha256("wrong bytes"), + eof: true, + }) + .unwrap_err(); + assert!(bad_chunk.to_string().contains("chunk digest mismatch")); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: vec!["app.txt".to_owned()], + direct_connectivity: true, + online: true, + }) + .unwrap(); + + assert_eq!(link.tenant, TenantId::from("tenant")); + assert_eq!(link.project, ProjectId::from("project")); + assert_eq!(link.process, ProcessId::from("process")); + assert_eq!(link.actor, Actor::User(UserId::from("user"))); + assert_eq!(link.max_bytes, 64); + assert!(link.policy_context_digest.is_valid_sha256()); + assert_eq!(link.expires_at_epoch_seconds, 70); + assert!(link.url_path.contains("/artifacts/tenant/project/process")); + + let cross_tenant = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "other".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(cross_tenant.to_string().contains("tenant mismatch")); + + let cross_project = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "other-project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(cross_project.to_string().contains("project mismatch")); + + let cross_tenant_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "other".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_tenant_open.to_string().contains("tenant mismatch")); + + let cross_project_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "other-project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_project_open.to_string().contains("project mismatch")); + + let guessed = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: Digest::sha256("guessed"), + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(guessed.to_string().contains("token is invalid")); + + let cross_actor_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "other-user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_actor_open.to_string().contains("token is invalid")); + + service.set_server_time(71); + let expired = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(expired.to_string().contains("expired")); + + service.set_server_time(11); + upload_pending_artifact_transfer(&mut service, "node", artifact_bytes); + let CoordinatorResponse::ArtifactDownloadStream { + streamed_bytes, + charged_download_bytes, + content_base64: Some(content_base64), + content_source: Some(content_source), + .. + } = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 16, + }) + .unwrap() + else { + panic!("expected download stream"); + }; + + assert_eq!(streamed_bytes, 16); + assert_eq!(charged_download_bytes, 16); + assert_eq!( + BASE64_STANDARD.decode(content_base64).unwrap(), + &artifact_bytes[..16] + ); + assert_eq!(content_source, "retaining_node_reverse_stream"); + + let cross_actor_revoke = service + .handle_request(CoordinatorRequest::RevokeArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "other-user".to_owned(), + artifact: "app.txt".to_owned(), + token_digest: link.scoped_token_digest.clone(), + }) + .unwrap_err(); + assert!(cross_actor_revoke.to_string().contains("token is invalid")); + + let CoordinatorResponse::ArtifactDownloadLinkRevoked { link: revoked } = service + .handle_request(CoordinatorRequest::RevokeArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + token_digest: link.scoped_token_digest.clone(), + }) + .unwrap() + else { + panic!("expected revoked download link"); + }; + assert_eq!(revoked.scoped_token_digest, link.scoped_token_digest); + + let revoked = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_digest: link.scoped_token_digest, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(revoked.to_string().contains("revoked")); +} + +#[test] +fn download_quota_cannot_be_reset_by_reconnect_retry_or_scope_switch() { + let mut service = CoordinatorService::new(7); + let artifact_bytes = b"0123456789abcdef"; + service.quota.set_download_limits(ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 16)]), + }); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + register_test_task_assignment( + &mut service, + "tenant", + "project", + "process", + "node", + "compile-linux", + "compile-linux", + 7, + ); + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 16, + stderr_bytes: 0, + stdout_tail: "artifact bytes".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/app.txt".to_owned()), + artifact_digest: Some(Digest::sha256(artifact_bytes)), + artifact_size_bytes: Some(artifact_bytes.len() as u64), + result: None, + }) + .unwrap(); + + let CoordinatorResponse::ArtifactDownloadLink { link } = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + ttl_seconds: 60, + }) + .unwrap() + else { + panic!("expected initial download link"); + }; + + let pending = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 16, + }) + .unwrap(); + assert!(matches!( + pending, + CoordinatorResponse::ArtifactDownloadStream { + content_bytes_available: false, + .. + } + )); + upload_pending_artifact_transfer(&mut service, "node", artifact_bytes); + let CoordinatorResponse::ArtifactDownloadStream { + charged_download_bytes, + .. + } = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 16, + }) + .unwrap() + else { + panic!("expected initial download stream"); + }; + assert_eq!(charged_download_bytes, 16); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + let after_reconnect = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(after_reconnect + .to_string() + .contains("ArtifactDownloadBytes")); + + let restarted_cli_retry = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(restarted_cli_retry + .to_string() + .contains("ArtifactDownloadBytes")); + + let cross_tenant_retry = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "other-tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_tenant_retry.to_string().contains("tenant mismatch")); + + let cross_project_retry = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "other-project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_digest: link.scoped_token_digest, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_project_retry.to_string().contains("project mismatch")); +} + +#[test] +fn retained_artifact_reverse_stream_is_chunked_below_control_frame_limit() { + let mut service = CoordinatorService::new(7); + let artifact_bytes = vec![0x5a; 600_000]; + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: false, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + register_test_task_assignment( + &mut service, + "tenant", + "project", + "process", + "node", + "package", + "package", + 7, + ); + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "package".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/large.bin".to_owned()), + artifact_digest: Some(Digest::sha256(&artifact_bytes)), + artifact_size_bytes: Some(artifact_bytes.len() as u64), + result: Some(TaskBoundaryValue::Artifact(ArtifactHandle { + id: ArtifactId::from("large.bin"), + digest: Digest::sha256(&artifact_bytes), + size_bytes: artifact_bytes.len() as u64, + })), + }) + .unwrap(); + let CoordinatorResponse::ArtifactDownloadLink { link } = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "large.bin".to_owned(), + max_bytes: artifact_bytes.len() as u64, + ttl_seconds: 60, + }) + .unwrap() + else { + panic!("expected download link"); + }; + let pending = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "large.bin".to_owned(), + max_bytes: artifact_bytes.len() as u64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: artifact_bytes.len() as u64, + }) + .unwrap(); + assert!(matches!( + pending, + CoordinatorResponse::ArtifactDownloadStream { + content_bytes_available: false, + .. + } + )); + upload_pending_artifact_transfer(&mut service, "node", &artifact_bytes); + + let mut downloaded = Vec::new(); + loop { + let CoordinatorResponse::ArtifactDownloadStream { + streamed_bytes, + content_offset: Some(offset), + content_eof, + content_base64: Some(content), + .. + } = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "large.bin".to_owned(), + max_bytes: artifact_bytes.len() as u64, + token_digest: link.scoped_token_digest.clone(), + chunk_bytes: artifact_bytes.len() as u64, + }) + .unwrap() + else { + panic!("expected verified reverse-stream content chunk"); + }; + assert_eq!(offset, downloaded.len() as u64); + assert!(streamed_bytes <= super::artifacts::MAX_ARTIFACT_REVERSE_CHUNK_BYTES); + downloaded.extend(BASE64_STANDARD.decode(content).unwrap()); + if content_eof { + break; + } + } + assert_eq!(downloaded, artifact_bytes); + assert_eq!( + service.quota.used_download_bytes( + &TenantId::from("tenant"), + &ProjectId::from("project"), + service.current_epoch_seconds().unwrap(), + ), + 600_000 + ); +} + +#[test] +fn windows_task_events_share_the_virtual_process_scope() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "windows-node".to_owned(), + public_key: test_node_public_key("windows-node"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "windows-node".to_owned(), + capabilities: windows_capabilities(), + cached_environment_digests: vec![Digest::sha256("env-windows-command-dev")], + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + register_test_task_assignment( + &mut service, + "tenant", + "project", + "process", + "windows-node", + "windows-command-dev", + "windows-command-dev", + 7, + ); + + let recorded = service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "windows-node".to_owned(), + task: "windows-command-dev".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 24, + stderr_bytes: 0, + stdout_tail: "windows output".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/windows-output.txt".to_owned()), + artifact_digest: Some(Digest::sha256("windows-artifact")), + artifact_size_bytes: Some(24), + result: None, + }) + .unwrap(); + assert_eq!( + recorded, + CoordinatorResponse::TaskRecorded { + process: ProcessId::from("process"), + task: TaskInstanceId::from("windows-command-dev"), + events_recorded: 1, + } + ); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert_eq!(events.len(), 1); + assert_eq!(events[0].process, ProcessId::from("process")); + assert_eq!(events[0].node, NodeId::from("windows-node")); + assert_eq!(events[0].task, TaskInstanceId::from("windows-command-dev")); + assert_eq!( + events[0].artifact_path, + Some(VfsPath::new("/vfs/artifacts/windows-output.txt").unwrap()) + ); +} + +#[test] +fn service_schedules_task_across_reported_node_descriptors() { + let mut service = CoordinatorService::new(7); + for node in ["cold-node", "warm-node"] { + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: node.to_owned(), + public_key: test_node_public_key(node), + }) + .unwrap(); + } + + let recorded = service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "cold-node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: false, + online: true, + }) + .unwrap(); + assert_eq!( + recorded, + CoordinatorResponse::NodeCapabilitiesRecorded { + node: NodeId::from("cold-node"), + node_descriptors: 1, + } + ); + + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "warm-node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: vec![Digest::sha256("env")], + dependency_cache_digests: vec![Digest::sha256("deps")], + source_snapshots: vec![Digest::sha256("source")], + artifact_locations: vec!["build-output".to_owned()], + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::NodeDescriptors { descriptors, actor } = service + .handle_request(CoordinatorRequest::ListNodeDescriptors { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "operator".to_owned(), + }) + .unwrap() + else { + panic!("expected node descriptor inspector state"); + }; + assert_eq!(actor, UserId::from("operator")); + assert_eq!(descriptors.len(), 2); + let warm = descriptors + .iter() + .find(|descriptor| descriptor.id == NodeId::from("warm-node")) + .expect("warm node descriptor is visible to inspector state"); + assert!(warm + .capabilities + .capabilities + .contains(&Capability::Command)); + assert!(warm.cached_environments.contains(&Digest::sha256("env"))); + assert!(warm.dependency_caches.contains(&Digest::sha256("deps"))); + assert!(warm.source_snapshots.contains(&Digest::sha256("source"))); + assert!(warm + .artifact_locations + .contains(&ArtifactId::from("build-output"))); + + let CoordinatorResponse::TaskPlacement { placement } = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: Some(EnvironmentRequirements::linux_container()), + environment_digest: Some(Digest::sha256("env")), + required_capabilities: vec![Capability::Command], + dependency_cache: Some(Digest::sha256("deps")), + source_snapshot: Some(Digest::sha256("source")), + required_artifacts: vec!["build-output".to_owned()], + prefer_node: None, + }) + .unwrap() + else { + panic!("expected task placement"); + }; + assert_eq!(placement.node, NodeId::from("warm-node")); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("environment"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("source"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("dependency"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("artifact"))); + + let error = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::WindowsCommandDev], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + prefer_node: None, + }) + .unwrap_err(); + assert!(error.to_string().contains("WindowsCommandDev")); + + service.quota.set_workflow_limits(ResourceLimits { + limits: BTreeMap::from([(LimitKind::Spawn, 0)]), + }); + let quota_error = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + prefer_node: None, + }) + .unwrap_err(); + assert!(quota_error + .to_string() + .contains("quota unavailable for placement")); + + service.quota.set_workflow_limits(ResourceLimits { + limits: BTreeMap::from([(LimitKind::Spawn, 10)]), + }); + service.admission.workflow_placement_allowed = false; + let policy_error = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + prefer_node: None, + }) + .unwrap_err(); + assert!(policy_error.to_string().contains("policy denied placement")); +} + +#[test] +fn coordinator_side_task_launch_queues_worker_assignment() { + let mut service = CoordinatorService::new(9); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + public_key: test_node_public_key("worker-linux"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: vec![Digest::sha256("env")], + dependency_cache_digests: vec![Digest::sha256("deps")], + source_snapshots: vec![Digest::sha256("source")], + artifact_locations: vec!["bootstrap-artifact".to_owned()], + direct_connectivity: true, + online: true, + }) + .unwrap(); + let CoordinatorResponse::ProcessStarted { epoch, .. } = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-control".to_owned(), + restart: false, + }) + .unwrap() + else { + panic!("expected coordinator-side process start"); + }; + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "worker-linux".to_owned(), + process: "vp-control".to_owned(), + epoch, + }) + .unwrap(); + service.artifact_registry.flush_metadata(ArtifactFlush { + id: ArtifactId::from("bootstrap-artifact"), + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("vp-control"), + producer_task: TaskInstanceId::from("bootstrap"), + retaining_node: NodeId::from("worker-linux"), + digest: Digest::sha256("bootstrap"), + size: 9, + }); + + let submitted_task_spec = TaskSpec { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("vp-control"), + task_definition: disasmer_core::TaskDefinitionId::from("compile-linux"), + task_instance: disasmer_core::TaskInstanceId::from("compile-linux-1"), + dispatch: TaskDispatch::CoordinatorNodeWasm { + export: Some("compile-linux".to_owned()), + abi: WasmExportAbi::TaskV1, + }, + environment_id: Some("linux".to_owned()), + environment: None, + environment_digest: Some(Digest::sha256("env")), + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: Some(Digest::sha256("deps")), + source_snapshot: Some(Digest::sha256("source")), + required_artifacts: vec![ArtifactId::from("bootstrap-artifact")], + args: vec![TaskBoundaryValue::SmallJson(serde_json::json!("test"))], + vfs_epoch: epoch, + bundle_digest: Some(Digest::sha256(TEST_WASM_MODULE)), + }; + + let CoordinatorResponse::TaskLaunched { + process, + task, + placement, + assignment, + .. + } = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: submitted_task_spec.clone(), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap() + else { + panic!("expected launched task"); + }; + assert_eq!(process, ProcessId::from("vp-control")); + assert_eq!(task, TaskInstanceId::from("compile-linux-1")); + assert_eq!(placement.node, NodeId::from("worker-linux")); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("environment"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("source"))); + assert_eq!(assignment.node, NodeId::from("worker-linux")); + assert_eq!(assignment.epoch, epoch); + assert_eq!( + assignment.task_spec.bundle_digest, + Some(Digest::sha256(TEST_WASM_MODULE)) + ); + assert!(assignment.task_spec.product_mode_uses_remote_dispatch()); + assert_eq!(assignment.task_spec, submitted_task_spec); + assert_eq!( + assignment.task_spec.environment_id.as_deref(), + Some("linux") + ); + assert!(matches!( + assignment.task_spec.dispatch, + TaskDispatch::CoordinatorNodeWasm { + export: Some(ref export), + abi: WasmExportAbi::TaskV1, + } if export == "compile-linux" + )); + assert_eq!(assignment.task_spec.vfs_epoch, epoch); + assert_eq!( + assignment.task_spec.args, + vec![TaskBoundaryValue::SmallJson(serde_json::json!("test"))] + ); + + let CoordinatorResponse::TaskAssignment { assignment } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected worker assignment poll"); + }; + let assignment = assignment.expect("worker should receive queued assignment"); + assert_eq!(assignment.process, ProcessId::from("vp-control")); + assert_eq!(assignment.task, TaskInstanceId::from("compile-linux-1")); + assert!(assignment.task_spec.product_mode_uses_remote_dispatch()); + assert_eq!( + assignment.task_spec.bundle_digest, + Some(Digest::sha256(TEST_WASM_MODULE)) + ); + assert_eq!(assignment.wasm_module_base64, test_wasm_module_base64()); + + let CoordinatorResponse::TaskJoined { join } = service + .handle_request(CoordinatorRequest::JoinTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp-control".to_owned(), + task: "compile-linux-1".to_owned(), + }) + .unwrap() + else { + panic!("expected pending join result"); + }; + assert_eq!(join.state, TaskJoinState::Pending); + assert!(!join.remote_completion_observed); + assert!(join.message.contains("waiting for signed node")); + + let CoordinatorResponse::TaskAssignment { assignment } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected empty worker assignment poll"); + }; + assert!(assignment.is_none()); + + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp-control".to_owned(), + node: "worker-linux".to_owned(), + task: "compile-linux-1".to_owned(), + terminal_state: Some(TaskTerminalState::Completed), + status_code: Some(0), + stdout_bytes: 12, + stderr_bytes: 0, + stdout_tail: "ok".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/dap-output.txt".to_owned()), + artifact_digest: Some(Digest::sha256("artifact")), + artifact_size_bytes: Some(12), + result: Some(TaskBoundaryValue::Artifact(ArtifactHandle { + id: ArtifactId::from("dap-output.txt"), + digest: Digest::sha256("artifact"), + size_bytes: 12, + })), + }) + .unwrap(); + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("vp-control".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + let event_placement = events[0] + .placement + .as_ref() + .expect("task event should retain launch placement explanation"); + assert_eq!(event_placement.node, NodeId::from("worker-linux")); + assert_eq!(event_placement.reasons, placement.reasons); + assert_eq!( + events[0].result, + Some(TaskBoundaryValue::Artifact(ArtifactHandle { + id: ArtifactId::from("dap-output.txt"), + digest: Digest::sha256("artifact"), + size_bytes: 12, + })) + ); + + let CoordinatorResponse::TaskJoined { join } = service + .handle_request(CoordinatorRequest::JoinTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp-control".to_owned(), + task: "compile-linux-1".to_owned(), + }) + .unwrap() + else { + panic!("expected completed join result"); + }; + assert_eq!(join.state, TaskJoinState::Completed); + assert!(join.remote_completion_observed); + assert_eq!( + join.result, + Some(TaskBoundaryValue::Artifact(ArtifactHandle { + id: ArtifactId::from("dap-output.txt"), + digest: Digest::sha256("artifact"), + size_bytes: 12, + })) + ); + + register_test_task_assignment( + &mut service, + "tenant", + "project", + "vp-control", + "worker-linux", + "wasm-add", + "wasm-add", + epoch, + ); + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp-control".to_owned(), + node: "worker-linux".to_owned(), + task: "wasm-add".to_owned(), + terminal_state: Some(TaskTerminalState::Completed), + status_code: Some(0), + stdout_bytes: 3, + stderr_bytes: 0, + stdout_tail: "42\n".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: Some(TaskBoundaryValue::SmallJson(serde_json::json!(42))), + }) + .unwrap(); + let CoordinatorResponse::TaskJoined { join } = service + .handle_request(CoordinatorRequest::JoinTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp-control".to_owned(), + task: "wasm-add".to_owned(), + }) + .unwrap() + else { + panic!("expected serialized join result"); + }; + assert_eq!( + join.result, + Some(TaskBoundaryValue::SmallJson(serde_json::json!(42))) + ); +} + +#[test] +fn same_definition_instances_join_correctly_when_they_complete_in_reverse_order() { + let mut service = CoordinatorService::new(41); + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant".to_owned(), + actor_user: "user".to_owned(), + project: "project".to_owned(), + name: "Duplicate instances".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + public_key: test_node_public_key("worker"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "worker".to_owned(), + process: "vp".to_owned(), + epoch: 41, + }) + .unwrap(); + + let restart_compatibility = Digest::sha256("compile-u32-to-u32-abi-v1"); + let (initial_module, initial_bundle_digest) = + test_edited_task_bundle(&restart_compatibility, "initial-body"); + for (instance, argument) in [("compile-1", 1), ("compile-2", 2)] { + let mut spec = + test_task_spec_instance("tenant", "project", "vp", "compile", instance, 41, []); + spec.args = vec![TaskBoundaryValue::SmallJson(serde_json::json!(argument))]; + spec.dispatch = TaskDispatch::CoordinatorNodeWasm { + export: Some("compile_export".to_owned()), + abi: WasmExportAbi::TaskV1, + }; + spec.bundle_digest = Some(initial_bundle_digest.clone()); + let CoordinatorResponse::TaskLaunched { + task, assignment, .. + } = service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + task_spec: spec, + wait_for_node: false, + artifact_path: format!("/vfs/artifacts/{instance}.json"), + wasm_module_base64: initial_module.clone(), + }) + .unwrap() + else { + panic!("expected launched task instance"); + }; + assert_eq!(task, TaskInstanceId::from(instance)); + assert_eq!( + assignment.task_spec.task_definition, + disasmer_core::TaskDefinitionId::from("compile") + ); + assert_eq!( + assignment.task_spec.task_instance, + disasmer_core::TaskInstanceId::from(instance) + ); + } + for instance in ["compile-1", "compile-2"] { + let CoordinatorResponse::TaskAssignment { + assignment: Some(assignment), + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + }) + .unwrap() + else { + panic!("expected queued task assignment"); + }; + assert_eq!(assignment.task, TaskInstanceId::from(instance)); + } + + service + .handle_request(CoordinatorRequest::CancelTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp".to_owned(), + node: "worker".to_owned(), + task: "compile-1".to_owned(), + }) + .unwrap(); + for (instance, expected_cancelled) in [("compile-1", true), ("compile-2", false)] { + let CoordinatorResponse::TaskControl { + cancel_requested, + abort_requested, + .. + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskControl { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp".to_owned(), + node: "worker".to_owned(), + task: instance.to_owned(), + }) + .unwrap() + else { + panic!("expected task control response"); + }; + assert_eq!(cancel_requested, expected_cancelled); + assert!(!abort_requested); + } + + for (instance, result) in [("compile-2", 22), ("compile-1", 11)] { + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp".to_owned(), + node: "worker".to_owned(), + task: instance.to_owned(), + terminal_state: Some(TaskTerminalState::Completed), + status_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: Some(TaskBoundaryValue::SmallJson(serde_json::json!(result))), + }) + .unwrap(); + } + + for (instance, expected) in [("compile-1", 11), ("compile-2", 22)] { + let CoordinatorResponse::TaskJoined { join } = service + .handle_request(CoordinatorRequest::JoinTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp".to_owned(), + task: instance.to_owned(), + }) + .unwrap() + else { + panic!("expected task join"); + }; + assert_eq!( + join.task_instance, + disasmer_core::TaskInstanceId::from(instance) + ); + assert_eq!( + join.result, + Some(TaskBoundaryValue::SmallJson(serde_json::json!(expected))) + ); + } + + let duplicate = service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + task_spec: test_task_spec_instance( + "tenant", + "project", + "vp", + "compile", + "compile-1", + 41, + [], + ), + wait_for_node: false, + artifact_path: "/vfs/artifacts/duplicate.json".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap_err(); + assert!(duplicate.to_string().contains("fresh task-instance id")); + + let (replacement_module, replacement_bundle_digest) = + test_edited_task_bundle(&restart_compatibility, "edited-body"); + assert_ne!(replacement_bundle_digest, initial_bundle_digest); + let CoordinatorResponse::TaskRestart { + accepted, + restarted_task_instance, + .. + } = service + .handle_request(CoordinatorRequest::RestartTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp".to_owned(), + task: "compile-1".to_owned(), + replacement_bundle: Some(TaskReplacementBundle { + bundle_digest: replacement_bundle_digest.clone(), + wasm_module_base64: replacement_module.clone(), + }), + }) + .unwrap() + else { + panic!("expected task restart response"); + }; + assert!(accepted); + let restarted = restarted_task_instance.expect("restart creates a new instance"); + assert_ne!(restarted, TaskInstanceId::from("compile-1")); + assert_ne!(restarted, TaskInstanceId::from("compile-2")); + let CoordinatorResponse::TaskAssignment { + assignment: Some(restarted_assignment), + } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + }) + .unwrap() + else { + panic!("expected restarted assignment"); + }; + assert_eq!(restarted_assignment.task, restarted); + assert_eq!( + restarted_assignment.task_spec.task_definition, + disasmer_core::TaskDefinitionId::from("compile") + ); + assert_eq!( + restarted_assignment.task_spec.args, + vec![TaskBoundaryValue::SmallJson(serde_json::json!(1))] + ); + assert_eq!( + restarted_assignment.task_spec.bundle_digest, + Some(replacement_bundle_digest) + ); + assert_eq!( + restarted_assignment.task_spec.dispatch, + TaskDispatch::CoordinatorNodeWasm { + export: Some("compile_export".to_owned()), + abi: WasmExportAbi::TaskV1, + } + ); + assert_eq!(restarted_assignment.wasm_module_base64, replacement_module); + + let incompatible_compatibility = Digest::sha256("compile-changed-signature"); + let (incompatible_module, incompatible_bundle_digest) = + test_edited_task_bundle(&incompatible_compatibility, "incompatible-body"); + let CoordinatorResponse::TaskRestart { + accepted, + requires_whole_process_restart, + restarted_task_instance, + message, + .. + } = service + .handle_request(CoordinatorRequest::RestartTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp".to_owned(), + task: "compile-2".to_owned(), + replacement_bundle: Some(TaskReplacementBundle { + bundle_digest: incompatible_bundle_digest, + wasm_module_base64: incompatible_module, + }), + }) + .unwrap() + else { + panic!("expected incompatible task restart response"); + }; + assert!(!accepted); + assert!(requires_whole_process_restart); + assert!(restarted_task_instance.is_none()); + assert!(message.contains("task ABI changed")); + + let CoordinatorResponse::TaskJoined { join } = service + .handle_request(CoordinatorRequest::JoinTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp".to_owned(), + task: "compile-2".to_owned(), + }) + .unwrap() + else { + panic!("expected unaffected sibling join"); + }; + assert_eq!(join.state, TaskJoinState::Completed); + assert_eq!( + join.result, + Some(TaskBoundaryValue::SmallJson(serde_json::json!(22))) + ); +} + +#[test] +fn long_lived_process_state_reaches_a_scoped_bounded_steady_state() { + let mut service = CoordinatorService::new(73); + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant-soak".to_owned(), + actor_user: "user-soak".to_owned(), + project: "project-soak".to_owned(), + name: "Bounded soak".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-soak".to_owned(), + project: "project-soak".to_owned(), + node: "worker-soak".to_owned(), + public_key: test_node_public_key("worker-soak"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant-soak".to_owned(), + project: "project-soak".to_owned(), + node: "worker-soak".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant-soak".to_owned(), + project: "project-soak".to_owned(), + actor_user: Some("user-soak".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-soak".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "worker-soak".to_owned(), + process: "vp-soak".to_owned(), + epoch: 73, + }) + .unwrap(); + + let event_waves = 4; + for index in 0..super::MAX_TASK_EVENTS_PER_PROCESS * event_waves { + let task = format!("soak-task-{index}"); + register_test_task_assignment( + &mut service, + "tenant-soak", + "project-soak", + "vp-soak", + "worker-soak", + "soak-task", + &task, + 73, + ); + service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant-soak".to_owned(), + project: "project-soak".to_owned(), + process: "vp-soak".to_owned(), + node: "worker-soak".to_owned(), + task, + terminal_state: Some(TaskTerminalState::Completed), + status_code: Some(0), + stdout_bytes: 8, + stderr_bytes: 0, + stdout_tail: "soak-log".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: Some(TaskBoundaryValue::SmallJson(json!(index))), + }) + .unwrap(); + } + for index in 0..super::MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS * 3 { + service + .record_debug_audit_event( + TenantId::from("tenant-soak"), + ProjectId::from("project-soak"), + ProcessId::from("vp-soak"), + None, + UserId::from("user-soak"), + "soak_inspect", + true, + format!("bounded audit {index}"), + ) + .unwrap(); + } + for _ in 0..128 { + service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant-soak".to_owned(), + project: "project-soak".to_owned(), + node: "worker-soak".to_owned(), + }) + .unwrap(); + } + + for index in 0..2_000 { + service + .node_replay_nonces + .insert((NodeId::from("worker-soak"), format!("expired-{index}")), 0); + } + service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "worker-soak".to_owned(), + node_signature: Some(signed_node_heartbeat("worker-soak", "post-expiry-prune")), + }) + .unwrap(); + + for index in 0..3 { + service.record_task_completion_event(TaskCompletionEvent { + tenant: TenantId::from("tenant-other"), + project: ProjectId::from("project-other"), + process: ProcessId::from("vp-other"), + node: NodeId::from("worker-other"), + executor: TaskExecutor::Node, + task_definition: disasmer_core::TaskDefinitionId::from("other"), + task: TaskInstanceId::new(format!("other-{index}")), + placement: None, + terminal_state: TaskTerminalState::Completed, + status_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + result: None, + }); + } + + let retained_soak_events = service + .task_events + .iter() + .filter(|event| event.process == ProcessId::from("vp-soak")) + .count(); + let retained_other_events = service + .task_events + .iter() + .filter(|event| event.process == ProcessId::from("vp-other")) + .count(); + let retained_soak_audit = service + .debug_audit_events + .iter() + .filter(|event| event.process == ProcessId::from("vp-soak")) + .count(); + let retained_soak_checkpoints = service + .task_restart_checkpoints + .keys() + .filter(|(_, _, process, _)| process == &ProcessId::from("vp-soak")) + .count(); + let retained_soak_nonces = service + .node_replay_nonces + .keys() + .filter(|(node, _)| node == &NodeId::from("worker-soak")) + .count(); + + assert_eq!(retained_soak_events, super::MAX_TASK_EVENTS_PER_PROCESS); + assert_eq!(retained_other_events, 3); + assert_eq!( + retained_soak_audit, + super::MAX_DEBUG_AUDIT_EVENTS_PER_PROCESS + ); + assert_eq!( + retained_soak_checkpoints, + super::MAX_RESTART_CHECKPOINTS_PER_PROCESS + ); + assert!(retained_soak_nonces <= super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY); + assert!( + super::MAX_NODE_REPLAY_NONCES_PER_AUTHORITY + >= super::NODE_SIGNATURE_WINDOW_SECONDS as usize * 2 * 1_000 + / disasmer_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS as usize + + 64, + "the bounded node replay window must sustain artifact and assignment polls at the protocol minimum with control-message headroom" + ); + assert!(service + .coordinator + .active_process( + &TenantId::from("tenant-soak"), + &ProjectId::from("project-soak"), + &ProcessId::from("vp-soak"), + ) + .is_some()); +} + +#[test] +fn signed_active_wasm_task_can_spawn_and_join_child_in_its_process_only() { + let mut service = CoordinatorService::new(12); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + public_key: test_node_public_key("worker"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp".to_owned(), + restart: false, + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReconnectNode { + node: "worker".to_owned(), + process: "vp".to_owned(), + epoch: 12, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + task_spec: test_task_spec("tenant", "project", "vp", "parent", 12, []), + wait_for_node: false, + artifact_path: "/vfs/artifacts/parent.json".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap(); + + let child_request = CoordinatorRequest::LaunchChildTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp".to_owned(), + node: "worker".to_owned(), + parent_task: "parent".to_owned(), + task_spec: test_task_spec("tenant", "project", "vp", "child", 12, []), + wait_for_node: false, + artifact_path: "/vfs/artifacts/child.json".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }; + let unsigned = service.handle_request(child_request.clone()).unwrap_err(); + assert!(unsigned.to_string().contains("signed_node")); + + let CoordinatorResponse::TaskLaunched { actor, task, .. } = service + .handle_signed_node_request_auto(child_request) + .unwrap() + else { + panic!("expected signed child launch"); + }; + assert_eq!(task, TaskInstanceId::from("child")); + assert_eq!(actor.kind, "task"); + assert_eq!(actor.credential_kind, CredentialKind::TaskCredential); + assert!(actor.scopes.contains(&"process:spawn-child".to_owned())); + + let wrong_parent = service + .handle_signed_node_request_auto(CoordinatorRequest::JoinChildTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp".to_owned(), + node: "worker".to_owned(), + parent_task: "not-active".to_owned(), + task: "child".to_owned(), + }) + .unwrap_err(); + assert!(wrong_parent.to_string().contains("active parent")); + + let CoordinatorResponse::TaskJoined { join } = service + .handle_signed_node_request_auto(CoordinatorRequest::JoinChildTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp".to_owned(), + node: "worker".to_owned(), + parent_task: "parent".to_owned(), + task: "child".to_owned(), + }) + .unwrap() + else { + panic!("expected signed child join"); + }; + assert_eq!(join.state, TaskJoinState::Pending); +} + +#[test] +fn coordinator_side_task_launch_fails_cleanly_without_capable_worker() { + let mut service = CoordinatorService::new(10); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-control".to_owned(), + restart: false, + }) + .unwrap(); + + let error = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "vp-control", + "compile-linux", + 10, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: false, + artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("no capable node")); +} + +#[test] +fn coordinator_side_task_launch_can_wait_for_capable_worker() { + let mut service = CoordinatorService::new(11); + let CoordinatorResponse::ProcessStarted { epoch, .. } = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-wait".to_owned(), + restart: false, + }) + .unwrap() + else { + panic!("expected process start"); + }; + + let CoordinatorResponse::TaskQueued { + process, + task, + reason, + queued_tasks, + charged_spawns, + .. + } = service + .handle_request(CoordinatorRequest::LaunchTask { + task_spec: test_task_spec( + "tenant", + "project", + "vp-wait", + "compile-linux", + epoch, + [Capability::Command], + ), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: Some("user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + wait_for_node: true, + artifact_path: "/vfs/artifacts/wait-output.txt".to_owned(), + wasm_module_base64: test_wasm_module_base64(), + }) + .unwrap() + else { + panic!("expected queued task launch"); + }; + assert_eq!(process, ProcessId::from("vp-wait")); + assert_eq!(task, TaskInstanceId::from("compile-linux")); + assert!(reason.contains("waiting for any capable node")); + assert_eq!(queued_tasks, 1); + assert_eq!(charged_spawns, 2); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "late-worker".to_owned(), + public_key: test_node_public_key("late-worker"), + }) + .unwrap(); + service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "late-worker".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::TaskAssignment { assignment } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "late-worker".to_owned(), + }) + .unwrap() + else { + panic!("expected pending assignment poll"); + }; + let assignment = assignment.expect("late worker should receive pending assignment"); + assert_eq!(assignment.process, ProcessId::from("vp-wait")); + assert_eq!(assignment.task, TaskInstanceId::from("compile-linux")); + assert_eq!(assignment.node, NodeId::from("late-worker")); + assert_eq!(assignment.epoch, epoch); + assert!(assignment.task_spec.product_mode_uses_remote_dispatch()); + assert_eq!(assignment.wasm_module_base64, test_wasm_module_base64()); + + let CoordinatorResponse::TaskAssignment { assignment } = service + .handle_signed_node_request_auto(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "late-worker".to_owned(), + }) + .unwrap() + else { + panic!("expected empty assignment poll"); + }; + assert!(assignment.is_none()); +} + +#[test] +fn service_rejects_malformed_node_capability_report() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + + let mut capabilities = linux_capabilities(); + capabilities + .source_providers + .insert("../checkout".to_owned()); + let error = service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities, + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap_err(); + + assert!(error.to_string().contains("source provider id")); + assert!(service.node_descriptors.is_empty()); +} + +#[test] +fn service_meters_rendezvous_before_direct_transfer_plan() { + let mut service = CoordinatorService::new(7); + service.quota.set_rendezvous_limits(ResourceLimits { + limits: BTreeMap::from([(LimitKind::RendezvousAttempt, 2)]), + }); + + let CoordinatorResponse::RendezvousPlan { + plan, + charged_rendezvous_attempts, + } = service + .handle_request(CoordinatorRequest::RequestRendezvous { + scope: data_plane_scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + direct_connectivity: true, + failure_reason: String::new(), + }) + .unwrap() + else { + panic!("expected rendezvous plan"); + }; + + assert_eq!(charged_rendezvous_attempts, 1); + assert_eq!(plan.scope.project, ProjectId::from("project")); + assert_eq!(plan.source.node, NodeId::from("node-a")); + assert_eq!(plan.destination.node, NodeId::from("node-b")); + assert!(plan.coordinator_assisted_rendezvous); + assert!(!plan.coordinator_bulk_relay_allowed); + + let unavailable = service + .handle_request(CoordinatorRequest::RequestRendezvous { + scope: data_plane_scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + direct_connectivity: false, + failure_reason: "nat traversal failed".to_owned(), + }) + .unwrap_err(); + let unavailable = unavailable.to_string(); + assert!(unavailable.contains("nat traversal failed")); + assert!(unavailable.contains("coordinator bulk relay is disabled")); + + let quota = service + .handle_request(CoordinatorRequest::RequestRendezvous { + scope: data_plane_scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + direct_connectivity: true, + failure_reason: String::new(), + }) + .unwrap_err(); + assert!(quota.to_string().contains("RendezvousAttempt")); +} + +#[test] +fn service_rejects_task_completion_outside_node_scope() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + actor_user: None, + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "process".to_owned(), + restart: false, + }) + .unwrap(); + + let error = service + .handle_signed_node_request_auto(CoordinatorRequest::TaskCompleted { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 128, + stderr_bytes: 64, + stdout_tail: "foreign stdout".to_owned(), + stderr_tail: "foreign stderr".to_owned(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/foreign.txt".to_owned()), + artifact_digest: Some(Digest::sha256("foreign-artifact")), + artifact_size_bytes: Some(128), + result: None, + }) + .unwrap_err(); + + assert!(error.to_string().contains("outside")); + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert!(events.is_empty()); +} + +#[test] +fn service_rejects_node_capability_report_outside_enrollment_scope() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + + let error = service + .handle_signed_node_request_auto(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap_err(); + + assert!(error.to_string().contains("tenant/project scope")); + assert!(service.node_descriptors.is_empty()); +} + +#[test] +fn service_rejects_source_preparation_completion_outside_node_scope() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + + let error = service + .handle_signed_node_request_auto(CoordinatorRequest::CompleteSourcePreparation { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + node: "node".to_owned(), + provider: SourceProviderKind::Filesystem, + source_snapshot: Digest::sha256("foreign-source"), + }) + .unwrap_err(); + + assert!(error.to_string().contains("tenant/project scope")); +} + +#[test] +fn service_rejects_unknown_node_heartbeat() { + let mut service = CoordinatorService::new(1); + + let error = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "missing".to_owned(), + node_signature: None, + }) + .unwrap_err(); + + assert!(error.to_string().contains("not enrolled")); +} + +#[test] +fn service_requires_signed_node_heartbeat_from_enrolled_key() { + let mut service = CoordinatorService::new(5); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + + let unsigned = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + node_signature: None, + }) + .unwrap_err(); + assert!(unsigned.to_string().contains("signed proof")); + + let wrong_private_key = test_node_private_key("other-node"); + let wrong_signature = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + node_signature: Some(signed_node_heartbeat_with_private_key( + "node", + &wrong_private_key, + "wrong-node-key", + )), + }) + .unwrap_err(); + assert!(wrong_signature.to_string().contains("signature")); + + let signed = signed_node_heartbeat("node", "fresh-node-heartbeat"); + let heartbeat = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + node_signature: Some(signed.clone()), + }) + .unwrap(); + assert_eq!( + heartbeat, + CoordinatorResponse::NodeHeartbeat { + node: NodeId::from("node"), + epoch: 5, + } + ); + + let replay = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + node_signature: Some(signed), + }) + .unwrap_err(); + assert!(replay.to_string().contains("nonce")); +} + +#[test] +fn service_rejects_raw_node_originated_requests_without_signed_envelope() { + let mut service = CoordinatorService::new(5); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }) + .unwrap(); + + let error = service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap_err(); + + assert!(error.to_string().contains("signed_node envelope proof")); +} + +#[test] +fn service_stream_accepts_multiple_requests_on_one_connection() { + let (listener, addr) = bind_listener("127.0.0.1:0").unwrap(); + let server = std::thread::spawn(move || { + let mut service = CoordinatorService::new(3); + let (stream, _) = listener.accept().unwrap(); + service.handle_stream_local_trusted(stream).unwrap(); + }); + + let mut stream = TcpStream::connect(addr).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + + write_coordinator_wire_request( + &mut stream, + &CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: test_node_public_key("node"), + }, + "stream-1", + ); + + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(matches!( + serde_json::from_str::(&line).unwrap(), + CoordinatorResponse::NodeAttached { .. } + )); + + write_coordinator_wire_request( + &mut stream, + &CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + node_signature: Some(signed_node_heartbeat("node", "stream-heartbeat")), + }, + "stream-2", + ); + + line.clear(); + reader.read_line(&mut line).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + CoordinatorResponse::NodeHeartbeat { + node: NodeId::from("node"), + epoch: 3, + } + ); + + stream.shutdown(std::net::Shutdown::Both).unwrap(); + server.join().unwrap(); +} + +#[test] +fn strict_service_stream_rejects_body_authority_and_accepts_cli_session() { + let (listener, addr) = bind_listener("127.0.0.1:0").unwrap(); + let server = std::thread::spawn(move || { + let mut service = CoordinatorService::new(3); + service + .issue_cli_session( + TenantId::from("tenant"), + ProjectId::from("project"), + UserId::from("user"), + "strict-stream-session", + None, + ) + .unwrap(); + let (stream, _) = listener.accept().unwrap(); + service.handle_stream(stream).unwrap(); + }); + + let mut stream = TcpStream::connect(addr).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + write_coordinator_wire_request( + &mut stream, + &CoordinatorRequest::StartProcess { + tenant: "victim-tenant".to_owned(), + project: "victim-project".to_owned(), + actor_user: Some("forged-user".to_owned()), + actor_agent: None, + agent_public_key_fingerprint: None, + agent_signature: None, + process: "vp-forged".to_owned(), + restart: false, + }, + "strict-stream-forged", + ); + + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let CoordinatorResponse::Error { message } = + serde_json::from_str::(&line).unwrap() + else { + panic!("expected strict body-authority denial"); + }; + assert!(message.contains("request-body identity fields are not authority")); + + write_coordinator_wire_request( + &mut stream, + &CoordinatorRequest::Authenticated { + session_secret: "strict-stream-session".to_owned(), + request: AuthenticatedCoordinatorRequest::StartProcess { + process: "vp-authenticated".to_owned(), + restart: false, + }, + }, + "strict-stream-authenticated", + ); + + line.clear(); + reader.read_line(&mut line).unwrap(); + let CoordinatorResponse::ProcessStarted { process, actor, .. } = + serde_json::from_str::(&line).unwrap() + else { + panic!("expected authenticated strict process start"); + }; + assert_eq!(process, ProcessId::from("vp-authenticated")); + assert_eq!(actor.user, Some(UserId::from("user"))); + + stream.shutdown(std::net::Shutdown::Both).unwrap(); + server.join().unwrap(); +} + +#[test] +fn service_stream_rejects_invalid_versioned_envelope_metadata() { + let (listener, addr) = bind_listener("127.0.0.1:0").unwrap(); + let server = std::thread::spawn(move || { + let mut service = CoordinatorService::new(3); + let (stream, _) = listener.accept().unwrap(); + service.handle_stream(stream).unwrap(); + }); + + let mut stream = TcpStream::connect(addr).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut wire_request = coordinator_wire_request( + "bad-operation", + serde_json::to_value(CoordinatorRequest::Ping).unwrap(), + ); + wire_request["operation"] = serde_json::Value::String("attach_node".to_owned()); + serde_json::to_writer(&mut stream, &wire_request).unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let response = serde_json::from_str::(&line).unwrap(); + let CoordinatorResponse::Error { message } = response else { + panic!("expected invalid wire envelope response"); + }; + assert!(message.contains("operation attach_node does not match payload operation ping")); + + stream.shutdown(std::net::Shutdown::Both).unwrap(); + server.join().unwrap(); +} + +#[test] +fn coordinator_protocol_rejects_client_supplied_authority_fields() { + for payload in [ + json!({ + "type": "create_node_enrollment_grant", + "tenant": "tenant", + "project": "project", + "actor_user": "user", + "grant": "attacker-chosen", + "ttl_seconds": 60, + }), + json!({ + "type": "exchange_node_enrollment_grant", + "tenant": "tenant", + "project": "project", + "node": "node", + "public_key": "key", + "enrollment_grant": "grant", + "now_epoch_seconds": 0, + }), + json!({ + "type": "schedule_task", + "tenant": "tenant", + "project": "project", + "environment": null, + "environment_digest": null, + "required_capabilities": [], + "dependency_cache": null, + "source_snapshot": null, + "required_artifacts": [], + "quota_available": true, + "policy_allowed": true, + "prefer_node": null, + }), + json!({ + "type": "create_artifact_download_link", + "tenant": "tenant", + "project": "project", + "actor_user": "user", + "artifact": "artifact", + "max_bytes": 1, + "token_nonce": "attacker-chosen", + "ttl_seconds": 60, + }), + ] { + let error = serde_json::from_value::(payload).unwrap_err(); + assert!(error.to_string().contains("unknown field")); + } +} + +#[test] +fn coordinator_generates_and_bounds_node_enrollment_grants() { + let mut service = CoordinatorService::new(7); + service.set_server_time(100); + + let first = service + .handle_request(CoordinatorRequest::CreateNodeEnrollmentGrant { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + ttl_seconds: u64::MAX, + }) + .unwrap(); + let second = service + .handle_request(CoordinatorRequest::CreateNodeEnrollmentGrant { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + ttl_seconds: u64::MAX, + }) + .unwrap(); + + let CoordinatorResponse::NodeEnrollmentGrantCreated { + grant: first_grant, + expires_at_epoch_seconds, + .. + } = first + else { + panic!("expected enrollment grant"); + }; + let CoordinatorResponse::NodeEnrollmentGrantCreated { + grant: second_grant, + .. + } = second + else { + panic!("expected enrollment grant"); + }; + assert!(first_grant.starts_with("node_grant_")); + assert_ne!(first_grant, second_grant); + assert_eq!(expires_at_epoch_seconds, 100 + 15 * 60); +} diff --git a/crates/disasmer-coordinator/src/service/wire_protocol.rs b/crates/disasmer-coordinator/src/service/wire_protocol.rs new file mode 100644 index 0000000..2d871fa --- /dev/null +++ b/crates/disasmer-coordinator/src/service/wire_protocol.rs @@ -0,0 +1,59 @@ +use disasmer_core::{COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::CoordinatorRequest; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CoordinatorWireRequest { + Envelope(CoordinatorRequestEnvelope), +} + +impl CoordinatorWireRequest { + pub fn into_request(self) -> Result { + match self { + Self::Envelope(envelope) => envelope.into_request(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CoordinatorRequestEnvelope { + #[serde(rename = "type")] + pub envelope_type: String, + pub protocol_version: u64, + pub request_id: String, + pub operation: String, + #[serde(default)] + pub authentication: Option, + pub payload: CoordinatorRequest, +} + +impl CoordinatorRequestEnvelope { + pub fn into_request(self) -> Result { + if self.envelope_type != COORDINATOR_WIRE_REQUEST_TYPE { + return Err(format!( + "unsupported coordinator wire request type {}; expected {}", + self.envelope_type, COORDINATOR_WIRE_REQUEST_TYPE + )); + } + if self.protocol_version != COORDINATOR_PROTOCOL_VERSION { + return Err(format!( + "unsupported coordinator protocol version {}; expected {}", + self.protocol_version, COORDINATOR_PROTOCOL_VERSION + )); + } + if self.request_id.trim().is_empty() { + return Err("coordinator wire request_id must be non-empty".to_owned()); + } + let payload_operation = self.payload.operation()?; + if self.operation != payload_operation { + return Err(format!( + "coordinator wire operation {} does not match payload operation {}", + self.operation, payload_operation + )); + } + Ok(self.payload) + } +} diff --git a/crates/disasmer-coordinator/src/sessions.rs b/crates/disasmer-coordinator/src/sessions.rs new file mode 100644 index 0000000..097667d --- /dev/null +++ b/crates/disasmer-coordinator/src/sessions.rs @@ -0,0 +1,167 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{Actor, AuthContext, CredentialKind, Digest, ProjectId, TenantId, UserId}; + +use crate::{CliSessionRecord, Coordinator, CoordinatorError, CredentialRecord}; + +impl Coordinator { + pub fn issue_cli_session( + &mut self, + tenant: TenantId, + project: ProjectId, + user: UserId, + session_secret: &str, + expires_at_epoch_seconds: Option, + ) -> CliSessionRecord { + self.upsert_tenant(tenant.clone()); + self.upsert_user( + tenant.clone(), + user.clone(), + CredentialKind::CliDeviceSession, + ); + let session_digest = Digest::sha256(session_secret); + let record = CliSessionRecord { + session_digest: session_digest.clone(), + tenant: tenant.clone(), + project: project.clone(), + user: user.clone(), + credential_kind: CredentialKind::CliDeviceSession, + expires_at_epoch_seconds, + revoked: false, + }; + self.durable + .cli_sessions + .insert(session_digest.clone(), record.clone()); + let credential_subject = format!("cli-session:{}", session_digest.as_str()); + self.durable.credentials.insert( + credential_subject.clone(), + CredentialRecord { + subject: credential_subject, + tenant, + project: Some(project), + kind: CredentialKind::CliDeviceSession, + public_key_fingerprint: None, + }, + ); + record + } + + pub fn authenticate_cli_session( + &self, + session_secret: &str, + ) -> Result { + self.authenticate_cli_session_at(session_secret, unix_timestamp_seconds()) + } + + pub fn authenticate_cli_session_at( + &self, + session_secret: &str, + now_epoch_seconds: u64, + ) -> Result { + if session_secret.trim().is_empty() { + return Err(CoordinatorError::Unauthorized( + "CLI session credential is missing".to_owned(), + )); + } + let session_digest = Digest::sha256(session_secret); + let record = self + .durable + .cli_sessions + .get(&session_digest) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "CLI session credential is not recognized".to_owned(), + ) + })?; + if record.revoked { + return Err(CoordinatorError::Unauthorized( + "CLI session credential has been revoked".to_owned(), + )); + } + if record + .expires_at_epoch_seconds + .is_some_and(|expires_at| expires_at <= now_epoch_seconds) + { + return Err(CoordinatorError::Unauthorized( + "CLI session credential has expired; run disasmer login --browser again".to_owned(), + )); + } + if record.credential_kind != CredentialKind::CliDeviceSession { + return Err(CoordinatorError::Unauthorized( + "credential is not a CLI session".to_owned(), + )); + } + self.ensure_tenant_active(&record.tenant)?; + Ok(AuthContext { + tenant: record.tenant.clone(), + project: record.project.clone(), + actor: Actor::User(record.user.clone()), + }) + } + + pub fn revoke_cli_session( + &mut self, + session_secret: &str, + ) -> Result { + self.authenticate_cli_session(session_secret)?; + let session_digest = Digest::sha256(session_secret); + let record = self + .durable + .cli_sessions + .get_mut(&session_digest) + .expect("authenticated session must exist"); + record.revoked = true; + Ok(record.clone()) + } +} + +fn unix_timestamp_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::InMemoryDurableStore; + + use super::*; + + #[test] + fn repeated_logins_for_one_user_create_distinct_session_credentials() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + let tenant = TenantId::from("tenant"); + let user = UserId::from("user"); + + coordinator.issue_cli_session( + tenant.clone(), + ProjectId::from("project-one"), + user.clone(), + "session-one", + None, + ); + coordinator.issue_cli_session( + tenant, + ProjectId::from("project-two"), + user, + "session-two", + None, + ); + + let subjects = coordinator + .durable + .credentials + .values() + .map(|credential| credential.subject.clone()) + .collect::>(); + assert_eq!(coordinator.durable.cli_sessions.len(), 2); + assert_eq!(subjects.len(), 2); + assert!(subjects + .iter() + .all(|subject| subject.starts_with("cli-session:sha256:"))); + } +} diff --git a/crates/disasmer-core/Cargo.toml b/crates/disasmer-core/Cargo.toml new file mode 100644 index 0000000..4dbbfa8 --- /dev/null +++ b/crates/disasmer-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "disasmer-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +base64.workspace = true +ed25519-dalek.workspace = true +serde.workspace = true +serde_json.workspace = true +hex.workspace = true +sha2.workspace = true +syn.workspace = true +thiserror.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +getrandom.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/disasmer-core/src/artifact.rs b/crates/disasmer-core/src/artifact.rs new file mode 100644 index 0000000..fffd58a --- /dev/null +++ b/crates/disasmer-core/src/artifact.rs @@ -0,0 +1,1178 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + auth::same_tenant_project, Actor, ArtifactId, AuthContext, Digest, LimitError, LimitKind, + NodeId, ProcessId, ProjectId, ResourceLimits, ResourceMeter, Scope, TaskInstanceId, TenantId, +}; + +const MAX_ISSUED_DOWNLOAD_LINKS_PER_ARTIFACT: usize = 32; +const DOWNLOAD_LINK_TOMBSTONE_SECONDS: u64 = 15 * 60; +const MAX_ARTIFACT_METADATA_PER_PROCESS: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactHandle { + pub id: ArtifactId, + pub digest: Digest, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum StorageLocation { + RetainedNode(NodeId), + ExplicitStore(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetentionPolicy { + pub best_effort_node_retention: bool, + pub max_download_bytes: u64, +} + +impl Default for RetentionPolicy { + fn default() -> Self { + Self { + best_effort_node_retention: true, + max_download_bytes: 256 * 1024 * 1024, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadPolicy { + pub max_bytes: u64, +} + +#[derive(Clone, Copy, Debug)] +pub struct DownloadStreamRequest<'a> { + pub context: &'a AuthContext, + pub artifact: &'a ArtifactId, + pub policy: &'a DownloadPolicy, + pub presented_token_digest: &'a Digest, + pub now_epoch_seconds: u64, + pub limits: &'a ResourceLimits, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadAction { + pub artifact: ArtifactId, + pub source: StorageLocation, + pub scoped_token_subject: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadLink { + pub artifact: ArtifactId, + pub artifact_digest: Digest, + pub artifact_size_bytes: u64, + pub source: StorageLocation, + pub url_path: String, + pub scoped_token_digest: Digest, + pub expires_at_epoch_seconds: u64, + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub actor: Actor, + pub max_bytes: u64, + pub policy_context_digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssuedDownloadLink { + pub link: DownloadLink, + pub revoked: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactDownloadStream { + pub link: DownloadLink, + pub streamed_bytes: u64, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum DownloadError { + #[error("artifact does not exist")] + NotFound, + #[error("artifact is unavailable from current retention or explicit storage")] + Unavailable, + #[error("artifact download direct connectivity unavailable: {0}")] + DirectConnectivityUnavailable(String), + #[error("artifact download denied: {0}")] + Unauthorized(String), + #[error("artifact size {size} exceeds download limit {limit}")] + LimitExceeded { size: u64, limit: u64 }, + #[error("download link token is invalid for this scoped artifact link")] + InvalidToken, + #[error("download link has expired")] + Expired, + #[error("download link has been revoked")] + Revoked, + #[error("download usage limit failed: {0}")] + Usage(String), +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("artifact is unavailable because node-local unsynced bytes were lost")] +pub struct ArtifactUnavailable; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactMetadata { + pub id: ArtifactId, + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub producer_task: TaskInstanceId, + pub producer_node: NodeId, + pub digest: Digest, + pub size: u64, + pub flushed_epoch: u64, + pub retaining_nodes: BTreeSet, + pub explicit_locations: Vec, + pub coordinator_has_large_bytes: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactFlush { + pub id: ArtifactId, + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub producer_task: TaskInstanceId, + pub retaining_node: NodeId, + pub digest: Digest, + pub size: u64, +} + +#[derive(Clone, Debug, Default)] +pub struct ArtifactRegistry { + artifacts: BTreeMap, + issued_download_links: BTreeMap, + next_epoch: u64, +} + +impl ArtifactRegistry { + pub fn flush_metadata(&mut self, flush: ArtifactFlush) -> ArtifactMetadata { + self.flush_metadata_bounded(flush, &BTreeSet::new()) + .expect("an unpinned artifact scope always has an evictable metadata entry") + } + + pub fn flush_metadata_bounded( + &mut self, + flush: ArtifactFlush, + pinned: &BTreeSet, + ) -> Result { + let replacing_existing = self.artifacts.contains_key(&flush.id); + while !replacing_existing + && self + .artifacts + .values() + .filter(|metadata| { + metadata.tenant == flush.tenant + && metadata.project == flush.project + && metadata.process == flush.process + }) + .count() + >= MAX_ARTIFACT_METADATA_PER_PROCESS + { + let candidate = self + .artifacts + .values() + .filter(|metadata| { + metadata.tenant == flush.tenant + && metadata.project == flush.project + && metadata.process == flush.process + && !pinned.contains(&metadata.id) + && !self.issued_download_links.values().any(|issued| { + issued.link.artifact == metadata.id + }) + }) + .min_by_key(|metadata| metadata.flushed_epoch) + .map(|metadata| metadata.id.clone()) + .ok_or_else(|| { + "artifact metadata retention limit reached and every retained object is pinned by active work, restart state, or a download" + .to_owned() + })?; + self.artifacts.remove(&candidate); + } + self.next_epoch += 1; + let metadata = ArtifactMetadata { + id: flush.id.clone(), + tenant: flush.tenant, + project: flush.project, + process: flush.process, + producer_task: flush.producer_task, + producer_node: flush.retaining_node.clone(), + digest: flush.digest, + size: flush.size, + flushed_epoch: self.next_epoch, + retaining_nodes: BTreeSet::from([flush.retaining_node]), + explicit_locations: Vec::new(), + coordinator_has_large_bytes: false, + }; + self.artifacts.insert(flush.id, metadata.clone()); + Ok(metadata) + } + + pub fn sync_to_explicit_store( + &mut self, + artifact: &ArtifactId, + location: impl Into, + ) -> Result<(), ArtifactUnavailable> { + let metadata = self + .artifacts + .get_mut(artifact) + .ok_or(ArtifactUnavailable)?; + metadata.explicit_locations.push(location.into()); + Ok(()) + } + + pub fn garbage_collect_node(&mut self, node: &NodeId) { + for metadata in self.artifacts.values_mut() { + metadata.retaining_nodes.remove(node); + } + } + + pub fn reconcile_node_retention( + &mut self, + node: &NodeId, + retained_artifacts: &BTreeSet, + ) { + for (artifact, metadata) in &mut self.artifacts { + if !retained_artifacts.contains(artifact) { + metadata.retaining_nodes.remove(node); + } + } + } + + pub fn metadata(&self, artifact: &ArtifactId) -> Option<&ArtifactMetadata> { + self.artifacts.get(artifact) + } + + pub fn download_action( + &self, + context: &AuthContext, + artifact: &ArtifactId, + policy: &DownloadPolicy, + ) -> Result { + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + let scope = Scope { + tenant: metadata.tenant.clone(), + project: metadata.project.clone(), + process: Some(metadata.process.clone()), + task: Some(metadata.producer_task.clone()), + node: None, + artifact: Some(metadata.id.clone()), + }; + let authz = same_tenant_project(context, &scope); + if !authz.allowed { + return Err(DownloadError::Unauthorized(authz.reason)); + } + if metadata.size > policy.max_bytes { + return Err(DownloadError::LimitExceeded { + size: metadata.size, + limit: policy.max_bytes, + }); + } + let source = metadata + .retaining_nodes + .iter() + .next() + .cloned() + .map(StorageLocation::RetainedNode) + .or_else(|| { + metadata + .explicit_locations + .first() + .cloned() + .map(StorageLocation::ExplicitStore) + }) + .ok_or(DownloadError::Unavailable)?; + + Ok(DownloadAction { + artifact: artifact.clone(), + source, + scoped_token_subject: format!( + "{}/{}/{}/{}", + metadata.tenant, metadata.project, metadata.process, metadata.id + ), + }) + } + + pub fn downloadable_size( + &self, + context: &AuthContext, + artifact: &ArtifactId, + policy: &DownloadPolicy, + ) -> Result { + self.download_action(context, artifact, policy)?; + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + Ok(metadata.size) + } + + pub fn create_download_link( + &mut self, + context: &AuthContext, + artifact: &ArtifactId, + policy: &DownloadPolicy, + token_nonce: &str, + now_epoch_seconds: u64, + ttl_seconds: u64, + ) -> Result { + self.expire_download_links(now_epoch_seconds); + if self + .issued_download_links + .values() + .filter(|issued| issued.link.artifact == *artifact) + .count() + >= MAX_ISSUED_DOWNLOAD_LINKS_PER_ARTIFACT + { + return Err(DownloadError::Usage( + "artifact download session limit reached; revoke a link or wait for expiry" + .to_owned(), + )); + } + let action = self.download_action(context, artifact, policy)?; + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds); + let policy_context_digest = + download_policy_context_digest(metadata, &action.source, policy); + let scoped_token_digest = Digest::from_parts([ + b"artifact-download-token:v2".as_slice(), + action.scoped_token_subject.as_bytes(), + actor_subject(&context.actor).as_bytes(), + token_nonce.as_bytes(), + metadata.digest.as_str().as_bytes(), + metadata.size.to_string().as_bytes(), + policy_context_digest.as_str().as_bytes(), + expires_at_epoch_seconds.to_string().as_bytes(), + ]); + let link = DownloadLink { + artifact: artifact.clone(), + artifact_digest: metadata.digest.clone(), + artifact_size_bytes: metadata.size, + source: action.source, + url_path: format!( + "/artifacts/{}/{}/{}/{}", + metadata.tenant, metadata.project, metadata.process, metadata.id + ), + scoped_token_digest, + expires_at_epoch_seconds, + tenant: metadata.tenant.clone(), + project: metadata.project.clone(), + process: metadata.process.clone(), + actor: context.actor.clone(), + max_bytes: policy.max_bytes, + policy_context_digest, + }; + self.issued_download_links.insert( + link.scoped_token_digest.clone(), + IssuedDownloadLink { + link: link.clone(), + revoked: false, + }, + ); + Ok(link) + } + + pub fn revoke_download_link( + &mut self, + context: &AuthContext, + artifact: &ArtifactId, + presented_token_digest: &Digest, + ) -> Result { + let issued = self + .issued_download_links + .get(presented_token_digest) + .ok_or(DownloadError::InvalidToken)?; + if issued.link.artifact != *artifact || issued.link.actor != context.actor { + return Err(DownloadError::InvalidToken); + } + self.download_action( + context, + artifact, + &DownloadPolicy { + max_bytes: issued.link.max_bytes, + }, + )?; + + let issued = self + .issued_download_links + .get_mut(presented_token_digest) + .ok_or(DownloadError::InvalidToken)?; + issued.revoked = true; + Ok(issued.link.clone()) + } + + pub fn open_download_stream( + &self, + request: DownloadStreamRequest<'_>, + meter: &mut ResourceMeter, + ) -> Result { + let DownloadStreamRequest { + context, + artifact, + policy, + presented_token_digest, + now_epoch_seconds, + limits, + } = request; + let issued = self + .issued_download_links + .get(presented_token_digest) + .ok_or(DownloadError::InvalidToken)?; + if issued.link.artifact != *artifact + || issued.link.max_bytes != policy.max_bytes + || issued.link.actor != context.actor + { + return Err(DownloadError::InvalidToken); + } + if issued.revoked { + return Err(DownloadError::Revoked); + } + if now_epoch_seconds > issued.link.expires_at_epoch_seconds { + return Err(DownloadError::Expired); + } + let action = self.download_action(context, artifact, policy)?; + if action.source != issued.link.source { + return Err(DownloadError::Unavailable); + } + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + if download_policy_context_digest(metadata, &action.source, policy) + != issued.link.policy_context_digest + { + return Err(DownloadError::InvalidToken); + } + meter + .charge(limits, LimitKind::ArtifactDownloadBytes, 0) + .map_err(download_usage_error)?; + Ok(ArtifactDownloadStream { + link: issued.link.clone(), + streamed_bytes: 0, + }) + } + + pub fn stream_download_chunk( + &self, + stream: &mut ArtifactDownloadStream, + limits: &ResourceLimits, + meter: &mut ResourceMeter, + bytes: u64, + ) -> Result<(), DownloadError> { + let metadata = self + .artifacts + .get(&stream.link.artifact) + .ok_or(DownloadError::NotFound)?; + if !source_is_available(metadata, &stream.link.source) { + return Err(DownloadError::Unavailable); + } + stream.stream_chunk(limits, meter, bytes) + } + + pub fn expire_download_links(&mut self, now_epoch_seconds: u64) { + self.issued_download_links.retain(|_, issued| { + issued + .link + .expires_at_epoch_seconds + .saturating_add(DOWNLOAD_LINK_TOMBSTONE_SECONDS) + >= now_epoch_seconds + }); + } + + pub fn retained_download_link_count(&self) -> usize { + self.issued_download_links.len() + } + + pub fn artifact_count(&self) -> usize { + self.artifacts.len() + } +} + +impl ArtifactDownloadStream { + pub fn stream_chunk( + &mut self, + limits: &ResourceLimits, + meter: &mut ResourceMeter, + bytes: u64, + ) -> Result<(), DownloadError> { + if self.streamed_bytes.saturating_add(bytes) > self.link.max_bytes { + return Err(DownloadError::LimitExceeded { + size: self.streamed_bytes.saturating_add(bytes), + limit: self.link.max_bytes, + }); + } + meter + .charge(limits, LimitKind::ArtifactDownloadBytes, bytes) + .map_err(download_usage_error)?; + self.streamed_bytes += bytes; + Ok(()) + } +} + +fn download_usage_error(error: LimitError) -> DownloadError { + DownloadError::Usage(error.to_string()) +} + +fn download_policy_context_digest( + metadata: &ArtifactMetadata, + source: &StorageLocation, + policy: &DownloadPolicy, +) -> Digest { + Digest::from_parts([ + b"artifact-download-policy-context:v1".as_slice(), + metadata.tenant.as_str().as_bytes(), + metadata.project.as_str().as_bytes(), + metadata.process.as_str().as_bytes(), + metadata.id.as_str().as_bytes(), + metadata.digest.as_str().as_bytes(), + metadata.size.to_string().as_bytes(), + storage_location_key(source).as_bytes(), + policy.max_bytes.to_string().as_bytes(), + ]) +} + +fn actor_subject(actor: &Actor) -> String { + match actor { + Actor::User(id) => format!("user:{id}"), + Actor::Agent(id) => format!("agent:{id}"), + Actor::Node(id) => format!("node:{id}"), + Actor::Task(id) => format!("task:{id}"), + } +} + +fn storage_location_key(source: &StorageLocation) -> String { + match source { + StorageLocation::RetainedNode(node) => format!("retained-node:{node}"), + StorageLocation::ExplicitStore(location) => format!("explicit-store:{location}"), + } +} + +fn source_is_available(metadata: &ArtifactMetadata, source: &StorageLocation) -> bool { + match source { + StorageLocation::RetainedNode(node) => metadata.retaining_nodes.contains(node), + StorageLocation::ExplicitStore(location) => metadata.explicit_locations.contains(location), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{Actor, LimitKind, ResourceLimits, ResourceMeter, UserId}; + + use super::*; + + fn registry_with_artifact() -> ArtifactRegistry { + let mut registry = ArtifactRegistry::default(); + registry.flush_metadata(ArtifactFlush { + id: ArtifactId::from("artifact"), + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + producer_task: TaskInstanceId::from("task"), + retaining_node: NodeId::from("node"), + digest: Digest::sha256("bytes"), + size: 32, + }); + registry + } + + #[test] + fn flush_publishes_metadata_without_coordinator_bytes() { + let registry = registry_with_artifact(); + let metadata = registry.metadata(&ArtifactId::from("artifact")).unwrap(); + + assert!(!metadata.coordinator_has_large_bytes); + assert_eq!(metadata.id, ArtifactId::from("artifact")); + assert_eq!(metadata.tenant, TenantId::from("tenant")); + assert_eq!(metadata.project, ProjectId::from("project")); + assert_eq!(metadata.process, ProcessId::from("process")); + assert_eq!(metadata.producer_task, TaskInstanceId::from("task")); + assert_eq!(metadata.producer_node, NodeId::from("node")); + assert_eq!(metadata.digest, Digest::sha256("bytes")); + assert_eq!(metadata.size, 32); + assert_eq!(metadata.flushed_epoch, 1); + assert!(metadata.retaining_nodes.contains(&NodeId::from("node"))); + assert!(metadata.explicit_locations.is_empty()); + } + + #[test] + fn unsynced_node_loss_surfaces_as_unavailable() { + let mut registry = registry_with_artifact(); + registry.garbage_collect_node(&NodeId::from("node")); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let error = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap_err(); + + assert_eq!(error, DownloadError::Unavailable); + } + + #[test] + fn signed_node_retention_inventory_removes_garbage_collected_location() { + let mut registry = registry_with_artifact(); + registry.reconcile_node_retention(&NodeId::from("node"), &BTreeSet::new()); + + let metadata = registry.metadata(&ArtifactId::from("artifact")).unwrap(); + + assert!(metadata.retaining_nodes.is_empty()); + } + + #[test] + fn explicit_user_storage_location_survives_node_retention_loss() { + let mut registry = registry_with_artifact(); + registry + .sync_to_explicit_store(&ArtifactId::from("artifact"), "s3://bucket/app") + .unwrap(); + registry.garbage_collect_node(&NodeId::from("node")); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let action = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap(); + + assert_eq!( + action.source, + StorageLocation::ExplicitStore("s3://bucket/app".to_owned()) + ); + assert!( + !registry + .metadata(&ArtifactId::from("artifact")) + .unwrap() + .coordinator_has_large_bytes + ); + } + + #[test] + fn default_retention_policy_is_best_effort_node_retention() { + let policy = RetentionPolicy::default(); + + assert!(policy.best_effort_node_retention); + assert_eq!(policy.max_download_bytes, 256 * 1024 * 1024); + } + + #[test] + fn cross_tenant_download_is_denied_even_with_known_artifact_id() { + let registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("other"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let error = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap_err(); + + assert!(matches!(error, DownloadError::Unauthorized(_))); + } + + #[test] + fn cross_project_download_is_denied_even_with_known_artifact_id() { + let registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("other-project"), + actor: Actor::User(UserId::from("user")), + }; + + let error = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap_err(); + + assert!(matches!(error, DownloadError::Unauthorized(_))); + } + + #[test] + fn download_link_is_not_created_when_artifact_is_unavailable_or_too_large() { + let mut registry = registry_with_artifact(); + registry.garbage_collect_node(&NodeId::from("node")); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + assert_eq!( + registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + "nonce", + 10, + 60, + ) + .unwrap_err(), + DownloadError::Unavailable + ); + + let mut registry = registry_with_artifact(); + assert!(matches!( + registry.create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 1 }, + "nonce", + 10, + 60, + ), + Err(DownloadError::LimitExceeded { .. }) + )); + } + + #[test] + fn download_link_is_authenticated_scoped_and_not_guessable() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + "nonce-a", + 10, + 60, + ) + .unwrap(); + let other = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + "nonce-b", + 10, + 60, + ) + .unwrap(); + + assert_eq!(link.tenant, TenantId::from("tenant")); + assert_eq!(link.project, ProjectId::from("project")); + assert_eq!(link.process, ProcessId::from("process")); + assert_eq!(link.actor, Actor::User(UserId::from("user"))); + assert_eq!(link.max_bytes, 100); + assert!(link.policy_context_digest.is_valid_sha256()); + assert_eq!(link.expires_at_epoch_seconds, 70); + assert!(link + .url_path + .contains("/artifacts/tenant/project/process/artifact")); + assert_ne!(link.scoped_token_digest, other.scoped_token_digest); + assert!(matches!(link.source, StorageLocation::RetainedNode(_))); + } + + #[test] + fn download_link_is_bound_to_actor_and_policy_context() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let other_actor = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("other-user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "nonce", + 10, + 60, + ) + .unwrap(); + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + + assert_eq!( + registry + .open_download_stream( + DownloadStreamRequest { + context: &other_actor, + artifact: &ArtifactId::from("artifact"), + policy: &policy, + presented_token_digest: &link.scoped_token_digest, + now_epoch_seconds: 11, + limits: &limits, + }, + &mut meter, + ) + .unwrap_err(), + DownloadError::InvalidToken + ); + assert_eq!( + registry + .open_download_stream( + DownloadStreamRequest { + context: &context, + artifact: &ArtifactId::from("artifact"), + policy: &DownloadPolicy { max_bytes: 99 }, + presented_token_digest: &link.scoped_token_digest, + now_epoch_seconds: 11, + limits: &limits, + }, + &mut meter, + ) + .unwrap_err(), + DownloadError::InvalidToken + ); + assert_eq!( + registry + .revoke_download_link( + &other_actor, + &ArtifactId::from("artifact"), + &link.scoped_token_digest, + ) + .unwrap_err(), + DownloadError::InvalidToken + ); + } + + #[test] + fn download_link_expires_and_can_be_revoked() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + let expired = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "expired", + 10, + 5, + ) + .unwrap(); + + let error = registry + .open_download_stream( + DownloadStreamRequest { + context: &context, + artifact: &ArtifactId::from("artifact"), + policy: &policy, + presented_token_digest: &expired.scoped_token_digest, + now_epoch_seconds: 16, + limits: &limits, + }, + &mut meter, + ) + .unwrap_err(); + assert_eq!(error, DownloadError::Expired); + + let active = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "active", + 20, + 60, + ) + .unwrap(); + let revoked = registry + .revoke_download_link( + &context, + &ArtifactId::from("artifact"), + &active.scoped_token_digest, + ) + .unwrap(); + assert_eq!(revoked.scoped_token_digest, active.scoped_token_digest); + + let error = registry + .open_download_stream( + DownloadStreamRequest { + context: &context, + artifact: &ArtifactId::from("artifact"), + policy: &policy, + presented_token_digest: &active.scoped_token_digest, + now_epoch_seconds: 21, + limits: &limits, + }, + &mut meter, + ) + .unwrap_err(); + assert_eq!(error, DownloadError::Revoked); + } + + #[test] + fn download_session_history_is_count_and_time_bounded() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + for index in 0..MAX_ISSUED_DOWNLOAD_LINKS_PER_ARTIFACT { + registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + &format!("bounded-{index}"), + 10, + 1, + ) + .unwrap(); + } + assert_eq!( + registry.retained_download_link_count(), + MAX_ISSUED_DOWNLOAD_LINKS_PER_ARTIFACT + ); + assert!(matches!( + registry.create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "over-limit", + 10, + 1, + ), + Err(DownloadError::Usage(_)) + )); + + registry.expire_download_links(12 + DOWNLOAD_LINK_TOMBSTONE_SECONDS); + assert_eq!(registry.retained_download_link_count(), 0); + registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "after-expiry", + 12 + DOWNLOAD_LINK_TOMBSTONE_SECONDS, + 1, + ) + .unwrap(); + } + + #[test] + fn artifact_metadata_is_bounded_without_evicting_pins() { + let mut registry = ArtifactRegistry::default(); + let mut pinned = BTreeSet::new(); + for index in 0..MAX_ARTIFACT_METADATA_PER_PROCESS { + let id = ArtifactId::new(format!("artifact-{index}")); + pinned.insert(id.clone()); + registry + .flush_metadata_bounded( + ArtifactFlush { + id, + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + producer_task: TaskInstanceId::new(format!("task-{index}")), + retaining_node: NodeId::from("node"), + digest: Digest::sha256(format!("content-{index}")), + size: 1, + }, + &pinned, + ) + .unwrap(); + } + assert_eq!(registry.artifact_count(), MAX_ARTIFACT_METADATA_PER_PROCESS); + let next = ArtifactFlush { + id: ArtifactId::from("artifact-next"), + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + producer_task: TaskInstanceId::from("task-next"), + retaining_node: NodeId::from("node"), + digest: Digest::sha256("next"), + size: 1, + }; + assert!(registry + .flush_metadata_bounded(next.clone(), &pinned) + .unwrap_err() + .contains("pinned")); + + pinned.remove(&ArtifactId::from("artifact-0")); + registry.flush_metadata_bounded(next, &pinned).unwrap(); + assert_eq!(registry.artifact_count(), MAX_ARTIFACT_METADATA_PER_PROCESS); + assert!(registry.metadata(&ArtifactId::from("artifact-0")).is_none()); + assert!(registry + .metadata(&ArtifactId::from("artifact-next")) + .is_some()); + } + + #[test] + fn download_stream_accounts_usage_before_and_during_streaming() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "nonce", + 10, + 60, + ) + .unwrap(); + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + let mut stream = registry + .open_download_stream( + DownloadStreamRequest { + context: &context, + artifact: &ArtifactId::from("artifact"), + policy: &policy, + presented_token_digest: &link.scoped_token_digest, + now_epoch_seconds: 11, + limits: &limits, + }, + &mut meter, + ) + .unwrap(); + + stream.stream_chunk(&limits, &mut meter, 16).unwrap(); + stream.stream_chunk(&limits, &mut meter, 16).unwrap(); + assert!(matches!( + stream.stream_chunk(&limits, &mut meter, 1), + Err(DownloadError::Usage(_)) + )); + assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 32); + } + + #[test] + fn download_stream_fails_honestly_when_source_disappears_mid_stream() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "nonce", + 10, + 60, + ) + .unwrap(); + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + let mut stream = registry + .open_download_stream( + DownloadStreamRequest { + context: &context, + artifact: &ArtifactId::from("artifact"), + policy: &policy, + presented_token_digest: &link.scoped_token_digest, + now_epoch_seconds: 11, + limits: &limits, + }, + &mut meter, + ) + .unwrap(); + + registry + .stream_download_chunk(&mut stream, &limits, &mut meter, 16) + .unwrap(); + registry.garbage_collect_node(&NodeId::from("node")); + + assert_eq!( + registry + .stream_download_chunk(&mut stream, &limits, &mut meter, 1) + .unwrap_err(), + DownloadError::Unavailable + ); + assert_eq!(stream.streamed_bytes, 16); + } + + #[test] + fn guessed_download_token_is_rejected() { + let registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + + let error = registry + .open_download_stream( + DownloadStreamRequest { + context: &context, + artifact: &ArtifactId::from("artifact"), + policy: &DownloadPolicy { max_bytes: 100 }, + presented_token_digest: &Digest::sha256("guessed"), + now_epoch_seconds: 11, + limits: &limits, + }, + &mut meter, + ) + .unwrap_err(); + + assert_eq!(error, DownloadError::InvalidToken); + } +} diff --git a/crates/disasmer-core/src/auth.rs b/crates/disasmer-core/src/auth.rs new file mode 100644 index 0000000..33bb2b1 --- /dev/null +++ b/crates/disasmer-core/src/auth.rs @@ -0,0 +1,720 @@ +#[cfg(not(target_arch = "wasm32"))] +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskInstanceId, TenantId, UserId, +}; + +pub fn admin_request_proof( + admin_token: &str, + operation: &str, + tenant: &str, + actor_user: &str, + target_tenant: &str, + nonce: &str, + issued_at_epoch_seconds: u64, +) -> Digest { + admin_request_proof_from_token_digest( + &Digest::sha256(admin_token), + operation, + tenant, + actor_user, + target_tenant, + nonce, + issued_at_epoch_seconds, + ) +} + +pub fn admin_request_proof_from_token_digest( + admin_token_digest: &Digest, + operation: &str, + tenant: &str, + actor_user: &str, + target_tenant: &str, + nonce: &str, + issued_at_epoch_seconds: u64, +) -> Digest { + let issued_at_epoch_seconds = issued_at_epoch_seconds.to_string(); + Digest::from_parts([ + b"disasmer-admin-request-proof:v1".as_slice(), + admin_token_digest.as_str().as_bytes(), + operation.as_bytes(), + tenant.as_bytes(), + actor_user.as_bytes(), + target_tenant.as_bytes(), + nonce.as_bytes(), + issued_at_epoch_seconds.as_bytes(), + ]) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Actor { + User(UserId), + Agent(AgentId), + Node(NodeId), + Task(TaskInstanceId), +} + +impl Actor { + pub fn kind(&self) -> IdentityKind { + match self { + Self::User(_) => IdentityKind::User, + Self::Agent(_) => IdentityKind::Agent, + Self::Node(_) => IdentityKind::Node, + Self::Task(_) => IdentityKind::Task, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum IdentityKind { + User, + Agent, + Node, + Project, + Task, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CredentialKind { + BrowserSession, + CliDeviceSession, + PublicKey, + NodeCredential, + TaskCredential, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthContext { + pub tenant: TenantId, + pub project: ProjectId, + pub actor: Actor, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Action { + CreateProject, + AttachNode, + CreateNodeEnrollmentGrant, + ExchangeNodeEnrollmentGrant, + LoginBrowser, + LoginCli, + EnrollAgent, + List, + Inspect, + Mutate, + ClaimTask, + DebugAttach, + DebugRead, + DownloadArtifact, + PublishArtifact, + RunNativeCommand, + RunContainer, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserLoginFlow { + pub authorization_url: String, + pub callback_path: String, + pub state: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicKeyIdentity { + pub subject: Actor, + pub public_key: String, + pub fingerprint: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSignedRequest { + pub nonce: String, + pub issued_at_epoch_seconds: u64, + pub signature: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeSignedRequest { + pub nonce: String, + pub issued_at_epoch_seconds: u64, + pub signature: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnrollmentGrant { + pub tenant: TenantId, + pub project: ProjectId, + pub grant_id: String, + pub scope: String, + pub expires_at_epoch_seconds: u64, + pub consumed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeCredential { + pub node: NodeId, + pub tenant: TenantId, + pub project: ProjectId, + pub public_key_fingerprint: Digest, + pub scope: String, + pub capability_policy_digest: Digest, + pub credential_kind: CredentialKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnrollmentError { + Expired, + AlreadyConsumed, + WrongScope, +} + +impl EnrollmentGrant { + pub fn exchange_for_node_identity( + &mut self, + node: NodeId, + public_key: &str, + requested_scope: &str, + now_epoch_seconds: u64, + ) -> Result { + if self.consumed { + return Err(EnrollmentError::AlreadyConsumed); + } + if now_epoch_seconds > self.expires_at_epoch_seconds { + return Err(EnrollmentError::Expired); + } + if requested_scope != self.scope { + return Err(EnrollmentError::WrongScope); + } + self.consumed = true; + let capability_policy_digest = + node_capability_policy_digest(&self.tenant, &self.project, &self.scope); + Ok(NodeCredential { + node, + tenant: self.tenant.clone(), + project: self.project.clone(), + public_key_fingerprint: Digest::sha256(public_key), + scope: self.scope.clone(), + capability_policy_digest, + credential_kind: CredentialKind::NodeCredential, + }) + } +} + +pub fn node_capability_policy_digest( + tenant: &TenantId, + project: &ProjectId, + scope: &str, +) -> Digest { + Digest::from_parts([ + b"node-capability-policy:v1".as_slice(), + tenant.as_str().as_bytes(), + project.as_str().as_bytes(), + scope.as_bytes(), + ]) +} + +pub fn agent_ed25519_public_key_from_private_key(private_key: &str) -> Result { + let private_key = decode_ed25519_key(private_key, 32, "agent private key")?; + let private_key: [u8; 32] = private_key + .try_into() + .map_err(|_| "agent private key must be 32 bytes".to_owned())?; + let signing_key = SigningKey::from_bytes(&private_key); + Ok(format!( + "ed25519:{}", + STANDARD.encode(signing_key.verifying_key().to_bytes()) + )) +} + +pub fn node_ed25519_public_key_from_private_key(private_key: &str) -> Result { + agent_ed25519_public_key_from_private_key(private_key) +} + +pub fn derive_ed25519_private_key_from_seed(seed: &str) -> String { + let digest = Digest::sha256(seed); + let hex = digest.as_str().trim_start_matches("sha256:"); + let bytes = hex::decode(hex).expect("sha256 digest hex should decode"); + format!("ed25519:{}", STANDARD.encode(bytes)) +} + +/// Generates a new Ed25519 private key from the operating system CSPRNG. +/// +/// Seed-derived keys remain available for deterministic test fixtures, but +/// must not be used for persisted user, Agent, or Node credentials. +#[cfg(not(target_arch = "wasm32"))] +pub fn generate_ed25519_private_key() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes) + .map_err(|err| format!("operating system random source failed: {err}"))?; + Ok(format!("ed25519:{}", STANDARD.encode(bytes))) +} + +/// Generates an opaque, URL-safe 256-bit token suitable for one-time grants, +/// session secrets, and nonces. The label is non-secret domain separation. +#[cfg(not(target_arch = "wasm32"))] +pub fn generate_opaque_token(label: &str) -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes) + .map_err(|err| format!("operating system random source failed: {err}"))?; + Ok(format!("{label}_{}", URL_SAFE_NO_PAD.encode(bytes))) +} + +#[derive(Clone, Copy, Debug)] +pub struct AgentWorkflowScope<'a> { + pub tenant: &'a TenantId, + pub project: &'a ProjectId, + pub agent: &'a AgentId, + pub request_kind: &'a str, + pub process: &'a ProcessId, + pub task: Option<&'a TaskInstanceId>, +} + +pub fn sign_agent_workflow_request( + private_key: &str, + scope: AgentWorkflowScope<'_>, + payload_digest: &Digest, + nonce: String, + issued_at_epoch_seconds: u64, +) -> Result { + let private_key = decode_ed25519_key(private_key, 32, "agent private key")?; + let private_key: [u8; 32] = private_key + .try_into() + .map_err(|_| "agent private key must be 32 bytes".to_owned())?; + let signing_key = SigningKey::from_bytes(&private_key); + let message = + agent_workflow_signature_message(scope, payload_digest, &nonce, issued_at_epoch_seconds); + let signature: Signature = signing_key.sign(&message); + Ok(AgentSignedRequest { + nonce, + issued_at_epoch_seconds, + signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())), + }) +} + +pub fn verify_agent_workflow_signature( + public_key: &str, + scope: AgentWorkflowScope<'_>, + payload_digest: &Digest, + signed_request: &AgentSignedRequest, +) -> Result<(), String> { + let public_key = decode_ed25519_key(public_key, 32, "agent public key")?; + let public_key: [u8; 32] = public_key + .try_into() + .map_err(|_| "agent public key must be 32 bytes".to_owned())?; + let verifying_key = VerifyingKey::from_bytes(&public_key) + .map_err(|_| "agent public key is not a valid Ed25519 verifying key".to_owned())?; + let signature = decode_ed25519_key(&signed_request.signature, 64, "agent signature")?; + let signature: [u8; 64] = signature + .try_into() + .map_err(|_| "agent signature must be 64 bytes".to_owned())?; + let signature = Signature::from_bytes(&signature); + let message = agent_workflow_signature_message( + scope, + payload_digest, + &signed_request.nonce, + signed_request.issued_at_epoch_seconds, + ); + verifying_key + .verify(&message, &signature) + .map_err(|_| "agent signature does not verify against the registered public key".to_owned()) +} + +pub fn sign_node_request( + private_key: &str, + node: &NodeId, + request_kind: &str, + payload_digest: &Digest, + nonce: String, + issued_at_epoch_seconds: u64, +) -> Result { + let private_key = decode_ed25519_key(private_key, 32, "node private key")?; + let private_key: [u8; 32] = private_key + .try_into() + .map_err(|_| "node private key must be 32 bytes".to_owned())?; + let signing_key = SigningKey::from_bytes(&private_key); + let message = node_request_signature_message( + node, + request_kind, + payload_digest, + &nonce, + issued_at_epoch_seconds, + ); + let signature: Signature = signing_key.sign(&message); + Ok(NodeSignedRequest { + nonce, + issued_at_epoch_seconds, + signature: format!("ed25519:{}", STANDARD.encode(signature.to_bytes())), + }) +} + +pub fn verify_node_request_signature( + public_key: &str, + node: &NodeId, + request_kind: &str, + payload_digest: &Digest, + signed_request: &NodeSignedRequest, +) -> Result<(), String> { + let public_key = decode_ed25519_key(public_key, 32, "node public key")?; + let public_key: [u8; 32] = public_key + .try_into() + .map_err(|_| "node public key must be 32 bytes".to_owned())?; + let verifying_key = VerifyingKey::from_bytes(&public_key) + .map_err(|_| "node public key is not a valid Ed25519 verifying key".to_owned())?; + let signature = decode_ed25519_key(&signed_request.signature, 64, "node signature")?; + let signature: [u8; 64] = signature + .try_into() + .map_err(|_| "node signature must be 64 bytes".to_owned())?; + let signature = Signature::from_bytes(&signature); + let message = node_request_signature_message( + node, + request_kind, + payload_digest, + &signed_request.nonce, + signed_request.issued_at_epoch_seconds, + ); + verifying_key + .verify(&message, &signature) + .map_err(|_| "node signature does not verify against the enrolled public key".to_owned()) +} + +fn decode_ed25519_key(value: &str, expected_len: usize, kind: &str) -> Result, String> { + let encoded = value + .strip_prefix("ed25519:") + .ok_or_else(|| format!("{kind} must use ed25519: encoding"))?; + let bytes = STANDARD + .decode(encoded) + .map_err(|_| format!("{kind} is not valid base64"))?; + if bytes.len() != expected_len { + return Err(format!("{kind} must be {expected_len} bytes")); + } + Ok(bytes) +} + +fn agent_workflow_signature_message( + scope: AgentWorkflowScope<'_>, + payload_digest: &Digest, + nonce: &str, + issued_at_epoch_seconds: u64, +) -> Vec { + let issued_at = issued_at_epoch_seconds.to_string(); + let task = scope.task.map(TaskInstanceId::as_str).unwrap_or(""); + let parts = [ + "disasmer-agent-workflow-signature:v2", + scope.tenant.as_str(), + scope.project.as_str(), + scope.agent.as_str(), + scope.request_kind, + scope.process.as_str(), + task, + payload_digest.as_str(), + nonce, + &issued_at, + ]; + let mut message = Vec::new(); + for part in parts { + message.extend_from_slice(part.len().to_string().as_bytes()); + message.push(b':'); + message.extend_from_slice(part.as_bytes()); + message.push(b'\n'); + } + message +} + +fn node_request_signature_message( + node: &NodeId, + request_kind: &str, + payload_digest: &Digest, + nonce: &str, + issued_at_epoch_seconds: u64, +) -> Vec { + let issued_at = issued_at_epoch_seconds.to_string(); + let parts = [ + "disasmer-node-request-signature:v2", + node.as_str(), + request_kind, + payload_digest.as_str(), + nonce, + &issued_at, + ]; + let mut message = Vec::new(); + for part in parts { + message.extend_from_slice(part.len().to_string().as_bytes()); + message.push(b':'); + message.extend_from_slice(part.as_bytes()); + message.push(b'\n'); + } + message +} + +/// Computes the stable digest covered by node and agent request signatures. +/// +/// The proof field itself is excluded, and explicit JSON nulls are normalized +/// with omitted optional fields because the wire protocol deserializes both to +/// the same request. Every semantically meaningful key and value remains bound +/// by the signature. +pub fn signed_request_payload_digest(value: &Value) -> Digest { + fn canonicalize(value: &Value, top_level: bool) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .filter(|(key, value)| { + !value.is_null() + && (!top_level + || !matches!(key.as_str(), "agent_signature" | "node_signature")) + }) + .map(|(key, value)| (key.clone(), canonicalize(value, false))) + .collect(), + ), + Value::Array(values) => Value::Array( + values + .iter() + .map(|value| canonicalize(value, false)) + .collect(), + ), + value => value.clone(), + } + } + + let canonical = canonicalize(value, true); + let bytes = serde_json::to_vec(&canonical) + .expect("canonical JSON request values are always serializable"); + Digest::sha256(bytes) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Scope { + pub tenant: TenantId, + pub project: ProjectId, + pub process: Option, + pub task: Option, + pub node: Option, + pub artifact: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Authorization { + pub allowed: bool, + pub reason: String, +} + +impl Authorization { + pub fn allow(reason: impl Into) -> Self { + Self { + allowed: true, + reason: reason.into(), + } + } + + pub fn deny(reason: impl Into) -> Self { + Self { + allowed: false, + reason: reason.into(), + } + } +} + +pub fn same_tenant_project(context: &AuthContext, scope: &Scope) -> Authorization { + if context.tenant != scope.tenant { + return Authorization::deny("tenant mismatch"); + } + if context.project != scope.project { + return Authorization::deny("project mismatch"); + } + Authorization::allow("same tenant and project") +} + +pub fn task_credentials_do_not_contain_user_session( + task: &Actor, + credentials: &[CredentialKind], +) -> Authorization { + if !matches!(task, Actor::Task(_)) { + return Authorization::deny("credential check requires task actor"); + } + if credentials.iter().any(|credential| { + matches!( + credential, + CredentialKind::BrowserSession | CredentialKind::CliDeviceSession + ) + }) { + return Authorization::deny( + "user OAuth/session tokens must not be passed to nodes as task credentials", + ); + } + Authorization::allow("task credentials are scoped runtime credentials") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tenant_project_scope_denies_cross_tenant_access() { + let context = AuthContext { + tenant: TenantId::from("tenant-a"), + project: ProjectId::from("project-a"), + actor: Actor::User(UserId::from("user-a")), + }; + let scope = Scope { + tenant: TenantId::from("tenant-b"), + project: ProjectId::from("project-a"), + process: None, + task: None, + node: None, + artifact: None, + }; + + assert!(!same_tenant_project(&context, &scope).allowed); + } + + #[test] + fn node_enrollment_exchanges_short_lived_grant_once() { + let mut grant = EnrollmentGrant { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + grant_id: "grant".to_owned(), + scope: "node:attach".to_owned(), + expires_at_epoch_seconds: 100, + consumed: false, + }; + + let credential = grant + .exchange_for_node_identity(NodeId::from("node"), "public-key", "node:attach", 99) + .unwrap(); + + assert_eq!(credential.credential_kind, CredentialKind::NodeCredential); + assert_eq!(credential.tenant, TenantId::from("tenant")); + assert_eq!(credential.project, ProjectId::from("project")); + assert_eq!(credential.node, NodeId::from("node")); + assert_eq!(credential.scope, "node:attach"); + assert_eq!( + credential.capability_policy_digest, + node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "node:attach" + ) + ); + assert_eq!( + grant.exchange_for_node_identity( + NodeId::from("node2"), + "public-key", + "node:attach", + 99 + ), + Err(EnrollmentError::AlreadyConsumed) + ); + } + + #[test] + fn node_capability_policy_digest_is_scoped() { + let base = node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "node:attach", + ); + let other_project = node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("other"), + "node:attach", + ); + let other_scope = node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "node:limited", + ); + + assert!(base.is_valid_sha256()); + assert_ne!(base, other_project); + assert_ne!(base, other_scope); + } + + #[test] + fn generated_ed25519_private_keys_are_random_and_valid() { + let first = generate_ed25519_private_key().unwrap(); + let second = generate_ed25519_private_key().unwrap(); + + assert_ne!(first, second); + assert!(agent_ed25519_public_key_from_private_key(&first) + .unwrap() + .starts_with("ed25519:")); + assert!(node_ed25519_public_key_from_private_key(&second) + .unwrap() + .starts_with("ed25519:")); + } + + #[test] + fn generated_opaque_tokens_are_random_and_url_safe() { + let first = generate_opaque_token("grant").unwrap(); + let second = generate_opaque_token("grant").unwrap(); + + assert_ne!(first, second); + assert!(first.starts_with("grant_")); + assert!(first + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))); + } + + #[test] + fn task_credentials_reject_user_session_tokens() { + for credential in [ + CredentialKind::BrowserSession, + CredentialKind::CliDeviceSession, + ] { + let authz = task_credentials_do_not_contain_user_session( + &Actor::Task(TaskInstanceId::from("task")), + &[CredentialKind::TaskCredential, credential], + ); + + assert!(!authz.allowed); + assert!(authz.reason.contains("must not be passed")); + } + + let scoped = task_credentials_do_not_contain_user_session( + &Actor::Task(TaskInstanceId::from("task")), + &[ + CredentialKind::TaskCredential, + CredentialKind::NodeCredential, + ], + ); + assert!(scoped.allowed); + } + + #[test] + fn identities_remain_distinct_for_authorization() { + assert_eq!(Actor::User(UserId::from("user")).kind(), IdentityKind::User); + assert_eq!( + Actor::Agent(AgentId::from("agent")).kind(), + IdentityKind::Agent + ); + assert_eq!(Actor::Node(NodeId::from("node")).kind(), IdentityKind::Node); + assert_eq!( + Actor::Task(TaskInstanceId::from("task")).kind(), + IdentityKind::Task + ); + + let scope = Scope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: Some(ProcessId::from("process")), + task: Some(TaskInstanceId::from("task")), + node: Some(NodeId::from("node")), + artifact: Some(ArtifactId::from("artifact")), + }; + assert_eq!(scope.process, Some(ProcessId::from("process"))); + assert_eq!(scope.artifact, Some(ArtifactId::from("artifact"))); + + assert_ne!( + CredentialKind::BrowserSession, + CredentialKind::CliDeviceSession + ); + assert_ne!(CredentialKind::PublicKey, CredentialKind::NodeCredential); + assert_ne!( + CredentialKind::NodeCredential, + CredentialKind::TaskCredential + ); + } +} diff --git a/crates/disasmer-core/src/bundle.rs b/crates/disasmer-core/src/bundle.rs new file mode 100644 index 0000000..6351c50 --- /dev/null +++ b/crates/disasmer-core/src/bundle.rs @@ -0,0 +1,459 @@ +use serde::{Deserialize, Serialize}; +use syn::parse::Parser; +use syn::{Expr, Item, Lit, Meta, Token}; + +use crate::{Digest, EnvironmentResource, SourceTransferPolicy, TaskDefinitionId}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectedInput { + pub path: String, + pub digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleIdentityInputs { + pub wasm_code: Digest, + pub task_abi: Digest, + pub entrypoints: Vec, + pub default_entrypoint: String, + pub environments: Vec, + pub source_provider_manifest: Digest, + pub source_transfer_policy: SourceTransferPolicy, + pub selected_inputs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleMetadata { + pub identity: Digest, + pub wasm_code: Digest, + pub task_metadata: BundleTaskMetadata, + pub source_metadata: BundleSourceMetadata, + pub debug_metadata: BundleDebugMetadata, + pub large_input_policy: BundleLargeInputPolicy, + pub restart_compatibility: BundleRestartCompatibility, + pub environments: Vec, + pub selected_inputs: Vec, + pub embeds_full_container_images: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleTaskMetadata { + pub task_abi: Digest, + pub entrypoints: Vec, + pub default_entrypoint: String, + pub boundary: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleSourceMetadata { + pub source_provider_manifest: Digest, + pub transfer_policy: SourceTransferPolicy, + pub selected_inputs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleDebugMetadata { + pub available: bool, + pub source_level_breakpoints: bool, + pub dap_virtual_process: bool, + pub variables_pane_supported: bool, + pub probes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleDebugProbe { + pub id: String, + pub source_path: String, + pub line_start: u32, + pub line_end: u32, + pub function: String, + pub task: TaskDefinitionId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleLargeInputPolicy { + pub selected_inputs_are_content_digests: bool, + pub selected_input_bytes_included: bool, + pub full_repository_bytes_included: bool, + pub silent_task_argument_serialization: bool, + pub supported_handle_types: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleRestartCompatibility { + pub source_edits_can_restart_from_clean_task_boundary: bool, + pub requires_clean_checkpoint_boundary: bool, + pub compares_task_abi: Digest, + pub compares_environment_digests: bool, + pub compares_serialized_args: bool, + pub discards_unflushed_task_local_changes: bool, + pub incompatible_changes_require_whole_process_restart: bool, +} + +impl BundleIdentityInputs { + pub fn identity(&self) -> Digest { + let mut parts = vec![ + b"bundle:v1".to_vec(), + self.wasm_code.as_str().as_bytes().to_vec(), + self.task_abi.as_str().as_bytes().to_vec(), + self.source_provider_manifest.as_str().as_bytes().to_vec(), + self.default_entrypoint.as_bytes().to_vec(), + format!("{:?}", self.source_transfer_policy).into_bytes(), + ]; + + let mut entrypoints = self.entrypoints.clone(); + entrypoints.sort(); + for entrypoint in entrypoints { + parts.push(entrypoint.into_bytes()); + } + + let mut environments = self.environments.clone(); + environments.sort_by(|left, right| left.name.cmp(&right.name)); + for environment in environments { + parts.push(environment.name.as_bytes().to_vec()); + parts.push(format!("{:?}", environment.kind).into_bytes()); + parts.push(environment.digest.as_str().as_bytes().to_vec()); + } + + let mut inputs = self.selected_inputs.clone(); + inputs.sort_by(|left, right| left.path.cmp(&right.path)); + for input in inputs { + parts.push(input.path.into_bytes()); + parts.push(input.digest.as_str().as_bytes().to_vec()); + } + + Digest::from_parts(parts) + } + + pub fn inspectable_metadata(&self) -> BundleMetadata { + let mut entrypoints = self.entrypoints.clone(); + entrypoints.sort(); + + BundleMetadata { + identity: self.identity(), + wasm_code: self.wasm_code.clone(), + task_metadata: BundleTaskMetadata { + task_abi: self.task_abi.clone(), + entrypoints, + default_entrypoint: self.default_entrypoint.clone(), + boundary: "disasmer_task_exports".to_owned(), + }, + source_metadata: BundleSourceMetadata { + source_provider_manifest: self.source_provider_manifest.clone(), + transfer_policy: self.source_transfer_policy.clone(), + selected_inputs: self.selected_inputs.clone(), + }, + debug_metadata: BundleDebugMetadata { + available: true, + source_level_breakpoints: true, + dap_virtual_process: true, + variables_pane_supported: true, + probes: Vec::new(), + }, + large_input_policy: BundleLargeInputPolicy { + selected_inputs_are_content_digests: true, + selected_input_bytes_included: false, + full_repository_bytes_included: false, + silent_task_argument_serialization: false, + supported_handle_types: vec![ + "SourceSnapshot".to_owned(), + "Blob".to_owned(), + "Artifact".to_owned(), + "VFS".to_owned(), + ], + }, + restart_compatibility: BundleRestartCompatibility { + source_edits_can_restart_from_clean_task_boundary: true, + requires_clean_checkpoint_boundary: true, + compares_task_abi: self.task_abi.clone(), + compares_environment_digests: true, + compares_serialized_args: true, + discards_unflushed_task_local_changes: true, + incompatible_changes_require_whole_process_restart: true, + }, + environments: self.environments.clone(), + selected_inputs: self.selected_inputs.clone(), + embeds_full_container_images: false, + } + } +} + +pub fn discover_source_debug_probes( + source_path: impl Into, + source: &str, +) -> Vec { + let source_path = source_path.into(); + let lines = source.lines().collect::>(); + let function_starts = lines + .iter() + .enumerate() + .filter_map(|(index, line)| { + parse_rust_function_name(line).map(|function| (index, function)) + }) + .collect::>(); + let Ok(file) = syn::parse_file(source) else { + return Vec::new(); + }; + + file.items + .iter() + .filter_map(|item| { + let Item::Fn(function) = item else { + return None; + }; + let function_name = function.sig.ident.to_string(); + let task = disasmer_probe_task(function)?; + let (function_index, (line_index, _)) = function_starts + .iter() + .enumerate() + .find(|(_, (_, candidate))| candidate == &function_name)?; + let line_start = (*line_index + 1) as u32; + let next_start = function_starts + .get(function_index + 1) + .map(|(next_index, _)| *next_index) + .unwrap_or(lines.len()); + let line_end = next_start.max(*line_index + 1) as u32; + Some(debug_probe( + &source_path, + line_start, + line_end, + &function_name, + task, + )) + }) + .collect() +} + +fn debug_probe( + source_path: &str, + line_start: u32, + line_end: u32, + function: &str, + task: TaskDefinitionId, +) -> BundleDebugProbe { + let id = Digest::from_parts([ + b"bundle-debug-probe:v1".as_slice(), + source_path.as_bytes(), + function.as_bytes(), + task.as_str().as_bytes(), + line_start.to_string().as_bytes(), + line_end.to_string().as_bytes(), + ]) + .as_str() + .to_owned(); + BundleDebugProbe { + id, + source_path: source_path.to_owned(), + line_start, + line_end, + function: function.to_owned(), + task, + } +} + +fn parse_rust_function_name(line: &str) -> Option { + let start = line.find("fn ")? + 3; + let rest = &line[start..]; + let name = rest + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + (!name.is_empty()).then_some(name) +} + +fn disasmer_probe_task(function: &syn::ItemFn) -> Option { + for attribute in &function.attrs { + let mut segments = attribute.path().segments.iter(); + let Some(namespace) = segments.next() else { + continue; + }; + let Some(kind) = segments.next() else { + continue; + }; + if namespace.ident != "disasmer" || segments.next().is_some() { + continue; + } + let function_name = function.sig.ident.to_string(); + let default_name = match kind.ident.to_string().as_str() { + "main" => function_name + .strip_suffix("_main") + .unwrap_or(&function_name) + .to_owned(), + "task" => function_name, + _ => continue, + }; + let declared_name = match &attribute.meta { + Meta::List(list) => { + let parser = syn::punctuated::Punctuated::::parse_terminated; + parser + .parse2(list.tokens.clone()) + .ok() + .and_then(|items| { + items.into_iter().find_map(|item| { + let Meta::NameValue(name_value) = item else { + return None; + }; + if !name_value.path.is_ident("name") { + return None; + } + let Expr::Lit(value) = name_value.value else { + return None; + }; + let Lit::Str(value) = value.lit else { + return None; + }; + Some(value.value()) + }) + }) + .unwrap_or(default_name) + } + _ => default_name, + }; + return Some(TaskDefinitionId::new(declared_name)); + } + None +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{EnvironmentKind, EnvironmentRequirements}; + + use super::*; + + fn env(digest: &str) -> EnvironmentResource { + EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: PathBuf::from("envs/linux/Containerfile"), + context_path: PathBuf::from("envs/linux"), + digest: Digest::sha256(digest), + requirements: EnvironmentRequirements::linux_container(), + } + } + + #[test] + fn bundle_identity_changes_when_environment_recipe_changes() { + let base = BundleIdentityInputs { + wasm_code: Digest::sha256("wasm"), + task_abi: Digest::sha256("abi"), + entrypoints: vec!["build".to_owned()], + default_entrypoint: "build".to_owned(), + environments: vec![env("recipe-a")], + source_provider_manifest: Digest::sha256("source"), + source_transfer_policy: SourceTransferPolicy::local_first_snapshot_chunks(), + selected_inputs: vec![], + }; + let mut changed = base.clone(); + changed.environments = vec![env("recipe-b")]; + + assert_ne!(base.identity(), changed.identity()); + } + + #[test] + fn bundle_metadata_is_inspectable_and_does_not_vendor_images_by_default() { + let inputs = BundleIdentityInputs { + wasm_code: Digest::sha256("wasm"), + task_abi: Digest::sha256("abi"), + entrypoints: vec!["build".to_owned(), "test".to_owned()], + default_entrypoint: "build".to_owned(), + environments: vec![env("recipe")], + source_provider_manifest: Digest::sha256("source"), + source_transfer_policy: SourceTransferPolicy::local_first_snapshot_chunks(), + selected_inputs: vec![SelectedInput { + path: "inputs/config.json".to_owned(), + digest: Digest::sha256("config"), + }], + }; + + let metadata = inputs.inspectable_metadata(); + + assert!(metadata.wasm_code.as_str().starts_with("sha256:")); + assert_eq!(metadata.task_metadata.default_entrypoint, "build"); + assert!(metadata + .task_metadata + .entrypoints + .contains(&"test".to_owned())); + assert!(metadata.debug_metadata.dap_virtual_process); + assert!( + metadata + .source_metadata + .transfer_policy + .local_source_bytes_remain_node_local + ); + assert!( + metadata + .large_input_policy + .selected_inputs_are_content_digests + ); + assert!(!metadata.large_input_policy.selected_input_bytes_included); + assert!(!metadata.large_input_policy.full_repository_bytes_included); + assert!( + !metadata + .large_input_policy + .silent_task_argument_serialization + ); + assert!(metadata + .large_input_policy + .supported_handle_types + .contains(&"Artifact".to_owned())); + assert!( + metadata + .restart_compatibility + .source_edits_can_restart_from_clean_task_boundary + ); + assert!( + metadata + .restart_compatibility + .requires_clean_checkpoint_boundary + ); + assert_eq!( + metadata.restart_compatibility.compares_task_abi, + inputs.task_abi + ); + assert!( + metadata + .restart_compatibility + .incompatible_changes_require_whole_process_restart + ); + assert_eq!(metadata.environments.len(), 1); + assert!(!metadata.embeds_full_container_images); + } + + #[test] + fn source_debug_probe_metadata_maps_function_ranges_to_tasks() { + let probes = discover_source_debug_probes( + "src/build.rs", + r#"#[disasmer::main] +fn build_main() { + let linux = compile_linux(); +} + +#[disasmer::task] +fn compile_linux() { + println!("linux"); +} + +fn helper_without_runtime_probe() {} + +#[disasmer::task(name = "release")] +fn package_release() { + println!("package"); +} +"#, + ); + + assert_eq!(probes.len(), 3); + assert_eq!(probes[0].source_path, "src/build.rs"); + assert_eq!(probes[0].function, "build_main"); + assert_eq!(probes[0].task, TaskDefinitionId::from("build")); + assert_eq!(probes[0].line_start, 2); + assert_eq!(probes[0].line_end, 6); + assert_eq!(probes[1].function, "compile_linux"); + assert_eq!(probes[1].task, TaskDefinitionId::from("compile_linux")); + assert_eq!(probes[2].function, "package_release"); + assert_eq!(probes[2].task, TaskDefinitionId::from("release")); + assert!(probes.iter().all(|probe| probe.id.starts_with("sha256:"))); + } +} diff --git a/crates/disasmer-core/src/capability.rs b/crates/disasmer-core/src/capability.rs new file mode 100644 index 0000000..0e3e818 --- /dev/null +++ b/crates/disasmer-core/src/capability.rs @@ -0,0 +1,218 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Capability { + Command, + Containers, + RootlessPodman, + SourceFilesystem, + SourceGit, + HostFilesystem, + Network, + Secrets, + InboundPorts, + ArbitrarySyscalls, + VfsArtifacts, + WindowsCommandDev, + QuicDirect, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum EnvironmentBackend { + Container, + NixFlake, + WindowsCommandDev, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Os { + Linux, + Windows, + Macos, + Other(String), +} + +impl Os { + pub fn current() -> Self { + match std::env::consts::OS { + "linux" => Self::Linux, + "windows" => Self::Windows, + "macos" => Self::Macos, + other => Self::Other(other.to_owned()), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeCapabilities { + pub os: Os, + pub arch: String, + pub capabilities: BTreeSet, + pub environment_backends: BTreeSet, + pub source_providers: BTreeSet, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CapabilityReportError { + #[error("node architecture `{0}` is invalid")] + InvalidArchitecture(String), + #[error("node OS label `{0}` is invalid")] + InvalidOsLabel(String), + #[error("source provider id `{0}` is invalid")] + InvalidSourceProvider(String), +} + +impl NodeCapabilities { + pub fn detect_current() -> Self { + let os = Os::current(); + let mut capabilities = BTreeSet::from([ + Capability::Command, + Capability::SourceFilesystem, + Capability::VfsArtifacts, + ]); + let mut environment_backends = BTreeSet::new(); + + match os { + Os::Linux => { + if rootless_podman_available() { + capabilities.insert(Capability::Containers); + capabilities.insert(Capability::RootlessPodman); + environment_backends.insert(EnvironmentBackend::Container); + } + } + Os::Windows => { + capabilities.insert(Capability::WindowsCommandDev); + environment_backends.insert(EnvironmentBackend::WindowsCommandDev); + } + Os::Macos | Os::Other(_) => {} + } + + Self { + os, + arch: std::env::consts::ARCH.to_owned(), + capabilities, + environment_backends, + source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]), + } + } + + pub fn with_capability(mut self, capability: Capability) -> Self { + self.capabilities.insert(capability); + self + } + + pub fn has_all(&self, required: &BTreeSet) -> bool { + required + .iter() + .all(|capability| self.capabilities.contains(capability)) + } + + pub fn validate_public_report(&self) -> Result<(), CapabilityReportError> { + if !valid_capability_label(&self.arch) { + return Err(CapabilityReportError::InvalidArchitecture( + self.arch.clone(), + )); + } + if let Os::Other(label) = &self.os { + if !valid_capability_label(label) { + return Err(CapabilityReportError::InvalidOsLabel(label.clone())); + } + } + for provider in &self.source_providers { + if !valid_source_provider_id(provider) { + return Err(CapabilityReportError::InvalidSourceProvider( + provider.clone(), + )); + } + } + Ok(()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn rootless_podman_available() -> bool { + const ATTEMPTS: usize = 3; + for attempt in 0..ATTEMPTS { + match std::process::Command::new("podman") + .args(["info", "--format", "{{.Host.Security.Rootless}}"]) + .output() + { + Ok(output) if rootless_podman_probe_succeeded(&output) => return true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false, + Ok(_) | Err(_) if attempt + 1 < ATTEMPTS => { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + Ok(_) | Err(_) => {} + } + } + false +} + +#[cfg(not(target_arch = "wasm32"))] +fn rootless_podman_probe_succeeded(output: &std::process::Output) -> bool { + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" +} + +#[cfg(target_arch = "wasm32")] +fn rootless_podman_available() -> bool { + false +} + +fn valid_capability_label(label: &str) -> bool { + !label.is_empty() + && label.len() <= 64 + && label.bytes().all( + |byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'), + ) +} + +fn valid_source_provider_id(provider: &str) -> bool { + !provider.is_empty() + && provider.len() <= 64 + && provider + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities() -> NodeCapabilities { + NodeCapabilities { + os: Os::Linux, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([Capability::Command]), + environment_backends: BTreeSet::new(), + source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]), + } + } + + #[test] + fn capability_reports_validate_hostile_strings() { + assert!(capabilities().validate_public_report().is_ok()); + + let mut invalid_arch = capabilities(); + invalid_arch.arch = "x86_64\nmalicious".to_owned(); + assert_eq!( + invalid_arch.validate_public_report(), + Err(CapabilityReportError::InvalidArchitecture( + "x86_64\nmalicious".to_owned() + )) + ); + + let mut invalid_provider = capabilities(); + invalid_provider + .source_providers + .insert("../checkout".to_owned()); + assert_eq!( + invalid_provider.validate_public_report(), + Err(CapabilityReportError::InvalidSourceProvider( + "../checkout".to_owned() + )) + ); + } +} diff --git a/crates/disasmer-core/src/checkpoint.rs b/crates/disasmer-core/src/checkpoint.rs new file mode 100644 index 0000000..bb92209 --- /dev/null +++ b/crates/disasmer-core/src/checkpoint.rs @@ -0,0 +1,284 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Digest, TaskInstanceId, VfsManifest}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckpointBoundary { + pub task_entrypoint: String, + pub serialized_args: Digest, + pub environment_digest: Digest, + pub vfs_epoch: u64, + pub task_abi: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskCheckpoint { + pub task: TaskInstanceId, + pub boundary: CheckpointBoundary, + pub vfs_manifest: VfsManifest, + pub depends_on_live_stack: bool, + pub depends_on_live_socket: bool, + pub depends_on_ephemeral_artifact_durability: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RestartRequest { + pub task: TaskInstanceId, + pub entrypoint: String, + pub serialized_args: Digest, + pub environment_digest: Digest, + pub task_abi: Digest, + pub source_edited: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RestartDecision { + RestartTask { + task: TaskInstanceId, + from_vfs_epoch: u64, + discard_unflushed_changes: bool, + }, + RestartWholeVirtualProcess { + message: String, + }, +} + +#[derive(Clone, Debug, Default)] +pub struct RestartPolicy; + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CompatibilityFailure { + #[error("task entrypoint changed")] + Entrypoint, + #[error("serialized task arguments changed")] + Args, + #[error("environment digest changed")] + Environment, + #[error("task ABI changed")] + TaskAbi, + #[error("checkpoint depends on unsupported live stack migration")] + LiveStack, + #[error("checkpoint depends on unsupported live socket checkpointing")] + LiveSocket, + #[error("checkpoint incorrectly treats ephemeral artifacts as durable")] + EphemeralArtifactDurability, +} + +impl RestartPolicy { + pub fn decide(&self, checkpoint: &TaskCheckpoint, request: &RestartRequest) -> RestartDecision { + match compatibility_failure(checkpoint, request) { + None => RestartDecision::RestartTask { + task: checkpoint.task.clone(), + from_vfs_epoch: checkpoint.boundary.vfs_epoch, + discard_unflushed_changes: true, + }, + Some(failure) => RestartDecision::RestartWholeVirtualProcess { + message: format!( + "cannot restart selected task `{}` from checkpoint: {failure}; restart the whole virtual process", + checkpoint.task + ), + }, + } + } +} + +fn compatibility_failure( + checkpoint: &TaskCheckpoint, + request: &RestartRequest, +) -> Option { + if checkpoint.depends_on_live_stack { + return Some(CompatibilityFailure::LiveStack); + } + if checkpoint.depends_on_live_socket { + return Some(CompatibilityFailure::LiveSocket); + } + if checkpoint.depends_on_ephemeral_artifact_durability { + return Some(CompatibilityFailure::EphemeralArtifactDurability); + } + if checkpoint.boundary.task_entrypoint != request.entrypoint { + return Some(CompatibilityFailure::Entrypoint); + } + if checkpoint.boundary.serialized_args != request.serialized_args { + return Some(CompatibilityFailure::Args); + } + if checkpoint.boundary.environment_digest != request.environment_digest { + return Some(CompatibilityFailure::Environment); + } + if checkpoint.boundary.task_abi != request.task_abi { + return Some(CompatibilityFailure::TaskAbi); + } + None +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{ + EnvironmentKind, EnvironmentRequirements, EnvironmentResource, NodeId, VfsOverlay, VfsPath, + }; + + use super::*; + + fn env(digest_input: &str) -> EnvironmentResource { + EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: PathBuf::from("envs/linux/Containerfile"), + context_path: PathBuf::from("envs/linux"), + digest: Digest::sha256(digest_input), + requirements: EnvironmentRequirements::linux_container(), + } + } + + fn checkpoint() -> (TaskCheckpoint, EnvironmentResource) { + let environment = env("env"); + let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node")); + overlay.write( + VfsPath::new("/vfs/artifacts/app").unwrap(), + Digest::sha256("app"), + 3, + ); + let manifest = overlay.flush(); + ( + TaskCheckpoint { + task: TaskInstanceId::from("task"), + boundary: CheckpointBoundary { + task_entrypoint: "compile_linux".to_owned(), + serialized_args: Digest::sha256("args"), + environment_digest: environment.digest.clone(), + vfs_epoch: manifest.epoch, + task_abi: Digest::sha256("abi"), + }, + vfs_manifest: manifest, + depends_on_live_stack: false, + depends_on_live_socket: false, + depends_on_ephemeral_artifact_durability: false, + }, + environment, + ) + } + + fn restart_request(environment: EnvironmentResource) -> RestartRequest { + RestartRequest { + task: TaskInstanceId::from("task"), + entrypoint: "compile_linux".to_owned(), + serialized_args: Digest::sha256("args"), + environment_digest: environment.digest, + task_abi: Digest::sha256("abi"), + source_edited: true, + } + } + + fn assert_whole_process_restart(decision: RestartDecision, expected_reason: &str) { + match decision { + RestartDecision::RestartWholeVirtualProcess { message } => { + assert!( + message.contains(expected_reason), + "restart message `{message}` did not include `{expected_reason}`" + ); + assert!( + message.contains("restart the whole virtual process"), + "restart message `{message}` did not direct a whole-process restart" + ); + } + RestartDecision::RestartTask { .. } => { + panic!("incompatible checkpoint unexpectedly restarted selected task") + } + } + } + + #[test] + fn compatible_restart_uses_task_boundary_and_discards_unflushed_changes() { + let (checkpoint, environment) = checkpoint(); + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_eq!( + decision, + RestartDecision::RestartTask { + task: TaskInstanceId::from("task"), + from_vfs_epoch: 1, + discard_unflushed_changes: true + } + ); + } + + #[test] + fn incompatible_environment_requires_whole_process_restart() { + let (checkpoint, _) = checkpoint(); + let request = restart_request(env("changed-env")); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "environment digest changed"); + } + + #[test] + fn incompatible_entrypoint_requires_whole_process_restart() { + let (checkpoint, environment) = checkpoint(); + let mut request = restart_request(environment); + request.entrypoint = "package_linux".to_owned(); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "task entrypoint changed"); + } + + #[test] + fn incompatible_serialized_args_require_whole_process_restart() { + let (checkpoint, environment) = checkpoint(); + let mut request = restart_request(environment); + request.serialized_args = Digest::sha256("changed-args"); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "serialized task arguments changed"); + } + + #[test] + fn incompatible_task_abi_requires_whole_process_restart() { + let (checkpoint, environment) = checkpoint(); + let mut request = restart_request(environment); + request.task_abi = Digest::sha256("changed-abi"); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "task ABI changed"); + } + + #[test] + fn restart_never_claims_live_stack_migration() { + let (mut checkpoint, environment) = checkpoint(); + checkpoint.depends_on_live_stack = true; + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "live stack"); + } + + #[test] + fn restart_never_claims_live_socket_checkpointing() { + let (mut checkpoint, environment) = checkpoint(); + checkpoint.depends_on_live_socket = true; + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "live socket"); + } + + #[test] + fn restart_never_depends_on_ephemeral_artifact_durability() { + let (mut checkpoint, environment) = checkpoint(); + checkpoint.depends_on_ephemeral_artifact_durability = true; + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "ephemeral artifacts"); + } +} diff --git a/crates/disasmer-core/src/debug.rs b/crates/disasmer-core/src/debug.rs new file mode 100644 index 0000000..a5da275 --- /dev/null +++ b/crates/disasmer-core/src/debug.rs @@ -0,0 +1,278 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ProcessId, TaskInstanceId}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DebugParticipantKind { + WasmTask, + ControlledNativeCommand, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DebugRuntimeState { + Running, + Frozen, + Completed, + Failed(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebugParticipant { + pub task: TaskInstanceId, + pub name: String, + pub kind: DebugParticipantKind, + pub can_freeze: bool, + pub state: DebugRuntimeState, + pub stack_frames: Vec, + pub local_values: Vec<(String, String)>, + pub task_args: Vec<(String, String)>, + pub handles: Vec<(String, String)>, + pub command_status: Option, + pub recent_output: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DebugStopReason { + Breakpoint { task: TaskInstanceId, line: u32 }, + PauseRequest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThreadInspection { + pub task: TaskInstanceId, + pub name: String, + pub stack_frames: Vec, + pub local_values: Vec<(String, String)>, + pub task_args: Vec<(String, String)>, + pub handles: Vec<(String, String)>, + pub command_status: Option, + pub recent_output: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebugEpoch { + pub process: ProcessId, + pub epoch: u64, + pub reason: DebugStopReason, + participants: BTreeMap, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum DebugEpochError { + #[error("participant `{task}` cannot freeze, so all-stop failed")] + CannotFreeze { task: TaskInstanceId }, + #[error("participant `{0}` is not part of this debug epoch")] + UnknownParticipant(TaskInstanceId), +} + +impl DebugEpoch { + pub fn all_stop( + process: ProcessId, + epoch: u64, + reason: DebugStopReason, + participants: Vec, + ) -> Result { + for participant in &participants { + if matches!( + participant.kind, + DebugParticipantKind::WasmTask | DebugParticipantKind::ControlledNativeCommand + ) && !participant.can_freeze + { + return Err(DebugEpochError::CannotFreeze { + task: participant.task.clone(), + }); + } + } + + let participants = participants + .into_iter() + .map(|mut participant| { + if matches!(participant.state, DebugRuntimeState::Running) { + participant.state = DebugRuntimeState::Frozen; + } + (participant.task.clone(), participant) + }) + .collect(); + + Ok(Self { + process, + epoch, + reason, + participants, + }) + } + + pub fn pause( + process: ProcessId, + epoch: u64, + participants: Vec, + ) -> Result { + Self::all_stop(process, epoch, DebugStopReason::PauseRequest, participants) + } + + pub fn continue_all(&mut self) { + for participant in self.participants.values_mut() { + if participant.state == DebugRuntimeState::Frozen { + participant.state = DebugRuntimeState::Running; + } + } + } + + pub fn inspection(&self, task: &TaskInstanceId) -> Result { + let participant = self + .participants + .get(task) + .ok_or_else(|| DebugEpochError::UnknownParticipant(task.clone()))?; + Ok(ThreadInspection { + task: participant.task.clone(), + name: participant.name.clone(), + stack_frames: participant.stack_frames.clone(), + local_values: participant.local_values.clone(), + task_args: participant.task_args.clone(), + handles: participant.handles.clone(), + command_status: participant.command_status.clone(), + recent_output: participant.recent_output.clone(), + }) + } + + pub fn participant_state(&self, task: &TaskInstanceId) -> Option<&DebugRuntimeState> { + self.participants + .get(task) + .map(|participant| &participant.state) + } + + pub fn thread_names(&self) -> Vec { + self.participants + .values() + .map(|participant| participant.name.clone()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn participant(task: &str, kind: DebugParticipantKind, can_freeze: bool) -> DebugParticipant { + DebugParticipant { + task: TaskInstanceId::from(task), + name: task.to_owned(), + kind, + can_freeze, + state: DebugRuntimeState::Running, + stack_frames: vec![format!("{task}::run")], + local_values: vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())], + task_args: vec![("target".to_owned(), "linux".to_owned())], + handles: vec![("artifact".to_owned(), "artifact-1".to_owned())], + command_status: Some("running".to_owned()), + recent_output: vec!["building".to_owned()], + } + } + + #[test] + fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks() { + let epoch = DebugEpoch::all_stop( + ProcessId::from("process"), + 1, + DebugStopReason::Breakpoint { + task: TaskInstanceId::from("compile-linux"), + line: 42, + }, + vec![ + participant("main", DebugParticipantKind::WasmTask, true), + participant( + "compile-linux", + DebugParticipantKind::ControlledNativeCommand, + true, + ), + ], + ) + .unwrap(); + + assert_eq!( + epoch.participant_state(&TaskInstanceId::from("main")), + Some(&DebugRuntimeState::Frozen) + ); + assert_eq!( + epoch.participant_state(&TaskInstanceId::from("compile-linux")), + Some(&DebugRuntimeState::Frozen) + ); + } + + #[test] + fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop() { + let error = DebugEpoch::pause( + ProcessId::from("process"), + 1, + vec![participant( + "compile-linux", + DebugParticipantKind::ControlledNativeCommand, + false, + )], + ) + .unwrap_err(); + + assert!(matches!(error, DebugEpochError::CannotFreeze { .. })); + } + + #[test] + fn continue_resumes_every_frozen_participant() { + let mut epoch = DebugEpoch::pause( + ProcessId::from("process"), + 1, + vec![ + participant("main", DebugParticipantKind::WasmTask, true), + participant("task", DebugParticipantKind::WasmTask, true), + ], + ) + .unwrap(); + + epoch.continue_all(); + + assert_eq!( + epoch.participant_state(&TaskInstanceId::from("main")), + Some(&DebugRuntimeState::Running) + ); + assert_eq!( + epoch.participant_state(&TaskInstanceId::from("task")), + Some(&DebugRuntimeState::Running) + ); + } + + #[test] + fn inspection_exposes_stack_args_handles_command_status_and_output() { + let epoch = DebugEpoch::pause( + ProcessId::from("process"), + 1, + vec![participant( + "compile-linux", + DebugParticipantKind::ControlledNativeCommand, + true, + )], + ) + .unwrap(); + + let inspection = epoch + .inspection(&TaskInstanceId::from("compile-linux")) + .unwrap(); + + assert_eq!(inspection.stack_frames, vec!["compile-linux::run"]); + assert_eq!( + inspection.local_values[0], + ("wasm_local_0".to_owned(), "I32(41)".to_owned()) + ); + assert_eq!( + inspection.task_args[0], + ("target".to_owned(), "linux".to_owned()) + ); + assert_eq!( + inspection.handles[0], + ("artifact".to_owned(), "artifact-1".to_owned()) + ); + assert_eq!(inspection.command_status, Some("running".to_owned())); + assert_eq!(inspection.recent_output, vec!["building"]); + } +} diff --git a/crates/disasmer-core/src/digest.rs b/crates/disasmer-core/src/digest.rs new file mode 100644 index 0000000..67e724d --- /dev/null +++ b/crates/disasmer-core/src/digest.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest as ShaDigest, Sha256}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Digest(String); + +impl Digest { + pub fn sha256(bytes: impl AsRef<[u8]>) -> Self { + let mut hasher = Sha256::new(); + hasher.update(bytes.as_ref()); + Self(format!("sha256:{}", hex::encode(hasher.finalize()))) + } + + pub fn from_parts(parts: impl IntoIterator>) -> Self { + let mut hasher = Sha256::new(); + for part in parts { + let part = part.as_ref(); + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + Self(format!("sha256:{}", hex::encode(hasher.finalize()))) + } + + pub fn from_sha256_hex(hex_digest: impl Into) -> Result { + let digest = Self(format!("sha256:{}", hex_digest.into())); + if digest.is_valid_sha256() { + Ok(digest) + } else { + Err( + "SHA-256 digest must contain exactly 64 lowercase hexadecimal characters" + .to_owned(), + ) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_valid_sha256(&self) -> bool { + let Some(hex) = self.0.strip_prefix("sha256:") else { + return false; + }; + hex.len() == 64 + && hex + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + } +} + +impl std::fmt::Display for Digest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_validates_strict_sha256_syntax() { + assert!(Digest::sha256("bytes").is_valid_sha256()); + assert!(!Digest("sha1:abc".to_owned()).is_valid_sha256()); + assert!(!Digest("sha256:ABCDEF".to_owned()).is_valid_sha256()); + assert!(!Digest("sha256:not-hex".to_owned()).is_valid_sha256()); + } +} diff --git a/crates/disasmer-core/src/environment.rs b/crates/disasmer-core/src/environment.rs new file mode 100644 index 0000000..ea2aee5 --- /dev/null +++ b/crates/disasmer-core/src/environment.rs @@ -0,0 +1,288 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Capability, Digest, Os}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum EnvironmentKind { + Containerfile, + Dockerfile, + NixFlake, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentRequirements { + pub os: Option, + pub arch: Option, + pub capabilities: BTreeSet, +} + +impl EnvironmentRequirements { + pub fn linux_container() -> Self { + Self { + os: Some(Os::Linux), + arch: None, + capabilities: BTreeSet::from([Capability::Containers, Capability::RootlessPodman]), + } + } + + pub fn windows_command_dev() -> Self { + Self { + os: Some(Os::Windows), + arch: None, + capabilities: BTreeSet::from([Capability::WindowsCommandDev]), + } + } + + pub fn unconstrained() -> Self { + Self { + os: None, + arch: None, + capabilities: BTreeSet::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentResource { + pub name: String, + pub kind: EnvironmentKind, + pub recipe_path: PathBuf, + pub context_path: PathBuf, + pub digest: Digest, + pub requirements: EnvironmentRequirements, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentReference { + pub name: String, + pub byte_offset: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentDiagnostic { + pub reference: EnvironmentReference, + pub message: String, +} + +#[derive(Debug, Error)] +pub enum EnvironmentError { + #[error("failed to read environment resources under {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +pub fn discover_environments( + project_root: &Path, +) -> Result, EnvironmentError> { + let envs_dir = project_root.join("envs"); + if !envs_dir.exists() { + return Ok(Vec::new()); + } + + let mut resources = Vec::new(); + let entries = fs::read_dir(&envs_dir).map_err(|source| EnvironmentError::Read { + path: envs_dir.clone(), + source, + })?; + + for entry in entries { + let entry = entry.map_err(|source| EnvironmentError::Read { + path: envs_dir.clone(), + source, + })?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if let Some(resource) = discover_one(project_root, name, &path)? { + resources.push(resource); + } + } + + resources.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(resources) +} + +pub fn diagnose_environment_references( + source: &str, + environments: &[EnvironmentResource], +) -> Vec { + let known = environments + .iter() + .map(|environment| environment.name.as_str()) + .collect::>(); + + find_env_macro_references(source) + .into_iter() + .filter(|reference| !known.contains(reference.name.as_str())) + .map(|reference| EnvironmentDiagnostic { + message: format!( + "missing Disasmer environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile", + reference.name, reference.name, reference.name + ), + reference, + }) + .collect() +} + +fn discover_one( + project_root: &Path, + name: &str, + env_dir: &Path, +) -> Result, EnvironmentError> { + let candidates = [ + ("Containerfile", EnvironmentKind::Containerfile), + ("Dockerfile", EnvironmentKind::Dockerfile), + ("flake.nix", EnvironmentKind::NixFlake), + ]; + + for (file_name, kind) in candidates { + let recipe_path = env_dir.join(file_name); + if !recipe_path.exists() { + continue; + } + + let recipe_bytes = fs::read(&recipe_path).map_err(|source| EnvironmentError::Read { + path: recipe_path.clone(), + source, + })?; + let relative_recipe = recipe_path + .strip_prefix(project_root) + .unwrap_or(&recipe_path) + .to_string_lossy(); + let digest = Digest::from_parts([ + b"environment:v1".as_slice(), + name.as_bytes(), + format!("{kind:?}").as_bytes(), + relative_recipe.as_bytes(), + recipe_bytes.as_slice(), + ]); + let requirements = match kind { + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile + if name.eq_ignore_ascii_case("windows") => + { + EnvironmentRequirements::windows_command_dev() + } + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => { + EnvironmentRequirements::linux_container() + } + EnvironmentKind::NixFlake => EnvironmentRequirements::unconstrained(), + }; + + return Ok(Some(EnvironmentResource { + name: name.to_owned(), + kind, + recipe_path, + context_path: env_dir.to_path_buf(), + digest, + requirements, + })); + } + + Ok(None) +} + +fn find_env_macro_references(source: &str) -> Vec { + let mut references = Vec::new(); + let mut cursor = 0; + + while let Some(index) = source[cursor..].find("env!(") { + let start = cursor + index; + let mut pos = start + "env!(".len(); + while source[pos..].starts_with(char::is_whitespace) { + pos += source[pos..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(1); + } + if !source[pos..].starts_with('"') { + cursor = pos; + continue; + } + pos += 1; + let name_start = pos; + while pos < source.len() && !source[pos..].starts_with('"') { + pos += source[pos..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(1); + } + if pos < source.len() { + references.push(EnvironmentReference { + name: source[name_start..pos].to_owned(), + byte_offset: start, + }); + } + cursor = pos.saturating_add(1); + } + + references +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn discovers_containerfile_environments_by_logical_name() { + let temp = tempfile::tempdir().unwrap(); + let linux = temp.path().join("envs/linux"); + fs::create_dir_all(&linux).unwrap(); + fs::write(linux.join("Containerfile"), "FROM alpine\n").unwrap(); + + let envs = discover_environments(temp.path()).unwrap(); + + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].name, "linux"); + assert_eq!(envs[0].kind, EnvironmentKind::Containerfile); + assert!(!envs[0].digest.as_str().is_empty()); + } + + #[test] + fn missing_env_macro_reference_reports_clear_diagnostic() { + let source = r#"fn main() { let _ = env!("windows"); }"#; + let diagnostics = diagnose_environment_references(source, &[]); + + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0] + .message + .contains("envs/windows/Containerfile")); + } + + #[test] + fn windows_environment_name_uses_windows_development_requirements() { + let temp = tempfile::tempdir().unwrap(); + let windows = temp.path().join("envs/windows"); + fs::create_dir_all(&windows).unwrap(); + fs::write( + windows.join("Dockerfile"), + "# user-attached windows dev contract\n", + ) + .unwrap(); + + let envs = discover_environments(temp.path()).unwrap(); + + assert_eq!(envs[0].name, "windows"); + assert_eq!(envs[0].requirements.os, Some(Os::Windows)); + assert!(envs[0] + .requirements + .capabilities + .contains(&Capability::WindowsCommandDev)); + } +} diff --git a/crates/disasmer-core/src/execution.rs b/crates/disasmer-core/src/execution.rs new file mode 100644 index 0000000..0ab9c53 --- /dev/null +++ b/crates/disasmer-core/src/execution.rs @@ -0,0 +1,764 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + ArtifactHandle, ArtifactId, Capability, Digest, EnvironmentRequirements, EnvironmentResource, + NodeId, ProcessId, ProjectId, TaskDefinitionId, TaskInstanceId, TenantId, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum GuestRuntimeKind { + Wasmtime, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CommandBackendKind { + LinuxRootlessPodman, + WindowsCommandDev, + StubbedWindowsSandbox, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandNetworkPolicy { + Disabled, + Enabled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandInvocation { + pub program: String, + pub args: Vec, + pub working_directory: String, + pub environment_variables: BTreeMap, + pub timeout_ms: u64, + pub network: CommandNetworkPolicy, + pub env: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandPlan { + pub guest_runtime: GuestRuntimeKind, + pub backend: CommandBackendKind, + pub required_capability: Capability, + pub user_attached_development_execution: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeCommandPolicy { + pub hosted_control_plane: bool, + pub node_has_command_capability: bool, +} + +impl NativeCommandPolicy { + pub fn authorize(&self) -> Result<(), String> { + if self.hosted_control_plane { + return Err("hosted coordinator control plane cannot run native commands".to_owned()); + } + if !self.node_has_command_capability { + return Err("selected node or task lacks native command capability".to_owned()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TaskBoundaryValue { + SmallJson(serde_json::Value), + Structured(StructuredTaskBoundary), + SourceSnapshot(crate::Digest), + Blob(crate::Digest), + Artifact(ArtifactHandle), + VfsManifest(crate::Digest), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum TaskBoundaryHandle { + SourceSnapshot(crate::Digest), + Blob(crate::Digest), + Artifact(crate::ArtifactId), + VfsManifest(crate::Digest), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StructuredTaskBoundary { + pub value: serde_json::Value, + pub handles: Vec, +} + +impl TaskBoundaryValue { + pub fn required_artifacts(&self) -> Vec { + match self { + Self::Artifact(artifact) => vec![artifact.id.clone()], + Self::Structured(structured) => structured + .handles + .iter() + .filter_map(|handle| match handle { + TaskBoundaryHandle::Artifact(artifact) => Some(artifact.clone()), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + } + + pub fn source_snapshots(&self) -> Vec { + match self { + Self::SourceSnapshot(snapshot) => vec![snapshot.clone()], + Self::Structured(structured) => structured + .handles + .iter() + .filter_map(|handle| match handle { + TaskBoundaryHandle::SourceSnapshot(snapshot) => Some(snapshot.clone()), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + } +} + +pub const WASM_TASK_ABI_VERSION: u32 = 1; +pub const MAX_WASM_TASK_ENVELOPE_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostTaskStartRequest { + pub abi_version: u32, + pub task_definition: TaskDefinitionId, + pub environment_id: Option, + pub args: Vec, +} + +impl WasmHostTaskStartRequest { + pub fn validate(&self) -> Result<(), String> { + WasmTaskInvocation { + abi_version: self.abi_version, + task_definition: self.task_definition.clone(), + task_instance: TaskInstanceId::from("validation-only-instance"), + args: self.args.clone(), + } + .validate()?; + if self + .environment_id + .as_deref() + .is_some_and(|environment| environment.trim().is_empty() || environment.len() > 128) + { + return Err("Wasm child task environment id is invalid".to_owned()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostTaskHandle { + pub abi_version: u32, + pub handle_id: u64, + pub task_spec: TaskSpec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostTaskJoinRequest { + pub abi_version: u32, + pub handle_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostTaskJoinResult { + pub abi_version: u32, + pub task_instance: TaskInstanceId, + pub result: TaskBoundaryValue, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostCommandRequest { + pub abi_version: u32, + pub program: String, + pub args: Vec, + pub working_directory: String, + pub environment_variables: BTreeMap, + pub timeout_ms: u64, + pub network: CommandNetworkPolicy, +} + +impl WasmHostCommandRequest { + pub fn validate(&self) -> Result<(), String> { + if self.abi_version != WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm command ABI version {}; expected {}", + self.abi_version, WASM_TASK_ABI_VERSION + )); + } + if self.program.trim().is_empty() || self.program.len() > 4096 { + return Err("Wasm command program is invalid".to_owned()); + } + if self.args.len() > 1024 || self.args.iter().any(|arg| arg.len() > 16 * 1024) { + return Err("Wasm command argument list exceeds ABI limits".to_owned()); + } + validate_command_working_directory(&self.working_directory)?; + if self.environment_variables.len() > 128 { + return Err("Wasm command environment exceeds 128 variables".to_owned()); + } + let environment_bytes = + self.environment_variables + .iter() + .try_fold(0_usize, |total, (name, value)| { + if !valid_environment_name(name) || value.contains('\0') { + return Err( + "Wasm command environment contains an invalid variable".to_owned() + ); + } + if name.len() > 128 || value.len() > 16 * 1024 { + return Err( + "Wasm command environment variable exceeds ABI limits".to_owned() + ); + } + total + .checked_add(name.len() + value.len()) + .ok_or_else(|| "Wasm command environment size overflowed".to_owned()) + })?; + if environment_bytes > 32 * 1024 { + return Err("Wasm command environment exceeds the 32 KiB limit".to_owned()); + } + if !(1_000..=60 * 60 * 1_000).contains(&self.timeout_ms) { + return Err("Wasm command timeout must be between 1 second and 1 hour".to_owned()); + } + let encoded = serde_json::to_vec(self).map_err(|error| error.to_string())?; + if encoded.len() > MAX_WASM_TASK_ENVELOPE_BYTES { + return Err("Wasm command request exceeds the task ABI limit".to_owned()); + } + Ok(()) + } +} + +fn validate_command_working_directory(path: &str) -> Result<(), String> { + let permitted_root = path == "/workspace" + || path.starts_with("/workspace/") + || path == "/disasmer/output" + || path.starts_with("/disasmer/output/"); + if !permitted_root + || path.len() > 4096 + || path.contains('\0') + || path.contains('\\') + || path.split('/').any(|component| component == "..") + { + return Err( + "Wasm command working directory must stay under /workspace or /disasmer/output" + .to_owned(), + ); + } + Ok(()) +} + +fn valid_environment_name(name: &str) -> bool { + let mut characters = name.chars(); + characters + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) + && characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostCommandResult { + pub abi_version: u32, + pub status_code: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostTaskControlRequest { + pub abi_version: u32, +} + +impl WasmHostTaskControlRequest { + pub fn validate(&self) -> Result<(), String> { + if self.abi_version != WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm task-control ABI version {}; expected {}", + self.abi_version, WASM_TASK_ABI_VERSION + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostTaskControlResult { + pub abi_version: u32, + pub cancellation_requested: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostDebugProbeRequest { + pub abi_version: u32, + pub symbol: String, +} + +impl WasmHostDebugProbeRequest { + pub fn validate(&self) -> Result<(), String> { + if self.abi_version != WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm debug-probe ABI version {}; expected {}", + self.abi_version, WASM_TASK_ABI_VERSION + )); + } + if self.symbol.trim().is_empty() + || self.symbol.len() > 256 + || !self + .symbol + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._:-/".contains(character)) + { + return Err("Wasm debug probe symbol is invalid".to_owned()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostDebugProbeResult { + pub abi_version: u32, + pub breakpoint_matched: bool, + pub debug_epoch: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostVfsRequest { + pub abi_version: u32, + pub operation: WasmHostVfsOperation, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WasmHostVfsOperation { + FlushOutput { + relative_path: String, + }, + MaterializeArtifact { + artifact: ArtifactHandle, + relative_path: String, + }, +} + +impl WasmHostVfsRequest { + pub fn validate(&self) -> Result<(), String> { + if self.abi_version != WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm VFS ABI version {}; expected {}", + self.abi_version, WASM_TASK_ABI_VERSION + )); + } + let relative_path = match &self.operation { + WasmHostVfsOperation::FlushOutput { relative_path } + | WasmHostVfsOperation::MaterializeArtifact { relative_path, .. } => relative_path, + }; + validate_task_relative_path(relative_path)?; + if let WasmHostVfsOperation::MaterializeArtifact { artifact, .. } = &self.operation { + if !artifact.digest.is_valid_sha256() { + return Err("Wasm VFS artifact digest is invalid".to_owned()); + } + } + let encoded = serde_json::to_vec(self).map_err(|error| error.to_string())?; + if encoded.len() > MAX_WASM_TASK_ENVELOPE_BYTES { + return Err("Wasm VFS request exceeds the task ABI limit".to_owned()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostVfsResult { + pub abi_version: u32, + pub artifact: ArtifactHandle, + pub relative_path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostSourceSnapshotRequest { + pub abi_version: u32, +} + +impl WasmHostSourceSnapshotRequest { + pub fn validate(&self) -> Result<(), String> { + if self.abi_version != WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm source-snapshot ABI version {}; expected {}", + self.abi_version, WASM_TASK_ABI_VERSION + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHostSourceSnapshotResult { + pub abi_version: u32, + pub snapshot: Digest, +} + +fn validate_task_relative_path(path: &str) -> Result<(), String> { + if path.is_empty() + || path.len() > 240 + || path.starts_with('/') + || path.starts_with('\\') + || path.split('/').any(|component| { + component.is_empty() + || component == "." + || component == ".." + || !component + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) + }) + { + return Err("Wasm VFS path must be a safe task-output-relative path".to_owned()); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmTaskInvocation { + pub abi_version: u32, + pub task_definition: TaskDefinitionId, + pub task_instance: TaskInstanceId, + pub args: Vec, +} + +impl WasmTaskInvocation { + pub fn new( + task_definition: TaskDefinitionId, + task_instance: TaskInstanceId, + args: Vec, + ) -> Self { + Self { + abi_version: WASM_TASK_ABI_VERSION, + task_definition, + task_instance, + args, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.abi_version != WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm task ABI version {}; expected {}", + self.abi_version, WASM_TASK_ABI_VERSION + )); + } + if self.task_definition.as_str().len() > 128 { + return Err("Wasm task invocation has an invalid task-definition id".to_owned()); + } + if self.task_instance.as_str().len() > 192 { + return Err("Wasm task invocation has an invalid task-instance id".to_owned()); + } + let encoded = serde_json::to_vec(self).map_err(|error| error.to_string())?; + if encoded.len() > MAX_WASM_TASK_ENVELOPE_BYTES { + return Err(format!( + "Wasm task invocation is {} bytes; maximum is {}", + encoded.len(), + MAX_WASM_TASK_ENVELOPE_BYTES + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WasmTaskOutcome { + Completed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmTaskResult { + pub abi_version: u32, + pub task_instance: TaskInstanceId, + pub outcome: WasmTaskOutcome, + pub result: Option, + pub error: Option, +} + +impl WasmTaskResult { + pub fn completed(task_instance: TaskInstanceId, result: TaskBoundaryValue) -> Self { + Self { + abi_version: WASM_TASK_ABI_VERSION, + task_instance, + outcome: WasmTaskOutcome::Completed, + result: Some(result), + error: None, + } + } + + pub fn failed(task_instance: TaskInstanceId, error: impl Into) -> Self { + Self { + abi_version: WASM_TASK_ABI_VERSION, + task_instance, + outcome: WasmTaskOutcome::Failed, + result: None, + error: Some(error.into()), + } + } + + pub fn validate_for(&self, expected_instance: &TaskInstanceId) -> Result<(), String> { + if self.abi_version != WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm task result ABI version {}; expected {}", + self.abi_version, WASM_TASK_ABI_VERSION + )); + } + if &self.task_instance != expected_instance { + return Err(format!( + "Wasm task result belongs to {} instead of {}", + self.task_instance, expected_instance + )); + } + match (&self.outcome, &self.result, &self.error) { + (WasmTaskOutcome::Completed, Some(_), None) + | (WasmTaskOutcome::Failed, None, Some(_)) => Ok(()), + _ => Err("Wasm task result has inconsistent outcome fields".to_owned()), + } + } +} + +impl TaskBoundaryValue { + pub fn reject_host_only(type_name: &str) -> Result { + Err(format!( + "task boundary value `{type_name}` is host-only; use small serialized data or handles" + )) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WasmExportAbi { + EntrypointV1, + TaskV1, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TaskDispatch { + CoordinatorNodeWasm { + export: Option, + abi: WasmExportAbi, + }, +} + +impl TaskDispatch { + pub fn is_product_remote_dispatch(&self) -> bool { + matches!(self, Self::CoordinatorNodeWasm { .. }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskSpec { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub task_definition: TaskDefinitionId, + pub task_instance: TaskInstanceId, + pub dispatch: TaskDispatch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_id: Option, + pub environment: Option, + pub environment_digest: Option, + pub required_capabilities: BTreeSet, + pub dependency_cache: Option, + pub source_snapshot: Option, + pub required_artifacts: Vec, + pub args: Vec, + pub vfs_epoch: u64, + pub bundle_digest: Option, +} + +impl TaskSpec { + pub fn product_mode_uses_remote_dispatch(&self) -> bool { + self.dispatch.is_product_remote_dispatch() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskJoinState { + Pending, + Completed, + Failed, + Cancelled, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskJoinResult { + pub process: ProcessId, + pub task_instance: TaskInstanceId, + pub node: Option, + pub state: TaskJoinState, + pub result: Option, + pub status_code: Option, + pub remote_completion_observed: bool, + pub message: String, +} + +impl TaskJoinResult { + pub fn pending( + process: ProcessId, + task_instance: TaskInstanceId, + message: impl Into, + ) -> Self { + Self { + process, + task_instance, + node: None, + state: TaskJoinState::Pending, + result: None, + status_code: None, + remote_completion_observed: false, + message: message.into(), + } + } + + pub fn from_remote_completion( + process: ProcessId, + task_instance: TaskInstanceId, + node: NodeId, + state: TaskJoinState, + result: Option, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + process, + task_instance, + node: Some(node), + state, + result, + status_code, + remote_completion_observed: true, + message: message.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosted_control_plane_cannot_authorize_native_command() { + let policy = NativeCommandPolicy { + hosted_control_plane: true, + node_has_command_capability: true, + }; + + assert!(policy + .authorize() + .unwrap_err() + .contains("hosted coordinator")); + } + + #[test] + fn raw_pointer_style_task_argument_is_rejected() { + let error = TaskBoundaryValue::reject_host_only("*const u8").unwrap_err(); + + assert!(error.contains("host-only")); + } + + #[test] + fn product_task_spec_has_no_local_function_dispatch_variant() { + let spec = TaskSpec { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + task_definition: TaskDefinitionId::from("compile-linux"), + task_instance: TaskInstanceId::from("compile-linux-1"), + dispatch: TaskDispatch::CoordinatorNodeWasm { + export: Some("compile_linux".to_owned()), + abi: WasmExportAbi::TaskV1, + }, + environment_id: Some("linux".to_owned()), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + args: vec![TaskBoundaryValue::SmallJson(serde_json::json!("src"))], + vfs_epoch: 7, + bundle_digest: Some(Digest::sha256("bundle")), + }; + + assert!(spec.product_mode_uses_remote_dispatch()); + } + + #[test] + fn task_join_result_marks_remote_completion_as_observed() { + let joined = TaskJoinResult::from_remote_completion( + ProcessId::from("process"), + TaskInstanceId::from("compile-linux-1"), + NodeId::from("linux-a"), + TaskJoinState::Completed, + Some(TaskBoundaryValue::Artifact(ArtifactHandle { + id: ArtifactId::from("app.tar.gz"), + digest: Digest::sha256("artifact"), + size_bytes: 8, + })), + Some(0), + "completed from signed node event", + ); + + assert!(joined.remote_completion_observed); + assert!(matches!( + joined.result, + Some(TaskBoundaryValue::Artifact(_)) + )); + } + + #[test] + fn structured_command_request_bounds_cwd_environment_timeout_and_network_policy() { + let mut request = WasmHostCommandRequest { + abi_version: WASM_TASK_ABI_VERSION, + program: "cc".to_owned(), + args: vec!["source.c".to_owned()], + working_directory: "/workspace/crate".to_owned(), + environment_variables: BTreeMap::from([ + ("BUILD_MODE".to_owned(), "release".to_owned()), + ("SOURCE_DATE_EPOCH".to_owned(), "0".to_owned()), + ]), + timeout_ms: 120_000, + network: CommandNetworkPolicy::Disabled, + }; + request.validate().unwrap(); + + request.working_directory = "/workspace/../host".to_owned(); + assert!(request + .validate() + .unwrap_err() + .contains("working directory")); + request.working_directory = "/workspace".to_owned(); + request.environment_variables = BTreeMap::from([("BAD-NAME".to_owned(), "x".to_owned())]); + assert!(request.validate().unwrap_err().contains("invalid variable")); + request.environment_variables.clear(); + request.timeout_ms = 0; + assert!(request.validate().unwrap_err().contains("timeout")); + } +} diff --git a/crates/disasmer-core/src/ids.rs b/crates/disasmer-core/src/ids.rs new file mode 100644 index 0000000..00b2d51 --- /dev/null +++ b/crates/disasmer-core/src/ids.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +macro_rules! id_type { + ($name:ident) => { + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Self { + let value = value.into(); + assert!( + !value.trim().is_empty(), + concat!(stringify!($name), " cannot be empty") + ); + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self::new(value) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +id_type!(AgentId); +id_type!(ArtifactId); +id_type!(NodeId); +id_type!(ProcessId); +id_type!(ProjectId); +id_type!(TaskDefinitionId); +id_type!(TaskInstanceId); +id_type!(TenantId); +id_type!(UserId); diff --git a/crates/disasmer-core/src/lib.rs b/crates/disasmer-core/src/lib.rs new file mode 100644 index 0000000..4aa4c77 --- /dev/null +++ b/crates/disasmer-core/src/lib.rs @@ -0,0 +1,102 @@ +pub mod artifact; +pub mod auth; +pub mod bundle; +pub mod capability; +pub mod checkpoint; +pub mod debug; +pub mod digest; +pub mod environment; +pub mod execution; +pub mod ids; +pub mod limits; +pub mod operator_panel; +pub mod policy; +pub mod project; +pub mod scheduler; +pub mod source; +pub mod transport; +pub mod vfs; +pub mod wire; + +pub use artifact::{ + ArtifactDownloadStream, ArtifactFlush, ArtifactHandle, ArtifactMetadata, ArtifactRegistry, + ArtifactUnavailable, DownloadAction, DownloadError, DownloadLink, DownloadPolicy, + DownloadStreamRequest, RetentionPolicy, StorageLocation, +}; +pub use auth::{ + admin_request_proof, admin_request_proof_from_token_digest, + agent_ed25519_public_key_from_private_key, derive_ed25519_private_key_from_seed, + node_capability_policy_digest, node_ed25519_public_key_from_private_key, + sign_agent_workflow_request, sign_node_request, signed_request_payload_digest, + verify_agent_workflow_signature, verify_node_request_signature, Action, Actor, + AgentSignedRequest, AgentWorkflowScope, AuthContext, Authorization, BrowserLoginFlow, + CredentialKind, EnrollmentError, EnrollmentGrant, IdentityKind, NodeCredential, + NodeSignedRequest, PublicKeyIdentity, Scope, +}; +#[cfg(not(target_arch = "wasm32"))] +pub use auth::{generate_ed25519_private_key, generate_opaque_token}; +pub use bundle::{ + discover_source_debug_probes, BundleDebugMetadata, BundleDebugProbe, BundleIdentityInputs, + BundleLargeInputPolicy, BundleMetadata, BundleRestartCompatibility, BundleSourceMetadata, + BundleTaskMetadata, SelectedInput, +}; +pub use capability::{Capability, CapabilityReportError, EnvironmentBackend, NodeCapabilities, Os}; +pub use checkpoint::{ + CheckpointBoundary, CompatibilityFailure, RestartDecision, RestartPolicy, RestartRequest, + TaskCheckpoint, +}; +pub use debug::{ + DebugEpoch, DebugEpochError, DebugParticipant, DebugParticipantKind, DebugRuntimeState, + DebugStopReason, ThreadInspection, +}; +pub use digest::Digest; +pub use environment::{ + diagnose_environment_references, discover_environments, EnvironmentDiagnostic, EnvironmentKind, + EnvironmentReference, EnvironmentRequirements, EnvironmentResource, +}; +pub use execution::{ + CommandBackendKind, CommandInvocation, CommandNetworkPolicy, CommandPlan, GuestRuntimeKind, + NativeCommandPolicy, StructuredTaskBoundary, TaskBoundaryHandle, TaskBoundaryValue, + TaskDispatch, TaskJoinResult, TaskJoinState, TaskSpec, WasmExportAbi, WasmHostCommandRequest, + WasmHostCommandResult, WasmHostDebugProbeRequest, WasmHostDebugProbeResult, + WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskControlRequest, + WasmHostTaskControlResult, WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, + WasmHostTaskStartRequest, WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, + WasmTaskInvocation, WasmTaskOutcome, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, + WASM_TASK_ABI_VERSION, +}; +pub use ids::{ + AgentId, ArtifactId, NodeId, ProcessId, ProjectId, TaskDefinitionId, TaskInstanceId, TenantId, + UserId, +}; +pub use limits::{ + LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits, + ResourceMeter, TaskArgumentBudget, MIN_SIGNED_NODE_POLL_INTERVAL_MS, +}; +pub use operator_panel::{ + ControlPlaneAction, PanelError, PanelEvent, PanelEventKind, PanelState, PanelWidget, + PanelWidgetKind, RateLimit, +}; +pub use policy::{ + CapabilityPolicy, Decision, LocalTrustedPolicy, PolicyReason, ResourceRequest, ServicePolicy, +}; +pub use project::{Entrypoint, ProjectModel, ProjectModelError}; +pub use scheduler::{ + DefaultScheduler, NodeDescriptor, Placement, PlacementError, PlacementRequest, Scheduler, +}; +pub use source::{ + SourceManifestError, SourcePreparation, SourceProviderKind, SourceProviderManifest, + SourceProviderModule, SourceTransferMode, SourceTransferPolicy, +}; +pub use transport::{ + BulkTransferDecision, DataPlaneObject, DataPlaneScope, DirectBulkTransferPlan, + NativeQuicTransport, NodeEndpoint, RendezvousRequest, Transport, TransportError, TransportKind, +}; +pub use vfs::{ + ReuseDecision, SyncPolicy, VfsError, VfsManifest, VfsObject, VfsOverlay, VfsPath, + VfsSyncDecision, +}; +pub use wire::{ + coordinator_authentication_metadata, coordinator_payload_operation, coordinator_wire_request, + COORDINATOR_PROTOCOL_VERSION, COORDINATOR_WIRE_REQUEST_TYPE, +}; diff --git a/crates/disasmer-core/src/limits.rs b/crates/disasmer-core/src/limits.rs new file mode 100644 index 0000000..4e781e8 --- /dev/null +++ b/crates/disasmer-core/src/limits.rs @@ -0,0 +1,273 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::TaskInstanceId; + +/// Fastest supported interval for a node's signed artifact/assignment polling loop. +/// The coordinator's bounded replay window is sized against this protocol limit. +pub const MIN_SIGNED_NODE_POLL_INTERVAL_MS: u64 = 20; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum LimitKind { + ApiCall, + Spawn, + LogBytes, + MetadataBytes, + DebugReadBytes, + UiEvent, + RendezvousAttempt, + ArtifactDownloadBytes, + HostedFuel, + HostedMemoryBytes, + HostedWallClockMs, + HostedStateBytes, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceLimits { + pub limits: BTreeMap, +} + +impl ResourceLimits { + pub fn new(limits: impl IntoIterator) -> Self { + Self { + limits: limits.into_iter().collect(), + } + } + + pub fn unlimited() -> Self { + Self::new(LimitKind::ALL.into_iter().map(|kind| (kind, u64::MAX))) + } + + pub fn limit(&self, kind: &LimitKind) -> u64 { + *self.limits.get(kind).unwrap_or(&0) + } +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self::unlimited() + } +} + +impl LimitKind { + pub const ALL: [Self; 12] = [ + Self::ApiCall, + Self::Spawn, + Self::LogBytes, + Self::MetadataBytes, + Self::DebugReadBytes, + Self::UiEvent, + Self::RendezvousAttempt, + Self::ArtifactDownloadBytes, + Self::HostedFuel, + Self::HostedMemoryBytes, + Self::HostedWallClockMs, + Self::HostedStateBytes, + ]; +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceMeter { + used: BTreeMap, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum LimitError { + #[error( + "resource limit exceeded for {kind:?}: requested {requested}, used {used}, limit {limit}" + )] + Exceeded { + kind: LimitKind, + requested: u64, + used: u64, + limit: u64, + }, + #[error("task argument is too large: {size} bytes exceeds {limit} bytes")] + LargeTaskArgument { size: u64, limit: u64 }, +} + +impl ResourceMeter { + pub fn can_charge( + &self, + limits: &ResourceLimits, + kind: LimitKind, + amount: u64, + ) -> Result<(), LimitError> { + let used = self.used.get(&kind).copied().unwrap_or(0); + let limit = limits.limit(&kind); + if used.saturating_add(amount) > limit { + return Err(LimitError::Exceeded { + kind, + requested: amount, + used, + limit, + }); + } + Ok(()) + } + + pub fn charge( + &mut self, + limits: &ResourceLimits, + kind: LimitKind, + amount: u64, + ) -> Result<(), LimitError> { + self.can_charge(limits, kind, amount)?; + let used = self.used.get(&kind).copied().unwrap_or(0); + self.used.insert(kind, used + amount); + Ok(()) + } + + pub fn used(&self, kind: &LimitKind) -> u64 { + self.used.get(kind).copied().unwrap_or(0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LogRecord { + pub task: TaskInstanceId, + pub bytes: Vec, + pub truncated: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LogBuffer { + max_bytes: usize, + used_bytes: usize, + records: Vec, + backpressured: bool, +} + +impl LogBuffer { + pub fn new(max_bytes: usize) -> Self { + Self { + max_bytes, + used_bytes: 0, + records: Vec::new(), + backpressured: false, + } + } + + pub fn push(&mut self, task: TaskInstanceId, bytes: impl AsRef<[u8]>) { + let bytes = bytes.as_ref(); + let remaining = self.max_bytes.saturating_sub(self.used_bytes); + let truncated = bytes.len() > remaining; + let stored = bytes[..bytes.len().min(remaining)].to_vec(); + self.used_bytes += stored.len(); + if truncated { + self.backpressured = true; + } + self.records.push(LogRecord { + task, + bytes: stored, + truncated, + }); + } + + pub fn records(&self) -> &[LogRecord] { + &self.records + } + + pub fn backpressured(&self) -> bool { + self.backpressured + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LargeArgumentPolicy { + Allow, + Warn, + Reject, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskArgumentBudget { + pub max_inline_bytes: u64, + pub policy: LargeArgumentPolicy, +} + +impl TaskArgumentBudget { + pub fn validate(&self, size: u64) -> Result, LimitError> { + if size <= self.max_inline_bytes { + return Ok(None); + } + + match self.policy { + LargeArgumentPolicy::Allow => Ok(None), + LargeArgumentPolicy::Warn => Ok(Some(format!( + "task argument is {size} bytes; prefer SourceSnapshot, Blob, Artifact, or VFS handles" + ))), + LargeArgumentPolicy::Reject => Err(LimitError::LargeTaskArgument { + size, + limit: self.max_inline_bytes, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_meter_rejects_usage_before_work_starts() { + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::Spawn, 1)]), + }; + let mut meter = ResourceMeter::default(); + + meter.charge(&limits, LimitKind::Spawn, 1).unwrap(); + let error = meter.charge(&limits, LimitKind::Spawn, 1).unwrap_err(); + + assert!(matches!(error, LimitError::Exceeded { .. })); + } + + #[test] + fn resource_meter_can_check_limits_without_consuming() { + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 4)]), + }; + let mut meter = ResourceMeter::default(); + + meter + .can_charge(&limits, LimitKind::ArtifactDownloadBytes, 4) + .unwrap(); + assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 0); + meter + .charge(&limits, LimitKind::ArtifactDownloadBytes, 3) + .unwrap(); + assert!(matches!( + meter.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 2), + Err(LimitError::Exceeded { .. }) + )); + } + + #[test] + fn log_buffer_caps_backpressures_and_keeps_task_association() { + let mut logs = LogBuffer::new(4); + logs.push(TaskInstanceId::from("task-a"), b"abcdef"); + + assert!(logs.backpressured()); + assert_eq!(logs.records()[0].task, TaskInstanceId::from("task-a")); + assert_eq!(logs.records()[0].bytes, b"abcd"); + assert!(logs.records()[0].truncated); + } + + #[test] + fn large_task_arguments_are_rejected_or_warned() { + let reject = TaskArgumentBudget { + max_inline_bytes: 4, + policy: LargeArgumentPolicy::Reject, + }; + assert!(reject.validate(5).is_err()); + + let warn = TaskArgumentBudget { + max_inline_bytes: 4, + policy: LargeArgumentPolicy::Warn, + }; + assert!(warn.validate(5).unwrap().unwrap().contains("Artifact")); + } +} diff --git a/crates/disasmer-core/src/operator_panel.rs b/crates/disasmer-core/src/operator_panel.rs new file mode 100644 index 0000000..8251942 --- /dev/null +++ b/crates/disasmer-core/src/operator_panel.rs @@ -0,0 +1,377 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskInstanceId, TenantId, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PanelWidgetKind { + Text { + value: String, + }, + Progress { + current: u64, + total: u64, + }, + Button { + action: String, + }, + Toggle { + value: bool, + }, + Select { + options: Vec, + selected: String, + }, + ArtifactDownload { + artifact: ArtifactId, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelWidget { + pub id: String, + pub label: String, + pub kind: PanelWidgetKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ControlPlaneAction { + RestartTask(TaskInstanceId), + CancelProcess, + DebugProcess, + DownloadArtifact(ArtifactId), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelState { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub widgets: BTreeMap, + pub program_ui_events_enabled: bool, + pub control_plane_actions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PanelEventKind { + ButtonClicked, + ToggleChanged(bool), + SelectChanged(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelEvent { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub widget_id: String, + pub kind: PanelEventKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateLimit { + pub max_events: u64, + pub used_events: u64, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum PanelError { + #[error("custom HTML or JavaScript is not supported in operator panels")] + CustomContentDenied, + #[error( + "operator panel widget `{0}` is not allowed to collect secrets or OAuth-like credentials" + )] + CredentialCollectionDenied(String), + #[error("panel event scope does not match tenant/project/process")] + ScopeMismatch, + #[error("program UI events are disabled while debug process is stopped")] + ProgramEventsDisabled, + #[error("panel event rate limit exceeded")] + RateLimited, + #[error("unknown panel widget `{0}`")] + UnknownWidget(String), + #[error("artifact download action is unavailable: {0}")] + DownloadUnavailable(String), +} + +impl PanelState { + pub fn new(tenant: TenantId, project: ProjectId, process: ProcessId) -> Self { + Self { + tenant, + project, + process, + widgets: BTreeMap::new(), + program_ui_events_enabled: true, + control_plane_actions: Vec::new(), + } + } + + pub fn add_widget(&mut self, widget: PanelWidget) -> Result<(), PanelError> { + validate_widget(&widget)?; + self.widgets.insert(widget.id.clone(), widget); + Ok(()) + } + + pub fn add_download_widget_from_action( + &mut self, + widget_id: impl Into, + label: impl Into, + action: Result, + ) -> Result<(), PanelError> { + let action = action.map_err(|err| PanelError::DownloadUnavailable(err.to_string()))?; + let artifact = action.artifact; + self.add_widget(PanelWidget { + id: widget_id.into(), + label: label.into(), + kind: PanelWidgetKind::ArtifactDownload { + artifact: artifact.clone(), + }, + })?; + self.control_plane_actions + .push(ControlPlaneAction::DownloadArtifact(artifact)); + Ok(()) + } + + pub fn reject_custom_content(_html_or_js: &str) -> Result<(), PanelError> { + Err(PanelError::CustomContentDenied) + } + + pub fn freeze_program_ui_events(&mut self) { + self.program_ui_events_enabled = false; + } + + pub fn set_control_plane_actions(&mut self, actions: Vec) { + self.control_plane_actions = actions; + } + + pub fn accept_event( + &self, + event: &PanelEvent, + limit: &mut RateLimit, + ) -> Result<(), PanelError> { + if !self.program_ui_events_enabled { + return Err(PanelError::ProgramEventsDisabled); + } + if self.tenant != event.tenant + || self.project != event.project + || self.process != event.process + { + return Err(PanelError::ScopeMismatch); + } + if !self.widgets.contains_key(&event.widget_id) { + return Err(PanelError::UnknownWidget(event.widget_id.clone())); + } + if limit.used_events >= limit.max_events { + return Err(PanelError::RateLimited); + } + limit.used_events += 1; + Ok(()) + } + + pub fn control_plane_actions_available(&self) -> &[ControlPlaneAction] { + &self.control_plane_actions + } +} + +fn validate_widget(widget: &PanelWidget) -> Result<(), PanelError> { + let mut checked_text = vec![widget.id.as_str(), widget.label.as_str()]; + match &widget.kind { + PanelWidgetKind::Button { action } => checked_text.push(action), + PanelWidgetKind::Select { options, selected } => { + checked_text.push(selected); + checked_text.extend(options.iter().map(String::as_str)); + } + PanelWidgetKind::Text { .. } + | PanelWidgetKind::Progress { .. } + | PanelWidgetKind::Toggle { .. } + | PanelWidgetKind::ArtifactDownload { .. } => {} + } + + let combined = checked_text.join(" ").to_ascii_lowercase(); + if combined.contains("password") + || combined.contains("token") + || combined.contains("oauth") + || combined.contains("secret") + { + return Err(PanelError::CredentialCollectionDenied(widget.id.clone())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn panel() -> PanelState { + PanelState::new( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ) + } + + #[test] + fn panel_uses_typed_widgets_and_rejects_custom_content() { + let mut panel = panel(); + panel + .add_widget(PanelWidget { + id: "progress".to_owned(), + label: "Build".to_owned(), + kind: PanelWidgetKind::Progress { + current: 1, + total: 2, + }, + }) + .unwrap(); + + assert!(PanelState::reject_custom_content("").is_err()); + assert!(panel.widgets.contains_key("progress")); + } + + #[test] + fn panel_rejects_password_or_oauth_collection_widgets() { + let mut panel = panel(); + let error = panel + .add_widget(PanelWidget { + id: "oauth_token".to_owned(), + label: "OAuth Token".to_owned(), + kind: PanelWidgetKind::Text { + value: String::new(), + }, + }) + .unwrap_err(); + + assert!(matches!(error, PanelError::CredentialCollectionDenied(_))); + } + + #[test] + fn panel_rejects_credential_collection_in_interactive_fields() { + let mut panel = panel(); + let button_error = panel + .add_widget(PanelWidget { + id: "continue".to_owned(), + label: "Continue".to_owned(), + kind: PanelWidgetKind::Button { + action: "collect-secret".to_owned(), + }, + }) + .unwrap_err(); + assert!(matches!( + button_error, + PanelError::CredentialCollectionDenied(_) + )); + + let select_error = panel + .add_widget(PanelWidget { + id: "auth-mode".to_owned(), + label: "Auth Mode".to_owned(), + kind: PanelWidgetKind::Select { + options: vec!["password".to_owned(), "public key".to_owned()], + selected: "public key".to_owned(), + }, + }) + .unwrap_err(); + assert!(matches!( + select_error, + PanelError::CredentialCollectionDenied(_) + )); + } + + #[test] + fn panel_events_are_scoped_and_rate_limited() { + let mut panel = panel(); + panel + .add_widget(PanelWidget { + id: "restart".to_owned(), + label: "Restart".to_owned(), + kind: PanelWidgetKind::Button { + action: "restart".to_owned(), + }, + }) + .unwrap(); + let event = PanelEvent { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + widget_id: "restart".to_owned(), + kind: PanelEventKind::ButtonClicked, + }; + let mut limit = RateLimit { + max_events: 1, + used_events: 0, + }; + + panel.accept_event(&event, &mut limit).unwrap(); + assert_eq!( + panel.accept_event(&event, &mut limit), + Err(PanelError::RateLimited) + ); + } + + #[test] + fn stopped_debug_process_keeps_control_plane_actions_available() { + let mut panel = panel(); + panel.freeze_program_ui_events(); + panel.set_control_plane_actions(vec![ + ControlPlaneAction::RestartTask(TaskInstanceId::from("task")), + ControlPlaneAction::DownloadArtifact(ArtifactId::from("artifact")), + ]); + + let event = PanelEvent { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + widget_id: "missing".to_owned(), + kind: PanelEventKind::ButtonClicked, + }; + let mut limit = RateLimit { + max_events: 1, + used_events: 0, + }; + + assert_eq!( + panel.accept_event(&event, &mut limit), + Err(PanelError::ProgramEventsDisabled) + ); + assert_eq!(panel.control_plane_actions_available().len(), 2); + } + + #[test] + fn download_widget_is_only_created_from_available_action() { + let mut panel = panel(); + let action = Ok(DownloadAction { + artifact: ArtifactId::from("artifact"), + source: crate::StorageLocation::RetainedNode(crate::NodeId::from("node")), + scoped_token_subject: "tenant/project/process/artifact".to_owned(), + }); + + panel + .add_download_widget_from_action("download-artifact", "Download", action) + .unwrap(); + + assert!(matches!( + panel.widgets["download-artifact"].kind, + PanelWidgetKind::ArtifactDownload { .. } + )); + assert!(matches!( + panel.control_plane_actions_available()[0], + ControlPlaneAction::DownloadArtifact(_) + )); + + let before = panel.widgets.len(); + let error = panel + .add_download_widget_from_action( + "missing-download", + "Download", + Err(DownloadError::Unavailable), + ) + .unwrap_err(); + + assert_eq!(panel.widgets.len(), before); + assert!(matches!(error, PanelError::DownloadUnavailable(_))); + } +} diff --git a/crates/disasmer-core/src/policy.rs b/crates/disasmer-core/src/policy.rs new file mode 100644 index 0000000..7c44663 --- /dev/null +++ b/crates/disasmer-core/src/policy.rs @@ -0,0 +1,134 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::{Action, AuthContext, Capability, Scope}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PolicyReason { + Allowed, + MissingCapability(Capability), + HostedNativeComputeDenied, + HostedContainerDenied, + QuotaExceeded(String), + Unauthorized(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Decision { + pub allowed: bool, + pub reason: PolicyReason, +} + +impl Decision { + pub fn allow() -> Self { + Self { + allowed: true, + reason: PolicyReason::Allowed, + } + } + + pub fn deny(reason: PolicyReason) -> Self { + Self { + allowed: false, + reason, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceRequest { + pub action: Action, + pub required_capabilities: BTreeSet, + pub hosted_control_plane: bool, +} + +pub trait CapabilityPolicy { + fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision; +} + +pub trait ServicePolicy: CapabilityPolicy + Send + Sync {} + +impl ServicePolicy for T where T: CapabilityPolicy + Send + Sync {} + +#[derive(Clone, Debug, Default)] +pub struct LocalTrustedPolicy; + +impl CapabilityPolicy for LocalTrustedPolicy { + fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision { + let authz = crate::auth::same_tenant_project(context, scope); + if !authz.allowed { + return Decision::deny(PolicyReason::Unauthorized(authz.reason)); + } + if request.hosted_control_plane && request.action == Action::RunNativeCommand { + return Decision::deny(PolicyReason::HostedNativeComputeDenied); + } + if request.hosted_control_plane && request.action == Action::RunContainer { + return Decision::deny(PolicyReason::HostedContainerDenied); + } + Decision::allow() + } +} + +#[cfg(test)] +mod tests { + use crate::{Actor, ProjectId, TenantId, UserId}; + + use super::*; + + #[test] + fn public_policy_interface_denies_hosted_native_compute() { + let policy = LocalTrustedPolicy; + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let scope = Scope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: None, + task: None, + node: None, + artifact: None, + }; + let request = ResourceRequest { + action: Action::RunNativeCommand, + required_capabilities: BTreeSet::new(), + hosted_control_plane: true, + }; + + let decision = policy.decide(&context, &scope, &request); + + assert!(!decision.allowed); + assert_eq!(decision.reason, PolicyReason::HostedNativeComputeDenied); + } + + #[test] + fn local_trusted_policy_allows_owner_controlled_native_capability_request() { + let policy = LocalTrustedPolicy; + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("owner")), + }; + let scope = Scope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: None, + task: None, + node: None, + artifact: None, + }; + let request = ResourceRequest { + action: Action::RunNativeCommand, + required_capabilities: BTreeSet::from([Capability::Command]), + hosted_control_plane: false, + }; + + let decision = policy.decide(&context, &scope, &request); + + assert!(decision.allowed); + assert_eq!(decision.reason, PolicyReason::Allowed); + } +} diff --git a/crates/disasmer-core/src/project.rs b/crates/disasmer-core/src/project.rs new file mode 100644 index 0000000..167d871 --- /dev/null +++ b/crates/disasmer-core/src/project.rs @@ -0,0 +1,303 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use syn::{punctuated::Punctuated, Expr, Item, Lit, Meta, Token}; +use thiserror::Error; + +use crate::{discover_environments, environment::EnvironmentError, EnvironmentResource}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entrypoint { + pub name: String, + pub function: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectModel { + pub root: PathBuf, + pub environments: Vec, + pub entrypoints: BTreeMap, + pub default_entrypoint: String, + pub required_config_file: Option, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ProjectModelError { + #[error("environment discovery failed: {0}")] + Environment(String), + #[error("Disasmer entrypoint discovery failed: {0}")] + EntrypointDiscovery(String), + #[error( + "no Disasmer entrypoint is declared; add `#[disasmer::main]` to a function under src/" + )] + NoEntrypoints, + #[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")] + UnknownEntrypoint { + name: String, + available: Vec, + }, +} + +impl ProjectModel { + pub fn discover_without_config(root: &Path) -> Result { + let environments = discover_environments(root).map_err(|err| { + ProjectModelError::Environment(match err { + EnvironmentError::Read { path, source } => { + format!("failed to read {}: {source}", path.display()) + } + }) + })?; + let entrypoints = discover_entrypoints(root)?; + let default_entrypoint = if entrypoints.contains_key("build") { + "build".to_owned() + } else { + entrypoints.keys().next().cloned().unwrap_or_default() + }; + Ok(Self { + root: root.to_path_buf(), + environments, + entrypoints, + default_entrypoint, + required_config_file: None, + }) + } + + pub fn select_entrypoint(&self, name: Option<&str>) -> Result<&Entrypoint, ProjectModelError> { + if self.entrypoints.is_empty() { + return Err(ProjectModelError::NoEntrypoints); + } + let name = name.unwrap_or(&self.default_entrypoint); + self.entrypoints + .get(name) + .ok_or_else(|| ProjectModelError::UnknownEntrypoint { + name: name.to_owned(), + available: self.entrypoints.keys().cloned().collect(), + }) + } +} + +fn discover_entrypoints(root: &Path) -> Result, ProjectModelError> { + let source_root = root.join("src"); + if !source_root.is_dir() { + return Ok(BTreeMap::new()); + } + + let mut source_files = Vec::new(); + collect_rust_sources(&source_root, &mut source_files)?; + source_files.sort(); + + let mut entrypoints = BTreeMap::new(); + for path in source_files { + let source = fs::read_to_string(&path).map_err(|error| { + ProjectModelError::EntrypointDiscovery(format!( + "failed to read {}: {error}", + path.display() + )) + })?; + let syntax = syn::parse_file(&source).map_err(|error| { + ProjectModelError::EntrypointDiscovery(format!( + "failed to parse {}: {error}", + path.display() + )) + })?; + collect_entrypoint_items(&syntax.items, &path, &mut entrypoints)?; + } + Ok(entrypoints) +} + +fn collect_rust_sources( + directory: &Path, + files: &mut Vec, +) -> Result<(), ProjectModelError> { + let entries = fs::read_dir(directory).map_err(|error| { + ProjectModelError::EntrypointDiscovery(format!( + "failed to read {}: {error}", + directory.display() + )) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + ProjectModelError::EntrypointDiscovery(format!( + "failed to inspect {}: {error}", + directory.display() + )) + })?; + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| { + ProjectModelError::EntrypointDiscovery(format!( + "failed to inspect {}: {error}", + path.display() + )) + })?; + if file_type.is_dir() { + collect_rust_sources(&path, files)?; + } else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + Ok(()) +} + +fn collect_entrypoint_items( + items: &[Item], + path: &Path, + entrypoints: &mut BTreeMap, +) -> Result<(), ProjectModelError> { + for item in items { + match item { + Item::Fn(function) => { + let Some(attribute) = function.attrs.iter().find(|attribute| { + let segments = attribute + .path() + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>(); + segments.as_slice() == ["disasmer", "main"] + }) else { + continue; + }; + let function_name = function.sig.ident.to_string(); + let default_name = function_name + .strip_suffix("_main") + .unwrap_or(&function_name); + let name = entrypoint_name(attribute, default_name); + let entrypoint = Entrypoint { + name: name.clone(), + function: function_name, + }; + if let Some(existing) = entrypoints.insert(name.clone(), entrypoint) { + return Err(ProjectModelError::EntrypointDiscovery(format!( + "duplicate entrypoint `{name}` in {}; it was already declared by `{}`", + path.display(), + existing.function + ))); + } + } + Item::Mod(module) => { + if let Some((_, nested)) = &module.content { + collect_entrypoint_items(nested, path, entrypoints)?; + } + } + _ => {} + } + } + Ok(()) +} + +fn entrypoint_name(attribute: &syn::Attribute, default: &str) -> String { + let Meta::List(_) = &attribute.meta else { + return default.to_owned(); + }; + let Ok(arguments) = attribute.parse_args_with(Punctuated::::parse_terminated) + else { + return default.to_owned(); + }; + arguments + .into_iter() + .find_map(|meta| { + let Meta::NameValue(name_value) = meta else { + return None; + }; + if !name_value.path.is_ident("name") { + return None; + } + let Expr::Lit(expression) = name_value.value else { + return None; + }; + let Lit::Str(value) = expression.lit else { + return None; + }; + Some(value.value()) + }) + .unwrap_or_else(|| default.to_owned()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn project_works_without_hand_written_configuration_file() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("envs/linux")).unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write( + temp.path().join("envs/linux/Containerfile"), + "FROM alpine\n", + ) + .unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "#[disasmer::main]\npub fn build_main() {}\n", + ) + .unwrap(); + + let model = ProjectModel::discover_without_config(temp.path()).unwrap(); + + assert_eq!(model.required_config_file, None); + assert_eq!(model.environments[0].name, "linux"); + assert_eq!(model.select_entrypoint(None).unwrap().name, "build"); + } + + #[test] + fn project_can_define_multiple_default_entrypoints() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src/nested")).unwrap(); + fs::write( + temp.path().join("src/lib.rs"), + "#[disasmer::main(name = \"check\")]\npub fn test_main() {}\n", + ) + .unwrap(); + fs::write( + temp.path().join("src/nested/release.rs"), + "#[disasmer::main]\npub fn release_main() {}\n", + ) + .unwrap(); + let model = ProjectModel::discover_without_config(temp.path()).unwrap(); + + assert_eq!( + model.select_entrypoint(Some("check")).unwrap().function, + "test_main" + ); + assert_eq!( + model.select_entrypoint(Some("release")).unwrap().function, + "release_main" + ); + } + + #[test] + fn unknown_entrypoint_lists_available_choices() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "#[disasmer::main]\npub fn build_main() {}\n", + ) + .unwrap(); + let model = ProjectModel::discover_without_config(temp.path()).unwrap(); + let error = model.select_entrypoint(Some("deploy")).unwrap_err(); + + assert!(matches!(error, ProjectModelError::UnknownEntrypoint { .. })); + } + + #[test] + fn project_without_declared_entrypoint_does_not_invent_product_surfaces() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap(); + + let model = ProjectModel::discover_without_config(temp.path()).unwrap(); + + assert!(model.entrypoints.is_empty()); + assert_eq!(model.default_entrypoint, ""); + assert_eq!( + model.select_entrypoint(None).unwrap_err(), + ProjectModelError::NoEntrypoints + ); + } +} diff --git a/crates/disasmer-core/src/scheduler.rs b/crates/disasmer-core/src/scheduler.rs new file mode 100644 index 0000000..0cf2e5e --- /dev/null +++ b/crates/disasmer-core/src/scheduler.rs @@ -0,0 +1,429 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + ArtifactId, Capability, Digest, EnvironmentRequirements, NodeCapabilities, NodeId, ProjectId, + TenantId, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeDescriptor { + pub id: NodeId, + pub tenant: TenantId, + pub project: ProjectId, + pub capabilities: NodeCapabilities, + pub cached_environments: BTreeSet, + pub dependency_caches: BTreeSet, + pub source_snapshots: BTreeSet, + pub artifact_locations: BTreeSet, + pub direct_connectivity: bool, + pub online: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlacementRequest { + pub tenant: TenantId, + pub project: ProjectId, + pub environment: Option, + pub environment_digest: Option, + pub required_capabilities: BTreeSet, + pub dependency_cache: Option, + pub source_snapshot: Option, + pub required_artifacts: BTreeSet, + pub quota_available: bool, + pub policy_allowed: bool, + pub prefer_node: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Placement { + pub node: NodeId, + pub score: i64, + pub reasons: Vec, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("no capable node for placement: {message}")] +pub struct PlacementError { + pub message: String, +} + +pub trait Scheduler { + fn place( + &self, + nodes: &[NodeDescriptor], + request: &PlacementRequest, + ) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct DefaultScheduler; + +impl Scheduler for DefaultScheduler { + fn place( + &self, + nodes: &[NodeDescriptor], + request: &PlacementRequest, + ) -> Result { + let mut scored = Vec::new(); + let mut rejection_counts = BTreeMap::::new(); + + for node in nodes { + match compatibility(node, request) { + Ok(mut placement) => { + locality_score(node, request, &mut placement); + scored.push(placement); + } + Err(reasons) => { + for reason in reasons { + *rejection_counts.entry(reason).or_default() += 1; + } + } + } + } + + scored + .into_iter() + .max_by_key(|placement| placement.score) + .ok_or_else(|| PlacementError { + message: rejection_counts + .into_iter() + .map(|(reason, count)| format!("{reason} ({count} node(s))")) + .collect::>() + .join("; "), + }) + } +} + +fn compatibility( + node: &NodeDescriptor, + request: &PlacementRequest, +) -> Result> { + let mut reasons = Vec::new(); + if !node.online { + reasons.push("node offline".to_owned()); + } + if node.tenant != request.tenant { + reasons.push("tenant mismatch".to_owned()); + } + if node.project != request.project { + reasons.push("project mismatch".to_owned()); + } + if !request.quota_available { + reasons.push("quota unavailable for placement".to_owned()); + } + if !request.policy_allowed { + reasons.push("policy denied placement".to_owned()); + } + for capability in &request.required_capabilities { + if !node.capabilities.capabilities.contains(capability) { + reasons.push(format!("missing capability {capability:?}")); + } + } + if let Some(environment) = &request.environment { + if let Some(required_os) = &environment.os { + if &node.capabilities.os != required_os { + reasons.push(format!("environment requires os {required_os:?}")); + } + } + if let Some(required_arch) = &environment.arch { + if &node.capabilities.arch != required_arch { + reasons.push(format!("environment requires arch {required_arch}")); + } + } + for capability in &environment.capabilities { + if !node.capabilities.capabilities.contains(capability) { + reasons.push(format!("environment requires capability {capability:?}")); + } + } + } + let source_transfer_required = request + .source_snapshot + .as_ref() + .is_some_and(|digest| !node.source_snapshots.contains(digest)); + if source_transfer_required && !node.direct_connectivity { + reasons.push("source snapshot unavailable and direct connectivity unavailable".to_owned()); + } + let missing_artifacts = request + .required_artifacts + .iter() + .filter(|artifact| !node.artifact_locations.contains(*artifact)) + .count(); + if missing_artifacts > 0 && !node.direct_connectivity { + reasons.push(format!( + "{missing_artifacts} required artifact(s) unavailable and direct connectivity unavailable" + )); + } + + if reasons.is_empty() { + Ok(Placement { + node: node.id.clone(), + score: 0, + reasons: Vec::new(), + }) + } else { + Err(reasons) + } +} + +fn locality_score(node: &NodeDescriptor, request: &PlacementRequest, placement: &mut Placement) { + if request.prefer_node.as_ref() == Some(&node.id) { + placement.score += 100; + placement.reasons.push("preferred node".to_owned()); + } + if request + .environment_digest + .as_ref() + .is_some_and(|digest| node.cached_environments.contains(digest)) + { + placement.score += 50; + placement.reasons.push("warm environment cache".to_owned()); + } + if request + .source_snapshot + .as_ref() + .is_some_and(|digest| node.source_snapshots.contains(digest)) + { + placement.score += 40; + placement + .reasons + .push("source snapshot already local".to_owned()); + } + if request + .dependency_cache + .as_ref() + .is_some_and(|digest| node.dependency_caches.contains(digest)) + { + placement.score += 30; + placement.reasons.push("warm dependency cache".to_owned()); + } + let artifact_hits = request + .required_artifacts + .iter() + .filter(|artifact| node.artifact_locations.contains(*artifact)) + .count() as i64; + if artifact_hits > 0 { + placement.score += 10 * artifact_hits; + placement.reasons.push(format!( + "{artifact_hits} required artifact(s) already local" + )); + } +} + +#[cfg(test)] +mod tests { + use crate::{EnvironmentBackend, Os}; + + use super::*; + + fn node(id: &str, cached_source: bool) -> NodeDescriptor { + let source = Digest::sha256("source"); + NodeDescriptor { + id: NodeId::from(id), + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + capabilities: NodeCapabilities { + os: Os::Linux, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([ + Capability::Command, + Capability::Containers, + Capability::RootlessPodman, + ]), + environment_backends: BTreeSet::from([EnvironmentBackend::Container]), + source_providers: BTreeSet::from(["filesystem".to_owned()]), + }, + cached_environments: BTreeSet::from([Digest::sha256("env")]), + dependency_caches: if cached_source { + BTreeSet::from([Digest::sha256("deps")]) + } else { + BTreeSet::new() + }, + source_snapshots: if cached_source { + BTreeSet::from([source]) + } else { + BTreeSet::new() + }, + artifact_locations: BTreeSet::new(), + direct_connectivity: true, + online: true, + } + } + + #[test] + fn scheduler_prefers_warm_source_and_environment() { + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: Some(EnvironmentRequirements::linux_container()), + environment_digest: Some(Digest::sha256("env")), + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: Some(Digest::sha256("deps")), + source_snapshot: Some(Digest::sha256("source")), + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let placement = DefaultScheduler + .place(&[node("cold", false), node("warm", true)], &request) + .unwrap(); + + assert_eq!(placement.node, NodeId::from("warm")); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("source"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("dependency"))); + } + + #[test] + fn scheduler_failure_names_missing_constraint() { + let mut request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::WindowsCommandDev]), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + request.required_capabilities.insert(Capability::Command); + + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("WindowsCommandDev")); + } + + #[test] + fn scheduler_failure_names_environment_constraint() { + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: Some(EnvironmentRequirements::windows_command_dev()), + environment_digest: None, + required_capabilities: BTreeSet::new(), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("environment requires os Windows")); + assert!(error + .message + .contains("environment requires capability WindowsCommandDev")); + } + + #[test] + fn scheduler_requires_direct_connectivity_when_transfer_is_needed() { + let mut disconnected = node("disconnected", false); + disconnected.direct_connectivity = false; + let mut local = node("local", true); + local.direct_connectivity = false; + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: None, + source_snapshot: Some(Digest::sha256("source")), + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let placement = DefaultScheduler + .place(&[disconnected, local], &request) + .unwrap(); + assert_eq!(placement.node, NodeId::from("local")); + + let mut disconnected = node("disconnected", false); + disconnected.direct_connectivity = false; + let error = DefaultScheduler + .place(&[disconnected], &request) + .unwrap_err(); + + assert!(error + .message + .contains("source snapshot unavailable and direct connectivity unavailable")); + } + + #[test] + fn scheduler_failure_names_required_artifact_transfer_constraint() { + let mut disconnected = node("disconnected", true); + disconnected.direct_connectivity = false; + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::from([ArtifactId::from("cache")]), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let error = DefaultScheduler + .place(&[disconnected], &request) + .unwrap_err(); + + assert!(error + .message + .contains("1 required artifact(s) unavailable and direct connectivity unavailable")); + } + + #[test] + fn scheduler_failure_names_quota_and_policy_constraints() { + let mut request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::new(), + quota_available: false, + policy_allowed: true, + prefer_node: None, + }; + + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("quota unavailable for placement")); + + request.quota_available = true; + request.policy_allowed = false; + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("policy denied placement")); + } +} diff --git a/crates/disasmer-core/src/source.rs b/crates/disasmer-core/src/source.rs new file mode 100644 index 0000000..c68771d --- /dev/null +++ b/crates/disasmer-core/src/source.rs @@ -0,0 +1,324 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Capability, Digest, ProjectId, TenantId}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum SourceProviderKind { + Filesystem, + Git, + Custom(String), +} + +impl SourceProviderKind { + pub fn provider_id(&self) -> &str { + match self { + SourceProviderKind::Filesystem => "filesystem", + SourceProviderKind::Git => "git", + SourceProviderKind::Custom(provider) => provider, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum SourceTransferMode { + RequiredContent, + ExplicitSnapshotChunks, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceTransferPolicy { + pub local_source_bytes_remain_node_local: bool, + pub coordinator_receives_source_bytes_by_default: bool, + pub default_full_repo_tarball: bool, + pub allowed_remote_transfer: BTreeSet, +} + +impl SourceTransferPolicy { + pub fn local_first_snapshot_chunks() -> Self { + Self { + local_source_bytes_remain_node_local: true, + coordinator_receives_source_bytes_by_default: false, + default_full_repo_tarball: false, + allowed_remote_transfer: BTreeSet::from([ + SourceTransferMode::RequiredContent, + SourceTransferMode::ExplicitSnapshotChunks, + ]), + } + } +} + +impl Default for SourceTransferPolicy { + fn default() -> Self { + Self::local_first_snapshot_chunks() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceProviderManifest { + pub kind: SourceProviderKind, + pub digest: Digest, + pub description: String, + #[serde(default)] + pub coordinator_requires_checkout_access: bool, + #[serde(default)] + pub transfer_policy: SourceTransferPolicy, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum SourceManifestError { + #[error("source provider manifest digest is not a valid sha256 digest: {0}")] + InvalidDigest(String), + #[error("custom source provider id `{0}` is invalid")] + InvalidProviderId(String), + #[error("source provider manifest description must be non-empty")] + EmptyDescription, + #[error("source provider manifest description is too long")] + DescriptionTooLong, + #[error("source provider manifest description contains control characters")] + DescriptionControlCharacter, + #[error("source provider manifest would require coordinator checkout access")] + CoordinatorCheckoutAccess, + #[error("source provider manifest would send source bytes to the coordinator by default")] + CoordinatorReceivesSourceBytes, + #[error("source provider manifest would default to a full-repo tarball")] + DefaultFullRepoTarball, + #[error("source provider manifest has no allowed remote transfer mode")] + MissingRemoteTransferMode, +} + +pub trait SourceProviderModule { + fn kind(&self) -> SourceProviderKind; + fn manifest(&self) -> SourceProviderManifest; +} + +impl SourceProviderManifest { + pub fn local_first(kind: SourceProviderKind, description: impl Into) -> Self { + let transfer_policy = SourceTransferPolicy::local_first_snapshot_chunks(); + let digest = Self::digest_for(&kind, false, &transfer_policy); + Self { + kind, + digest, + description: description.into(), + coordinator_requires_checkout_access: false, + transfer_policy, + } + } + + pub fn validate_public_mvp(&self) -> Result<(), SourceManifestError> { + self.validate_shape()?; + if self.coordinator_requires_checkout_access { + return Err(SourceManifestError::CoordinatorCheckoutAccess); + } + if self + .transfer_policy + .coordinator_receives_source_bytes_by_default + { + return Err(SourceManifestError::CoordinatorReceivesSourceBytes); + } + if self.transfer_policy.default_full_repo_tarball { + return Err(SourceManifestError::DefaultFullRepoTarball); + } + if self.transfer_policy.allowed_remote_transfer.is_empty() { + return Err(SourceManifestError::MissingRemoteTransferMode); + } + Ok(()) + } + + fn validate_shape(&self) -> Result<(), SourceManifestError> { + if !self.digest.is_valid_sha256() { + return Err(SourceManifestError::InvalidDigest( + self.digest.as_str().to_owned(), + )); + } + if let SourceProviderKind::Custom(provider) = &self.kind { + if !valid_provider_id(provider) { + return Err(SourceManifestError::InvalidProviderId(provider.clone())); + } + } + if self.description.trim().is_empty() { + return Err(SourceManifestError::EmptyDescription); + } + if self.description.len() > 256 { + return Err(SourceManifestError::DescriptionTooLong); + } + if self.description.chars().any(char::is_control) { + return Err(SourceManifestError::DescriptionControlCharacter); + } + Ok(()) + } + + fn digest_for( + kind: &SourceProviderKind, + coordinator_requires_checkout_access: bool, + transfer_policy: &SourceTransferPolicy, + ) -> Digest { + let mut modes = transfer_policy + .allowed_remote_transfer + .iter() + .map(|mode| format!("{mode:?}")) + .collect::>(); + modes.sort(); + let mut parts = vec![ + b"source-provider-manifest:v2".to_vec(), + kind.provider_id().as_bytes().to_vec(), + coordinator_requires_checkout_access + .to_string() + .into_bytes(), + transfer_policy + .local_source_bytes_remain_node_local + .to_string() + .into_bytes(), + transfer_policy + .coordinator_receives_source_bytes_by_default + .to_string() + .into_bytes(), + transfer_policy + .default_full_repo_tarball + .to_string() + .into_bytes(), + ]; + parts.extend(modes.into_iter().map(String::into_bytes)); + Digest::from_parts(parts) + } +} + +fn valid_provider_id(provider: &str) -> bool { + !provider.is_empty() + && provider.len() <= 64 + && provider + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.')) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePreparation { + pub tenant: TenantId, + pub project: ProjectId, + pub provider: SourceProviderKind, + pub required_capabilities: BTreeSet, + pub coordinator_requires_checkout_access: bool, +} + +impl SourcePreparation { + pub fn node_task(tenant: TenantId, project: ProjectId, provider: SourceProviderKind) -> Self { + let capability = match provider { + SourceProviderKind::Filesystem => Capability::SourceFilesystem, + SourceProviderKind::Git => Capability::SourceGit, + SourceProviderKind::Custom(_) => Capability::SourceFilesystem, + }; + + Self { + tenant, + project, + provider, + required_capabilities: BTreeSet::from([capability]), + coordinator_requires_checkout_access: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_preparation_can_be_scheduled_as_node_task() { + let prep = SourcePreparation::node_task( + TenantId::from("tenant"), + ProjectId::from("project"), + SourceProviderKind::Git, + ); + + assert!(!prep.coordinator_requires_checkout_access); + assert!(prep.required_capabilities.contains(&Capability::SourceGit)); + } + + #[test] + fn local_first_source_manifest_rejects_bulk_coordinator_paths() { + let manifest = SourceProviderManifest::local_first( + SourceProviderKind::Git, + "node-side Git snapshot provider", + ); + + assert!(manifest.validate_public_mvp().is_ok()); + assert!(!manifest.coordinator_requires_checkout_access); + assert!( + !manifest + .transfer_policy + .coordinator_receives_source_bytes_by_default + ); + assert!(!manifest.transfer_policy.default_full_repo_tarball); + assert!(manifest + .transfer_policy + .allowed_remote_transfer + .contains(&SourceTransferMode::ExplicitSnapshotChunks)); + } + + #[test] + fn source_manifest_validation_treats_manifest_as_hostile_input() { + let mut manifest = SourceProviderManifest::local_first( + SourceProviderKind::Custom("gitlab-lfs".to_owned()), + "custom provider", + ); + assert!(manifest.validate_public_mvp().is_ok()); + + manifest.kind = SourceProviderKind::Custom("../checkout".to_owned()); + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::InvalidProviderId( + "../checkout".to_owned() + )) + ); + + manifest.kind = SourceProviderKind::Git; + manifest.digest = Digest::sha256("valid"); + manifest.coordinator_requires_checkout_access = true; + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::CoordinatorCheckoutAccess) + ); + + manifest.coordinator_requires_checkout_access = false; + manifest + .transfer_policy + .coordinator_receives_source_bytes_by_default = true; + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::CoordinatorReceivesSourceBytes) + ); + + manifest + .transfer_policy + .coordinator_receives_source_bytes_by_default = false; + manifest.transfer_policy.default_full_repo_tarball = true; + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::DefaultFullRepoTarball) + ); + } + + #[test] + fn source_manifest_rejects_malformed_digest_from_json() { + let mut manifest = SourceProviderManifest::local_first( + SourceProviderKind::Filesystem, + "filesystem provider", + ); + let value = serde_json::to_value(&manifest).unwrap(); + let mut object = value.as_object().unwrap().clone(); + object.insert( + "digest".to_owned(), + serde_json::Value::String("sha256:not-a-real-digest".to_owned()), + ); + manifest = serde_json::from_value(serde_json::Value::Object(object)).unwrap(); + + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::InvalidDigest( + "sha256:not-a-real-digest".to_owned() + )) + ); + } +} diff --git a/crates/disasmer-core/src/transport.rs b/crates/disasmer-core/src/transport.rs new file mode 100644 index 0000000..3ab9c11 --- /dev/null +++ b/crates/disasmer-core/src/transport.rs @@ -0,0 +1,243 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ArtifactId, Digest, NodeId, ProcessId, ProjectId, TenantId}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportKind { + NativeQuic, +} + +pub trait Transport { + fn kind(&self) -> TransportKind; + fn authenticated_direct_connections(&self) -> bool; +} + +#[derive(Clone, Debug, Default)] +pub struct NativeQuicTransport; + +impl Transport for NativeQuicTransport { + fn kind(&self) -> TransportKind { + TransportKind::NativeQuic + } + + fn authenticated_direct_connections(&self) -> bool { + true + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DataPlaneScope { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub object: DataPlaneObject, + pub authorization_subject: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataPlaneObject { + Artifact(ArtifactId), + Blob(Digest), + SourceSnapshot(Digest), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeEndpoint { + pub node: NodeId, + pub advertised_addr: String, + pub public_key_fingerprint: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RendezvousRequest { + pub scope: DataPlaneScope, + pub source: NodeEndpoint, + pub destination: NodeEndpoint, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectBulkTransferPlan { + pub transport: TransportKind, + pub scope: DataPlaneScope, + pub source: NodeEndpoint, + pub destination: NodeEndpoint, + pub authorization_digest: Digest, + pub coordinator_assisted_rendezvous: bool, + pub coordinator_bulk_relay_allowed: bool, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum TransportError { + #[error( + "direct node-to-node connectivity is unavailable for scoped data-plane transfer: {reason}; coordinator bulk relay is disabled" + )] + DirectConnectivityUnavailable { reason: String }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BulkTransferDecision { + DirectAuthenticated { scope: DataPlaneScope }, + FailClear { message: String }, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("bulk relay through coordinator is not allowed by default")] +pub struct BulkRelayDenied; + +impl NativeQuicTransport { + pub fn plan_authenticated_direct_bulk_transfer( + &self, + request: RendezvousRequest, + direct_connectivity: bool, + failure_reason: impl Into, + ) -> Result { + if !direct_connectivity { + return Err(TransportError::DirectConnectivityUnavailable { + reason: failure_reason.into(), + }); + } + + let authorization_digest = data_plane_authorization_digest(&request); + Ok(DirectBulkTransferPlan { + transport: self.kind(), + scope: request.scope, + source: request.source, + destination: request.destination, + authorization_digest, + coordinator_assisted_rendezvous: true, + coordinator_bulk_relay_allowed: false, + }) + } +} + +pub fn direct_bulk_transfer_or_error( + scope: DataPlaneScope, + direct_connectivity: bool, +) -> BulkTransferDecision { + if direct_connectivity { + BulkTransferDecision::DirectAuthenticated { scope } + } else { + BulkTransferDecision::FailClear { + message: + "direct node-to-node connectivity is unavailable; coordinator bulk relay is disabled" + .to_owned(), + } + } +} + +fn data_plane_authorization_digest(request: &RendezvousRequest) -> Digest { + let object = match &request.scope.object { + DataPlaneObject::Artifact(artifact) => format!("artifact:{artifact}"), + DataPlaneObject::Blob(digest) => format!("blob:{}", digest.as_str()), + DataPlaneObject::SourceSnapshot(digest) => format!("source:{}", digest.as_str()), + }; + + Digest::from_parts([ + b"dataplane-auth:v1".as_slice(), + request.scope.tenant.as_str().as_bytes(), + request.scope.project.as_str().as_bytes(), + request.scope.process.as_str().as_bytes(), + object.as_bytes(), + request.scope.authorization_subject.as_bytes(), + request.source.node.as_str().as_bytes(), + request.source.public_key_fingerprint.as_str().as_bytes(), + request.destination.node.as_str().as_bytes(), + request + .destination + .public_key_fingerprint + .as_str() + .as_bytes(), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn endpoint(name: &str) -> NodeEndpoint { + NodeEndpoint { + node: NodeId::from(name), + advertised_addr: format!("{name}.mesh.invalid:4433"), + public_key_fingerprint: Digest::sha256(format!("{name}-public-key")), + } + } + + fn scope(project: &str) -> DataPlaneScope { + DataPlaneScope { + tenant: TenantId::from("tenant"), + project: ProjectId::from(project), + process: ProcessId::from("process"), + object: DataPlaneObject::Artifact(ArtifactId::from("artifact")), + authorization_subject: "node-a-to-node-b".to_owned(), + } + } + + #[test] + fn failed_direct_transfer_does_not_silently_relay() { + let decision = direct_bulk_transfer_or_error(scope("project"), false); + + assert!(matches!(decision, BulkTransferDecision::FailClear { .. })); + } + + #[test] + fn native_quic_rendezvous_plan_is_scoped_and_disallows_coordinator_bulk_relay() { + let transport = NativeQuicTransport; + let request = RendezvousRequest { + scope: scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + }; + + let plan = transport + .plan_authenticated_direct_bulk_transfer(request.clone(), true, "") + .unwrap(); + let changed_scope_plan = transport + .plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: scope("other-project"), + ..request + }, + true, + "", + ) + .unwrap(); + + assert_eq!(plan.transport, TransportKind::NativeQuic); + assert_eq!(plan.scope.tenant, TenantId::from("tenant")); + assert_eq!(plan.scope.project, ProjectId::from("project")); + assert_eq!(plan.scope.process, ProcessId::from("process")); + assert_eq!( + plan.scope.object, + DataPlaneObject::Artifact(ArtifactId::from("artifact")) + ); + assert_eq!(plan.source.node, NodeId::from("node-a")); + assert_eq!(plan.destination.node, NodeId::from("node-b")); + assert!(plan.coordinator_assisted_rendezvous); + assert!(!plan.coordinator_bulk_relay_allowed); + assert_ne!( + plan.authorization_digest, + changed_scope_plan.authorization_digest + ); + } + + #[test] + fn failed_direct_rendezvous_reports_clear_error_instead_of_relaying() { + let error = NativeQuicTransport + .plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + }, + false, + "nat traversal failed", + ) + .unwrap_err(); + + assert!(error.to_string().contains("nat traversal failed")); + assert!(error + .to_string() + .contains("coordinator bulk relay is disabled")); + } +} diff --git a/crates/disasmer-core/src/vfs.rs b/crates/disasmer-core/src/vfs.rs new file mode 100644 index 0000000..9e86e11 --- /dev/null +++ b/crates/disasmer-core/src/vfs.rs @@ -0,0 +1,241 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Digest, NodeId, TaskInstanceId}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct VfsPath(String); + +impl VfsPath { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if !path.starts_with("/vfs/") { + return Err(VfsError::InvalidPath(path)); + } + Ok(Self(path)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VfsObject { + pub path: VfsPath, + pub digest: Digest, + pub size: u64, + pub producer: TaskInstanceId, + pub node: NodeId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VfsManifest { + pub epoch: u64, + pub producer: TaskInstanceId, + pub node: NodeId, + pub objects: BTreeMap, + pub large_bytes_uploaded: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SyncPolicy { + MetadataOnly, + ExplicitNode(NodeId), + ExplicitStore(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum VfsSyncDecision { + NoBytesMoved, + MoveBytesToNode(NodeId), + MoveBytesToStore(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReuseDecision { + SameNodeZeroCopy, + NeedsTransfer { from: NodeId, to: NodeId }, + Unavailable, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum VfsError { + #[error("VFS path must start with /vfs/: {0}")] + InvalidPath(String), + #[error("path is not visible in the published VFS manifest: {0}")] + NotVisible(String), +} + +#[derive(Clone, Debug)] +pub struct VfsOverlay { + task: TaskInstanceId, + node: NodeId, + epoch: u64, + pending: BTreeMap, + published: BTreeMap, +} + +impl VfsOverlay { + pub fn new(task: TaskInstanceId, node: NodeId) -> Self { + Self { + task, + node, + epoch: 0, + pending: BTreeMap::new(), + published: BTreeMap::new(), + } + } + + pub fn write(&mut self, path: VfsPath, digest: Digest, size: u64) -> VfsObject { + let object = VfsObject { + path: path.clone(), + digest, + size, + producer: self.task.clone(), + node: self.node.clone(), + }; + self.pending.insert(path, object.clone()); + object + } + + pub fn flush(&mut self) -> VfsManifest { + self.epoch += 1; + self.published.append(&mut self.pending); + VfsManifest { + epoch: self.epoch, + producer: self.task.clone(), + node: self.node.clone(), + objects: self.published.clone(), + large_bytes_uploaded: false, + } + } + + pub fn sync(&self, policy: SyncPolicy) -> VfsSyncDecision { + match policy { + SyncPolicy::MetadataOnly => VfsSyncDecision::NoBytesMoved, + SyncPolicy::ExplicitNode(node) => VfsSyncDecision::MoveBytesToNode(node), + SyncPolicy::ExplicitStore(store) => VfsSyncDecision::MoveBytesToStore(store), + } + } + + pub fn read_published<'a>( + manifest: &'a VfsManifest, + path: &VfsPath, + ) -> Result<&'a VfsObject, VfsError> { + manifest + .objects + .get(path) + .ok_or_else(|| VfsError::NotVisible(path.as_str().to_owned())) + } + + pub fn reuse_for_consumer( + manifest: &VfsManifest, + path: &VfsPath, + consumer_node: &NodeId, + ) -> ReuseDecision { + let Some(object) = manifest.objects.get(path) else { + return ReuseDecision::Unavailable; + }; + if &object.node == consumer_node { + ReuseDecision::SameNodeZeroCopy + } else { + ReuseDecision::NeedsTransfer { + from: object.node.clone(), + to: consumer_node.clone(), + } + } + } + + pub fn discard_unflushed(&mut self) { + self.pending.clear(); + } + + pub fn pending_len(&self) -> usize { + self.pending.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path() -> VfsPath { + VfsPath::new("/vfs/artifacts/app").unwrap() + } + + #[test] + fn flush_publishes_manifest_without_large_byte_upload() { + let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + + let manifest = overlay.flush(); + + assert_eq!(manifest.epoch, 1); + assert!(!manifest.large_bytes_uploaded); + assert!(manifest.objects.contains_key(&path())); + } + + #[test] + fn downstream_task_can_read_after_flush_but_not_before() { + let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + + let empty = VfsManifest { + epoch: 0, + producer: TaskInstanceId::from("task"), + node: NodeId::from("node-a"), + objects: BTreeMap::new(), + large_bytes_uploaded: false, + }; + assert!(VfsOverlay::read_published(&empty, &path()).is_err()); + + let manifest = overlay.flush(); + assert!(VfsOverlay::read_published(&manifest, &path()).is_ok()); + } + + #[test] + fn sync_is_explicit_and_policy_driven() { + let overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a")); + + assert_eq!( + overlay.sync(SyncPolicy::MetadataOnly), + VfsSyncDecision::NoBytesMoved + ); + assert_eq!( + overlay.sync(SyncPolicy::ExplicitStore("s3://bucket/app".to_owned())), + VfsSyncDecision::MoveBytesToStore("s3://bucket/app".to_owned()) + ); + } + + #[test] + fn same_node_reuse_avoids_transfer() { + let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + let manifest = overlay.flush(); + + assert_eq!( + VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-a")), + ReuseDecision::SameNodeZeroCopy + ); + assert_eq!( + VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-b")), + ReuseDecision::NeedsTransfer { + from: NodeId::from("node-a"), + to: NodeId::from("node-b") + } + ); + } + + #[test] + fn unflushed_task_local_changes_can_be_discarded() { + let mut overlay = VfsOverlay::new(TaskInstanceId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + + overlay.discard_unflushed(); + + assert_eq!(overlay.pending_len(), 0); + } +} diff --git a/crates/disasmer-core/src/wire.rs b/crates/disasmer-core/src/wire.rs new file mode 100644 index 0000000..e611368 --- /dev/null +++ b/crates/disasmer-core/src/wire.rs @@ -0,0 +1,114 @@ +use serde_json::{json, Value}; + +pub const COORDINATOR_PROTOCOL_VERSION: u64 = 1; +pub const COORDINATOR_WIRE_REQUEST_TYPE: &str = "coordinator_request"; + +pub fn coordinator_wire_request(request_id: impl Into, payload: Value) -> Value { + let operation = coordinator_payload_operation(&payload); + let authentication = coordinator_authentication_metadata(&payload); + json!({ + "type": COORDINATOR_WIRE_REQUEST_TYPE, + "protocol_version": COORDINATOR_PROTOCOL_VERSION, + "request_id": request_id.into(), + "operation": operation, + "authentication": authentication, + "payload": payload, + }) +} + +pub fn coordinator_payload_operation(payload: &Value) -> String { + payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned() +} + +pub fn coordinator_authentication_metadata(payload: &Value) -> Value { + let operation = coordinator_payload_operation(payload); + match operation.as_str() { + "authenticated" => json!({ + "kind": "cli_session", + "session": true, + "request_operation": payload + .get("request") + .map(coordinator_payload_operation) + .unwrap_or_else(|| "unknown".to_owned()), + }), + "signed_node" => json!({ + "kind": "node_signature", + "node": payload.get("node").and_then(Value::as_str), + }), + "node_heartbeat" if payload.get("node_signature").is_some() => json!({ + "kind": "node_signature", + "node": payload.get("node").and_then(Value::as_str), + }), + "start_process" | "launch_task" if payload.get("agent_signature").is_some() => json!({ + "kind": "agent_signature", + "agent": payload.get("actor_agent").and_then(Value::as_str), + "fingerprint": payload.get("agent_public_key_fingerprint").and_then(Value::as_str), + }), + "admin_status" | "suspend_tenant" if payload.get("admin_proof").is_some() => json!({ + "kind": "admin_proof", + "actor": payload.get("actor_user").and_then(Value::as_str), + "nonce": payload.get("admin_nonce").and_then(Value::as_str), + "issued_at_epoch_seconds": payload.get("issued_at_epoch_seconds").and_then(Value::as_u64), + }), + "exchange_node_enrollment_grant" => json!({ + "kind": "node_enrollment_grant", + "node": payload.get("node").and_then(Value::as_str), + }), + _ => json!({ + "kind": "none", + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinator_wire_request_wraps_payload_without_exposing_secret_metadata() { + let envelope = coordinator_wire_request( + "cli-1", + json!({ + "type": "authenticated", + "session_secret": "secret-value", + "request": { "type": "list_projects" }, + }), + ); + + assert_eq!(envelope["type"], COORDINATOR_WIRE_REQUEST_TYPE); + assert_eq!(envelope["protocol_version"], COORDINATOR_PROTOCOL_VERSION); + assert_eq!(envelope["request_id"], "cli-1"); + assert_eq!(envelope["operation"], "authenticated"); + assert_eq!(envelope["authentication"]["kind"], "cli_session"); + assert_eq!( + envelope["authentication"]["request_operation"], + "list_projects" + ); + assert_eq!(envelope["authentication"].get("session_secret"), None); + assert_eq!(envelope["payload"]["session_secret"], "secret-value"); + } + + #[test] + fn coordinator_wire_request_describes_signature_metadata() { + let envelope = coordinator_wire_request( + "node-1", + json!({ + "type": "signed_node", + "node": "node-a", + "node_signature": { + "nonce": "nonce", + "issued_at_epoch_seconds": 1, + "signature": "ed25519:sig" + }, + "request": { "type": "poll_task_assignment", "node": "node-a" }, + }), + ); + + assert_eq!(envelope["authentication"]["kind"], "node_signature"); + assert_eq!(envelope["authentication"]["node"], "node-a"); + } +} diff --git a/crates/disasmer-dap/Cargo.toml b/crates/disasmer-dap/Cargo.toml new file mode 100644 index 0000000..d389edf --- /dev/null +++ b/crates/disasmer-dap/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "disasmer-dap" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "disasmer-debug-dap" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +base64.workspace = true +disasmer-core = { path = "../disasmer-core" } +disasmer-control = { path = "../disasmer-control" } +disasmer-node = { path = "../disasmer-node" } +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/disasmer-dap/src/adapter.rs b/crates/disasmer-dap/src/adapter.rs new file mode 100644 index 0000000..c2c632f --- /dev/null +++ b/crates/disasmer-dap/src/adapter.rs @@ -0,0 +1,814 @@ +use std::io::{self, BufReader}; + +use anyhow::Result; +use disasmer_core::DebugRuntimeState; +use serde_json::{json, Value}; + +use crate::breakpoints::{ + freeze_all, freeze_all_at_line, next_breakpoint_after, position_confirmed_breakpoint_stop, + request_thread, request_thread_id, resolve_breakpoints_for_source, + restart_requires_whole_process, simulated_freeze_failure_thread, step_thread, + stopped_thread_for_breakpoint, +}; +use crate::dap_protocol::{initialize_capabilities, read_message, DapWriter}; +use crate::demo_backend::{ + is_explicit_demo_backend, start_simulated_backend, LINUX_THREAD, MAIN_THREAD, +}; +use crate::runtime_client::{ + attach_services_runtime, create_debug_epoch, relaunch_services_main_runtime, restart_task, + resume_debug_epoch, run_live_services_runtime, run_local_services_runtime, + wait_for_debug_epoch_frozen, wait_for_debug_epoch_resumed, wait_for_services_runtime_outcome, + RuntimeContinuationOutcome, +}; +use crate::variables::variables_response; +use crate::virtual_model::{AdapterState, DapSessionMode, RuntimeBackend}; + +pub(crate) fn run_adapter() -> Result<()> { + let mut writer = DapWriter::new(); + let mut reader = BufReader::new(io::stdin()); + let mut state = AdapterState::default(); + let mut _local_runtime_session = None; + + while let Some(request) = read_message(&mut reader)? { + let command = request + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + match command { + "initialize" => writer.response(&request, true, initialize_capabilities())?, + "launch" | "attach" => { + let args = request + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + state.entry = args + .get("entry") + .and_then(Value::as_str) + .unwrap_or("build") + .to_owned(); + let project = args + .get("project") + .and_then(Value::as_str) + .unwrap_or(".") + .to_owned(); + let runtime_backend = match runtime_backend_from_launch_arg( + args.get("runtimeBackend").and_then(Value::as_str), + ) { + Ok(runtime_backend) => runtime_backend, + Err(message) => { + writer.error_response(&request, message)?; + continue; + } + }; + let coordinator_endpoint = args + .get("coordinatorEndpoint") + .and_then(Value::as_str) + .map(str::to_owned); + let source_path = args + .get("sourcePath") + .and_then(Value::as_str) + .map(str::to_owned); + if let Err(err) = state.configure_launch( + project, + state.entry.clone(), + runtime_backend, + coordinator_endpoint, + source_path, + ) { + writer.error_response(&request, err.to_string())?; + continue; + } + state.restart_existing = args + .get("restartExisting") + .and_then(Value::as_bool) + .unwrap_or(false); + if let Some(process_id) = args.get("processId").and_then(Value::as_str) { + state.process = disasmer_core::ProcessId::new(process_id); + } + if command == "attach" { + state.session_mode = DapSessionMode::Attach; + state.command_status = + format!("attached to existing virtual process {}", state.process); + } + writer.response(&request, true, json!({}))?; + writer.event("initialized", json!({}))?; + } + "setBreakpoints" => { + let requested_source_path = request + .get("arguments") + .and_then(|value| value.get("source")) + .and_then(|value| value.get("path")) + .and_then(Value::as_str); + let requested_lines = request + .get("arguments") + .and_then(|value| value.get("breakpoints")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.get("line").and_then(Value::as_i64)) + .collect::>() + }) + .unwrap_or_default(); + let response = resolve_breakpoints_for_source( + &mut state, + requested_source_path, + requested_lines, + ) + .iter() + .map(|breakpoint| breakpoint.to_dap()) + .collect::>(); + writer.response(&request, true, json!({ "breakpoints": response }))?; + } + "setExceptionBreakpoints" => { + writer.response(&request, true, json!({ "breakpoints": [] }))?; + } + "configurationDone" => { + if state.session_mode == DapSessionMode::Attach { + match state.runtime_backend { + RuntimeBackend::Simulated => { + state.command_status = + format!("attached to existing virtual process {}", state.process); + } + RuntimeBackend::LocalServices | RuntimeBackend::LiveServices => { + match attach_services_runtime(&state) { + Ok(record) => state.apply_attach_record(record), + Err(err) => { + writer.error_response( + &request, + format!("services runtime attach failed: {err:#}"), + )?; + continue; + } + } + } + } + } else { + match state.runtime_backend { + RuntimeBackend::LocalServices => match run_local_services_runtime(&state) { + Ok((record, session)) => { + state.apply_runtime_record(record); + _local_runtime_session = Some(session); + } + Err(err) => { + writer.error_response( + &request, + format!("local services runtime launch failed: {err:#}"), + )?; + continue; + } + }, + RuntimeBackend::LiveServices => match run_live_services_runtime(&state) { + Ok(record) => { + state.apply_runtime_record(record); + } + Err(err) => { + writer.error_response( + &request, + format!("live services runtime launch failed: {err:#}"), + )?; + continue; + } + }, + RuntimeBackend::Simulated => { + start_simulated_backend(&mut state); + } + } + } + writer.response(&request, true, json!({}))?; + let session_message = if state.session_mode == DapSessionMode::Attach { + format!( + "Attached to Disasmer virtual process {} with {:?} runtime\n", + state.process, state.runtime_backend + ) + } else { + format!( + "Disasmer bundle refreshed for entry `{}`; starting virtual process {} with {:?} runtime\n", + state.entry, state.process, state.runtime_backend + ) + }; + writer.output("console", session_message)?; + if !state.breakpoints.is_empty() { + let stopped_thread = stopped_thread_for_breakpoint(&state); + if matches!( + state.runtime_backend, + RuntimeBackend::LocalServices | RuntimeBackend::LiveServices + ) && state.coordinator_debug_epoch.is_some() + { + position_confirmed_breakpoint_stop(&mut state, stopped_thread); + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Disasmer Debug Epoch all-stop confirmed by every active participant", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + continue; + } + match freeze_all(&mut state, stopped_thread, None) { + Ok(()) => { + if let Err(err) = record_coordinator_debug_epoch( + &mut state, + stopped_thread, + "breakpoint", + ) { + let message = format!("coordinator debug epoch failed: {err:#}"); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + continue; + } + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Disasmer Debug Epoch all-stop", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Err(failure) => { + let message = failure.message(); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + } else { + writer.event("terminated", json!({}))?; + } + } + "threads" => { + let threads = state + .threads + .values() + .map(|thread| { + json!({ + "id": thread.id, + "name": format!("{}/{}", state.process, thread.name), + }) + }) + .collect::>(); + writer.response(&request, true, json!({ "threads": threads }))?; + } + "stackTrace" => { + let thread = request_thread(&request, &state); + let source_path = crate::source::stack_source_path(&state); + let frame_name = thread + .runtime_stack_frames + .first() + .cloned() + .unwrap_or_else(|| format!("{}::run", thread.name)); + writer.response( + &request, + true, + json!({ + "stackFrames": [{ + "id": thread.frame_id, + "name": frame_name, + "line": thread.line, + "column": 1, + "source": { + "name": crate::source::source_name(&source_path), + "path": source_path, + "presentationHint": "normal" + } + }], + "totalFrames": 1 + }), + )?; + } + "source" => match crate::source::source_response(&state, &request) { + Ok(body) => writer.response(&request, true, body)?, + Err(err) => writer.error_response(&request, err.to_string())?, + }, + "scopes" => { + let frame_id = request + .get("arguments") + .and_then(|value| value.get("frameId")) + .and_then(Value::as_i64) + .unwrap_or(1000 + crate::demo_backend::MAIN_THREAD); + let thread = state + .threads + .values() + .find(|thread| thread.frame_id == frame_id) + .cloned() + .unwrap_or_else(|| state.threads[&crate::demo_backend::MAIN_THREAD].clone()); + writer.response( + &request, + true, + json!({ + "scopes": [ + { + "name": "Source Locals", + "variablesReference": thread.locals_ref, + "expensive": false, + "presentationHint": "locals" + }, + { + "name": "Wasm Frame Locals", + "variablesReference": thread.wasm_locals_ref, + "expensive": false, + "presentationHint": "locals" + }, + { + "name": "Task Args and Handles", + "variablesReference": thread.args_ref, + "expensive": false, + "presentationHint": "arguments" + }, + { + "name": "Disasmer Runtime", + "variablesReference": thread.runtime_ref, + "expensive": false + }, + { + "name": "Recent Output", + "variablesReference": thread.output_ref, + "expensive": false + } + ] + }), + )?; + } + "variables" => { + let reference = request + .get("arguments") + .and_then(|value| value.get("variablesReference")) + .and_then(Value::as_i64) + .unwrap_or(0); + writer.response(&request, true, variables_response(&state, reference))?; + } + "evaluate" => { + let expression = request + .get("arguments") + .and_then(|value| value.get("expression")) + .and_then(Value::as_str) + .unwrap_or_default(); + let result = match expression { + "virtual_process_id" => state.process.to_string(), + "debug_epoch" => state.epoch.to_string(), + "command_status" => state.command_status.clone(), + _ => "not available".to_owned(), + }; + writer.response( + &request, + true, + json!({ "result": result, "variablesReference": 0 }), + )?; + } + "pause" => { + let stopped_thread = default_thread_id(&state); + match freeze_all( + &mut state, + stopped_thread, + simulated_freeze_failure_thread(&request), + ) { + Ok(()) => { + if let Err(err) = + record_coordinator_debug_epoch(&mut state, stopped_thread, "pause") + { + let message = format!("coordinator debug epoch failed: {err:#}"); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + continue; + } + writer.response(&request, true, json!({}))?; + writer.event( + "stopped", + json!({ + "reason": "pause", + "description": "Disasmer Debug Epoch pause", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Err(failure) => { + let message = failure.message(); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + } + "continue" => { + let thread_id = + request_thread_id(&request).unwrap_or_else(|| default_thread_id(&state)); + let next_breakpoint = next_breakpoint_after(&state, thread_id); + let previous_debug_epoch = state.coordinator_debug_epoch.unwrap_or(state.epoch); + if let Err(err) = resume_coordinator_epoch(&mut state) { + let message = format!("coordinator debug epoch resume failed: {err:#}"); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + continue; + } + for thread in state.threads.values_mut() { + if thread.state == DebugRuntimeState::Frozen { + thread.state = DebugRuntimeState::Running; + } + } + writer.response(&request, true, json!({ "allThreadsContinued": true }))?; + writer.event( + "continued", + json!({ + "threadId": thread_id, + "allThreadsContinued": true, + }), + )?; + if matches!( + state.runtime_backend, + RuntimeBackend::LocalServices | RuntimeBackend::LiveServices + ) { + match wait_for_services_runtime_outcome(&state, previous_debug_epoch) { + Ok(RuntimeContinuationOutcome::Breakpoint(record)) => { + state.apply_runtime_record(record); + let stopped_thread = stopped_thread_for_breakpoint(&state); + position_confirmed_breakpoint_stop(&mut state, stopped_thread); + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Disasmer Debug Epoch all-stop confirmed by every active participant", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Ok(RuntimeContinuationOutcome::Terminal(record)) => { + state.apply_runtime_record(record); + if state.last_task_failed { + writer.output("stderr", format!("{}\n", state.command_status))?; + } + writer.event("terminated", json!({}))?; + } + Err(err) => { + let message = format!("continued runtime observation failed: {err:#}"); + writer.output("stderr", format!("{message}\n"))?; + writer.event( + "stopped", + json!({ + "reason": "exception", + "description": message, + "threadId": thread_id, + "allThreadsStopped": false, + }), + )?; + } + } + continue; + } + if let Some((stopped_thread, line)) = next_breakpoint { + match freeze_all_at_line(&mut state, stopped_thread, line, None) { + Ok(()) => { + if let Err(err) = record_coordinator_debug_epoch( + &mut state, + stopped_thread, + "breakpoint", + ) { + let message = format!("coordinator debug epoch failed: {err:#}"); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + continue; + } + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Disasmer Debug Epoch all-stop", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Err(failure) => { + let message = failure.message(); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + } else { + writer.event("terminated", json!({}))?; + } + } + "next" | "stepIn" | "stepOut" => { + if state.runtime_backend != RuntimeBackend::Simulated { + writer.error_response( + &request, + "source stepping is not yet available from the executing node; use continue or pause instead of a synthetic step", + )?; + continue; + } + let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD); + let description = match command { + "next" => "step over", + "stepIn" => "step in", + "stepOut" => "step out", + _ => "step", + }; + step_thread(&mut state, thread_id, description); + writer.response(&request, true, json!({}))?; + writer.event( + "stopped", + json!({ + "reason": "step", + "description": format!("Disasmer Debug Epoch {description}"), + "threadId": thread_id, + "allThreadsStopped": true, + }), + )?; + } + "restart" | "restartFrame" => { + let thread_id = request_thread_id(&request) + .or_else(|| { + request + .get("arguments") + .and_then(|arguments| arguments.get("frameId")) + .and_then(Value::as_i64) + .and_then(|frame_id| { + state + .threads + .values() + .find(|thread| thread.frame_id == frame_id) + .map(|thread| thread.id) + }) + }) + .unwrap_or_else(|| default_thread_id(&state)); + if restart_requires_whole_process(&request) { + let message = format!( + "Incompatible source edit requires whole virtual-process restart for {}", + state.process + ); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + continue; + } + let restarting_failed_task = state + .threads + .get(&thread_id) + .is_some_and(|thread| matches!(thread.state, DebugRuntimeState::Failed(_))); + if state.runtime_backend != RuntimeBackend::Simulated { + if thread_id == MAIN_THREAD && restarting_failed_task { + match relaunch_services_main_runtime(&state) { + Ok(record) => { + state.apply_runtime_record(record); + let stopped_thread = stopped_thread_for_breakpoint(&state); + position_confirmed_breakpoint_stop(&mut state, stopped_thread); + writer.response(&request, true, json!({}))?; + writer.output( + "console", + "Rebuilt the current bundle and restarted the failed coordinator main from its entry boundary\n", + )?; + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Restarted main from the rebuilt bundle; every active participant confirmed all-stop", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Err(err) => { + let message = format!("coordinator main restart failed: {err:#}"); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + continue; + } + match restart_task_through_coordinator(&mut state, thread_id) { + Ok(message) => { + let previous_debug_epoch = + state.coordinator_debug_epoch.unwrap_or(state.epoch); + if let Some(thread) = state.threads.get_mut(&thread_id) { + thread.state = DebugRuntimeState::Running; + thread.recent_output.push(message.clone()); + } + state.last_task_failed = false; + state.command_status = message.clone(); + writer.response(&request, true, json!({}))?; + writer.output("console", format!("{message}\n"))?; + if matches!( + state.runtime_backend, + RuntimeBackend::LocalServices | RuntimeBackend::LiveServices + ) { + match wait_for_services_runtime_outcome( + &state, + previous_debug_epoch, + ) { + Ok(RuntimeContinuationOutcome::Breakpoint(record)) => { + state.apply_runtime_record(record); + let stopped_thread = stopped_thread_for_breakpoint(&state); + position_confirmed_breakpoint_stop( + &mut state, + stopped_thread, + ); + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Restarted task reached a Wasm probe and every active participant confirmed all-stop", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Ok(RuntimeContinuationOutcome::Terminal(record)) => { + state.apply_runtime_record(record); + writer.event("terminated", json!({}))?; + } + Err(err) => { + writer.event( + "stopped", + json!({ + "reason": "exception", + "description": format!("restarted runtime observation failed: {err:#}"), + "threadId": thread_id, + "allThreadsStopped": false, + }), + )?; + } + } + } + } + Err(err) => { + let message = format!("coordinator task restart failed: {err:#}"); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + continue; + } + if let Some(thread) = state.threads.get_mut(&thread_id) { + thread.state = DebugRuntimeState::Running; + thread + .recent_output + .push("task restarted from VFS checkpoint".to_owned()); + } + if restarting_failed_task { + state.last_task_failed = false; + } + state.command_status = format!( + "{} task restarted from compatible VFS checkpoint", + if restarting_failed_task { + "failed" + } else { + "selected" + } + ); + writer.response(&request, true, json!({}))?; + writer.output( + "console", + format!( + "Restarted {} task from compatible VFS checkpoint on thread {thread_id}\n", + if restarting_failed_task { + "failed" + } else { + "selected" + } + ), + )?; + } + "disconnect" | "terminate" => { + writer.response(&request, true, json!({}))?; + writer.event("terminated", json!({}))?; + break; + } + _ => writer.error_response(&request, format!("unsupported DAP command: {command}"))?, + } + } + + Ok(()) +} + +fn restart_task_through_coordinator(state: &mut AdapterState, thread_id: i64) -> Result { + let task = state + .threads + .get(&thread_id) + .or_else(|| state.threads.get(&default_thread_id(state))) + .map(|thread| thread.task.clone()) + .ok_or_else(|| anyhow::anyhow!("selected debug thread does not map to a virtual task"))?; + let record = restart_task(state, &task)?; + if !record.accepted { + let mut message = format!( + "coordinator refused task restart for `{task}`: {}", + record.message + ); + if record.requires_whole_process_restart { + message.push_str("; whole virtual-process restart required"); + } + if record.active_task { + message.push_str("; selected task is still active"); + } + if record.completed_event_observed { + message.push_str("; only terminal task-event metadata is available"); + } + return Err(anyhow::anyhow!(message)); + } + if !record.clean_boundary_available { + return Err(anyhow::anyhow!( + "coordinator accepted task restart for `{task}` without a clean checkpoint boundary" + )); + } + let restarted_task = record.restarted_task_instance.ok_or_else(|| { + anyhow::anyhow!("coordinator accepted task restart without a new task-instance ID") + })?; + if let Some(thread) = state.threads.get_mut(&thread_id) { + thread.task = restarted_task.clone(); + } + state.debug_probes = + crate::breakpoints::load_bundle_debug_probes(&state.project, &state.source_path); + Ok(format!( + "Coordinator restarted task `{task}` as `{restarted_task}` from the rebuilt bundle and a clean runtime checkpoint boundary" + )) +} + +fn record_coordinator_debug_epoch( + state: &mut AdapterState, + stopped_thread: i64, + reason: &str, +) -> Result<()> { + if state.runtime_backend == RuntimeBackend::Simulated { + return Ok(()); + } + let stopped_task = state + .threads + .get(&stopped_thread) + .map(|thread| thread.task.clone()) + .unwrap_or_else(|| state.threads[&default_thread_id(state)].task.clone()); + let record = create_debug_epoch(state, &stopped_task, reason)?; + let status = wait_for_debug_epoch_frozen(state, record.epoch)?; + state.epoch = record.epoch; + state.coordinator_debug_epoch = Some(record.epoch); + let previous_status = state.command_status.clone(); + state.command_status = format!( + "{previous_status}; debug epoch {} {} after {}/{} signed participant freeze acknowledgements", + status.epoch, + status.command, + status.acknowledgements.len(), + status.expected_tasks + ); + if let Some(thread) = state.threads.get_mut(&stopped_thread) { + thread.recent_output.push(format!( + "coordinator debug epoch {} reached all-stop after {}/{} signed participant acknowledgements", + status.epoch, + status.acknowledgements.len(), + status.expected_tasks + )); + } + Ok(()) +} + +fn default_thread_id(state: &AdapterState) -> i64 { + if state.runtime_backend == RuntimeBackend::Simulated + && state.threads.contains_key(&LINUX_THREAD) + { + LINUX_THREAD + } else { + MAIN_THREAD + } +} + +fn resume_coordinator_epoch(state: &mut AdapterState) -> Result<()> { + let Some(epoch) = state.coordinator_debug_epoch else { + return Ok(()); + }; + if state.runtime_backend == RuntimeBackend::Simulated { + return Ok(()); + } + let record = resume_debug_epoch(state, epoch)?; + let status = wait_for_debug_epoch_resumed(state, epoch)?; + state.coordinator_debug_epoch = None; + state.command_status = format!( + "debug epoch {} resumed after {}/{} signed participant acknowledgements", + status.epoch, + status.acknowledgements.len(), + status.expected_tasks + ); + let status_thread = default_thread_id(state); + if let Some(thread) = state.threads.get_mut(&status_thread) { + thread.recent_output.push(format!( + "coordinator debug epoch {} resumed with {} after {}/{} acknowledgements ({} affected task(s))", + status.epoch, + status.command, + status.acknowledgements.len(), + status.expected_tasks, + record.affected_tasks + )); + } + Ok(()) +} + +pub(crate) fn runtime_backend_from_launch_arg( + value: Option<&str>, +) -> Result { + match value { + None | Some("local-services") => Ok(RuntimeBackend::LocalServices), + Some("live-services") => Ok(RuntimeBackend::LiveServices), + Some(value) if is_explicit_demo_backend(value) => Ok(RuntimeBackend::Simulated), + Some(value) => Err(format!( + "unsupported Disasmer runtimeBackend `{value}`; use local-services, live-services, or explicit demo" + )), + } +} diff --git a/crates/disasmer-dap/src/breakpoints.rs b/crates/disasmer-dap/src/breakpoints.rs new file mode 100644 index 0000000..17bbf30 --- /dev/null +++ b/crates/disasmer-dap/src/breakpoints.rs @@ -0,0 +1,340 @@ +use std::fs; + +use disasmer_core::{ + discover_source_debug_probes, BundleDebugProbe, DebugRuntimeState, TaskInstanceId, +}; +use serde_json::{json, Value}; + +use crate::demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD}; +use crate::virtual_model::{AdapterState, VirtualThread}; + +pub(crate) fn request_thread<'a>(request: &Value, state: &'a AdapterState) -> &'a VirtualThread { + let thread_id = request_thread_id(request).unwrap_or(MAIN_THREAD); + state + .threads + .get(&thread_id) + .unwrap_or_else(|| &state.threads[&MAIN_THREAD]) +} + +pub(crate) fn request_thread_id(request: &Value) -> Option { + request + .get("arguments") + .and_then(|value| value.get("threadId")) + .and_then(Value::as_i64) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResolvedBreakpoint { + pub(crate) id: usize, + pub(crate) line: i64, + pub(crate) verified: bool, + pub(crate) message: String, +} + +impl ResolvedBreakpoint { + pub(crate) fn to_dap(&self) -> Value { + json!({ + "id": self.id, + "verified": self.verified, + "line": self.line, + "message": self.message, + }) + } +} + +pub(crate) fn load_bundle_debug_probes(project: &str, source_path: &str) -> Vec { + let source = fs::read_to_string(crate::source::resolve_source_path(project, source_path)); + source + .map(|source| discover_source_debug_probes(source_path, &source)) + .unwrap_or_default() +} + +pub(crate) fn resolve_breakpoints( + state: &mut AdapterState, + requested_lines: Vec, +) -> Vec { + let resolved = requested_lines + .into_iter() + .enumerate() + .map(|(index, line)| { + let probe = debug_probe_for_line(state, line); + let verified = probe.is_some() + || (state.debug_probes.is_empty() + && source_function_name_at_line(state, line).is_some()); + let message = match probe { + Some(probe) => format!( + "Mapped to Disasmer debug probe {} for task {}", + probe.id, probe.task + ), + None if verified => "Mapped to Disasmer virtual source location".to_owned(), + None => "No Disasmer debug probe metadata covers this source line".to_owned(), + }; + ResolvedBreakpoint { + id: index + 1, + line, + verified, + message, + } + }) + .collect::>(); + state.breakpoints = resolved + .iter() + .filter(|breakpoint| breakpoint.verified) + .map(|breakpoint| breakpoint.line) + .collect(); + resolved +} + +pub(crate) fn resolve_breakpoints_for_source( + state: &mut AdapterState, + requested_source_path: Option<&str>, + requested_lines: Vec, +) -> Vec { + if requested_source_path.is_none_or(|requested_source_path| { + crate::source::source_paths_match(&state.project, &state.source_path, requested_source_path) + }) { + return resolve_breakpoints(state, requested_lines); + } + + requested_lines + .into_iter() + .enumerate() + .map(|(index, line)| ResolvedBreakpoint { + id: index + 1, + line, + verified: false, + message: format!( + "This debug session is configured for `{}`; breakpoints from another source file are not applied", + state.source_path + ), + }) + .collect() +} + +pub(crate) fn restart_requires_whole_process(request: &Value) -> bool { + let Some(arguments) = request.get("arguments") else { + return false; + }; + + arguments + .get("requiresWholeProcessRestart") + .and_then(Value::as_bool) + .unwrap_or(false) + || [ + "compatibility", + "sourceCompatibility", + "sourceEditCompatibility", + "taskCompatibility", + ] + .iter() + .filter_map(|field| arguments.get(field).and_then(Value::as_str)) + .any(is_incompatible_restart) + || arguments + .get("sourceEdit") + .and_then(|value| value.get("compatibility")) + .and_then(Value::as_str) + .is_some_and(is_incompatible_restart) +} + +fn is_incompatible_restart(value: &str) -> bool { + value.eq_ignore_ascii_case("incompatible") + || value.eq_ignore_ascii_case("whole-process") + || value.eq_ignore_ascii_case("whole_process") +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FreezeFailure { + pub(crate) thread_id: i64, + pub(crate) task: TaskInstanceId, +} + +impl FreezeFailure { + pub(crate) fn message(&self) -> String { + format!( + "debug all-stop failed: participant `{}` on thread {} could not freeze", + self.task, self.thread_id + ) + } +} + +pub(crate) fn simulated_freeze_failure_thread(request: &Value) -> Option { + request + .get("arguments") + .and_then(|arguments| arguments.get("simulateFreezeFailure")) + .and_then(Value::as_bool) + .unwrap_or(false) + .then_some(PACKAGE_THREAD) +} + +pub(crate) fn freeze_all( + state: &mut AdapterState, + stopped_thread: i64, + forced_failure_thread: Option, +) -> Result<(), FreezeFailure> { + let line = state + .breakpoints + .iter() + .copied() + .find(|line| thread_for_source_line(state, *line) == stopped_thread) + .or_else(|| state.breakpoints.first().copied()) + .unwrap_or_else(|| state.threads[&stopped_thread].line); + freeze_all_at_line(state, stopped_thread, line, forced_failure_thread) +} + +pub(crate) fn freeze_all_at_line( + state: &mut AdapterState, + stopped_thread: i64, + line: i64, + forced_failure_thread: Option, +) -> Result<(), FreezeFailure> { + if let Some(failure) = state.threads.values().find(|thread| { + thread.state == DebugRuntimeState::Running + && (!thread.freeze_supported || forced_failure_thread == Some(thread.id)) + }) { + return Err(FreezeFailure { + thread_id: failure.id, + task: failure.task.clone(), + }); + } + + state.epoch += 1; + for thread in state.threads.values_mut() { + if thread.state == DebugRuntimeState::Running { + thread.state = DebugRuntimeState::Frozen; + } + } + if let Some(thread) = state.threads.get_mut(&stopped_thread) { + thread.line = line; + thread + .recent_output + .push(format!("debug epoch {} all-stop", state.epoch)); + } + Ok(()) +} + +pub(crate) fn stopped_thread_for_breakpoint(state: &AdapterState) -> i64 { + if let Some(stopped_task) = state.stopped_task.as_ref() { + if let Some(thread) = state + .threads + .values() + .find(|thread| &thread.task == stopped_task) + { + return thread.id; + } + } + state + .breakpoints + .first() + .map(|line| thread_for_source_line(state, *line)) + .unwrap_or(MAIN_THREAD) +} + +pub(crate) fn position_confirmed_breakpoint_stop(state: &mut AdapterState, stopped_thread: i64) { + let line = state + .breakpoints + .iter() + .copied() + .find(|line| thread_for_source_line(state, *line) == stopped_thread) + .or_else(|| state.breakpoints.first().copied()); + if let (Some(line), Some(thread)) = (line, state.threads.get_mut(&stopped_thread)) { + thread.line = line; + thread.recent_output.push(format!( + "debug epoch {} confirmed frozen by all participants", + state.epoch + )); + } +} + +pub(crate) fn next_breakpoint_after(state: &AdapterState, thread_id: i64) -> Option<(i64, i64)> { + let current_line = state.threads.get(&thread_id)?.line; + state + .breakpoints + .iter() + .copied() + .filter(|line| *line > current_line) + .min() + .map(|line| (thread_for_source_line(state, line), line)) +} + +fn thread_for_source_line(state: &AdapterState, line: i64) -> i64 { + if let Some(probe) = debug_probe_for_line(state, line) { + return state + .threads + .values() + .find(|thread| thread.task_definition == probe.task) + .map(|thread| thread.id) + .unwrap_or(MAIN_THREAD); + } + + if let Some(function_name) = source_function_name_at_line(state, line) { + let lower = function_name.to_ascii_lowercase(); + if lower.contains("linux") { + return LINUX_THREAD; + } + if lower.contains("windows") { + return WINDOWS_THREAD; + } + if lower.contains("package") { + return PACKAGE_THREAD; + } + return MAIN_THREAD; + } + + state + .threads + .values() + .find(|thread| thread.line == line) + .map(|thread| thread.id) + .unwrap_or(MAIN_THREAD) +} + +fn debug_probe_for_line(state: &AdapterState, line: i64) -> Option<&BundleDebugProbe> { + let line = u32::try_from(line).ok()?; + state + .debug_probes + .iter() + .find(|probe| probe.line_start <= line && line <= probe.line_end) +} + +pub(crate) fn source_function_name_at_line(state: &AdapterState, line: i64) -> Option { + let source = fs::read_to_string(crate::source::resolve_source_path( + &state.project, + &state.source_path, + )) + .ok()?; + let lines = source.lines().collect::>(); + let mut index = usize::try_from(line).ok()?.saturating_sub(1); + if index >= lines.len() { + index = lines.len().saturating_sub(1); + } + lines[..=index] + .iter() + .rev() + .find_map(|line| parse_rust_function_name(line)) +} + +pub(crate) fn parse_rust_function_name(line: &str) -> Option { + let start = line.find("fn ")? + 3; + let rest = &line[start..]; + let name = rest + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + (!name.is_empty()).then_some(name) +} + +pub(crate) fn step_thread(state: &mut AdapterState, thread_id: i64, description: &str) { + state.epoch += 1; + let resolved_thread = if state.threads.contains_key(&thread_id) { + thread_id + } else { + LINUX_THREAD + }; + if let Some(thread) = state.threads.get_mut(&resolved_thread) { + thread.state = DebugRuntimeState::Frozen; + thread.line += 1; + thread + .recent_output + .push(format!("debug epoch {} {description}", state.epoch)); + } +} diff --git a/crates/disasmer-dap/src/dap_protocol.rs b/crates/disasmer-dap/src/dap_protocol.rs new file mode 100644 index 0000000..bda035c --- /dev/null +++ b/crates/disasmer-dap/src/dap_protocol.rs @@ -0,0 +1,129 @@ +use std::io::{self, BufRead, Write}; + +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; + +#[derive(Clone)] +pub(crate) struct DapWriter { + seq: i64, +} + +impl DapWriter { + pub(crate) fn new() -> Self { + Self { seq: 1 } + } + + pub(crate) fn response( + &mut self, + request: &Value, + success: bool, + body: Value, + ) -> io::Result<()> { + let command = request + .get("command") + .and_then(Value::as_str) + .unwrap_or(""); + let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0); + let seq = self.next_seq(); + self.write(json!({ + "seq": seq, + "type": "response", + "request_seq": request_seq, + "success": success, + "command": command, + "body": body, + })) + } + + pub(crate) fn error_response( + &mut self, + request: &Value, + message: impl Into, + ) -> io::Result<()> { + let command = request + .get("command") + .and_then(Value::as_str) + .unwrap_or(""); + let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0); + let seq = self.next_seq(); + self.write(json!({ + "seq": seq, + "type": "response", + "request_seq": request_seq, + "success": false, + "command": command, + "message": message.into(), + })) + } + + pub(crate) fn event(&mut self, event: &str, body: Value) -> io::Result<()> { + let seq = self.next_seq(); + self.write(json!({ + "seq": seq, + "type": "event", + "event": event, + "body": body, + })) + } + + pub(crate) fn output(&mut self, category: &str, output: impl Into) -> io::Result<()> { + self.event( + "output", + json!({ + "category": category, + "output": output.into(), + }), + ) + } + + fn next_seq(&mut self) -> i64 { + let seq = self.seq; + self.seq += 1; + seq + } + + fn write(&mut self, message: Value) -> io::Result<()> { + let payload = serde_json::to_vec(&message)?; + let mut out = io::stdout().lock(); + write!(out, "Content-Length: {}\r\n\r\n", payload.len())?; + out.write_all(&payload)?; + out.flush() + } +} + +pub(crate) fn initialize_capabilities() -> Value { + json!({ + "supportsConfigurationDoneRequest": true, + "supportsTerminateRequest": true, + "supportsRestartRequest": true, + "supportsRestartFrame": true, + "supportsEvaluateForHovers": true, + "supportsStepBack": false, + }) +} + +pub(crate) fn read_message(reader: &mut R) -> Result> { + let mut content_length = None; + + loop { + let mut line = String::new(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + return Ok(None); + } + + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + break; + } + + if let Some(value) = line.strip_prefix("Content-Length:") { + content_length = Some(value.trim().parse::()?); + } + } + + let length = content_length.ok_or_else(|| anyhow!("DAP message missing Content-Length"))?; + let mut payload = vec![0; length]; + reader.read_exact(&mut payload)?; + Ok(Some(serde_json::from_slice(&payload)?)) +} diff --git a/crates/disasmer-dap/src/demo_backend.rs b/crates/disasmer-dap/src/demo_backend.rs new file mode 100644 index 0000000..a8101aa --- /dev/null +++ b/crates/disasmer-dap/src/demo_backend.rs @@ -0,0 +1,63 @@ +use std::collections::BTreeMap; + +use disasmer_core::{DebugRuntimeState, TaskInstanceId}; + +use crate::virtual_model::{AdapterState, VirtualThread}; + +pub(crate) const MAIN_THREAD: i64 = 1; +pub(crate) const LINUX_THREAD: i64 = 2; +pub(crate) const WINDOWS_THREAD: i64 = 3; +pub(crate) const PACKAGE_THREAD: i64 = 4; + +pub(crate) fn is_explicit_demo_backend(value: &str) -> bool { + value == "simulated" || value == "demo" +} + +pub(crate) fn launch_threads(entry: &str) -> BTreeMap { + [ + thread(MAIN_THREAD, "main", &format!("{entry} virtual process"), 12), + thread(LINUX_THREAD, "compile-linux", "compile linux", 42), + thread(WINDOWS_THREAD, "compile-windows", "compile windows", 52), + thread(PACKAGE_THREAD, "package-release", "package artifacts", 64), + ] + .into_iter() + .map(|thread| (thread.id, thread)) + .collect() +} + +pub(crate) fn start_simulated_backend(state: &mut AdapterState) { + state.command_status = "running under simulated debug adapter state".to_owned(); +} + +fn thread(id: i64, task: &str, name: &str, line: i64) -> VirtualThread { + VirtualThread { + id, + frame_id: 1000 + id, + locals_ref: 5000 + id, + wasm_locals_ref: 9000 + id, + args_ref: 2000 + id, + runtime_ref: 3000 + id, + output_ref: 4000 + id, + target_ref: 6000 + id, + vfs_ref: 7000 + id, + command_ref: 8000 + id, + task: TaskInstanceId::from(task), + task_definition: disasmer_core::TaskDefinitionId::from(task), + name: name.to_owned(), + line, + state: DebugRuntimeState::Running, + freeze_supported: true, + recent_output: vec![format!("{name}: waiting")], + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + runtime_stack_frames: Vec::new(), + wasm_local_values: Vec::new(), + runtime_task_args: Vec::new(), + runtime_handles: Vec::new(), + runtime_command_status: None, + } +} diff --git a/crates/disasmer-dap/src/main.rs b/crates/disasmer-dap/src/main.rs new file mode 100644 index 0000000..727532d --- /dev/null +++ b/crates/disasmer-dap/src/main.rs @@ -0,0 +1,43 @@ +use anyhow::Result; +#[cfg(test)] +use std::path::Path; + +mod adapter; +mod breakpoints; +mod dap_protocol; +mod demo_backend; +mod runtime_client; +mod source; +mod variables; +mod view_state; +mod virtual_model; + +#[cfg(test)] +use adapter::runtime_backend_from_launch_arg; +#[cfg(test)] +use breakpoints::{ + freeze_all, resolve_breakpoints, resolve_breakpoints_for_source, + restart_requires_whole_process, stopped_thread_for_breakpoint, +}; +#[cfg(test)] +use dap_protocol::{initialize_capabilities, read_message}; +#[cfg(test)] +use runtime_client::{client_user_request, parse_task_restart_response}; +#[cfg(test)] +use variables::variables_response; +#[cfg(test)] +use virtual_model::{AdapterState, RuntimeLaunchRecord}; + +#[cfg(test)] +use demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD}; +#[cfg(test)] +use disasmer_core::{BundleDebugProbe, DebugRuntimeState, TaskInstanceId}; +#[cfg(test)] +use virtual_model::{process_id, RuntimeBackend}; + +fn main() -> Result<()> { + adapter::run_adapter() +} + +#[cfg(test)] +mod tests; diff --git a/crates/disasmer-dap/src/runtime_client.rs b/crates/disasmer-dap/src/runtime_client.rs new file mode 100644 index 0000000..ee304a8 --- /dev/null +++ b/crates/disasmer-dap/src/runtime_client.rs @@ -0,0 +1,974 @@ +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Child, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::TaskInstanceId; +use serde_json::{json, Value}; + +use crate::virtual_model::{AdapterState, RuntimeLaunchRecord}; + +mod debug_protocol; +mod local_tools; +mod transport; +pub(crate) use debug_protocol::parse_task_restart_response; +use debug_protocol::{ + coordinator_debug_epoch_request, parse_debug_epoch_response, wait_for_debug_epoch_state, +}; +use local_tools::{child_stderr_suffix, local_tool_command}; +pub(crate) use transport::client_user_request; +use transport::coordinator_request; + +pub(crate) struct LocalRuntimeSession { + coordinator: Option, + worker: Option, +} + +impl Drop for LocalRuntimeSession { + fn drop(&mut self) { + for child in [&mut self.worker, &mut self.coordinator] { + if let Some(mut child) = child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} + +struct DebugBundle { + module_base64: String, + digest: String, + entry_export: String, + entry_definition: String, +} + +struct ChildGuard(Option); + +impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0.as_mut().expect("guarded child is present") + } + + fn take(&mut self) -> Child { + self.0.take().expect("guarded child is present") + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DebugEpochRecord { + pub(crate) epoch: u64, + pub(crate) command: String, + pub(crate) affected_tasks: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DebugEpochStatusRecord { + pub(crate) epoch: u64, + pub(crate) command: String, + pub(crate) expected_tasks: usize, + pub(crate) acknowledgements: Vec, + pub(crate) fully_frozen: bool, + pub(crate) fully_resumed: bool, + pub(crate) failed: bool, + pub(crate) failure_messages: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TaskRestartRecord { + pub(crate) accepted: bool, + pub(crate) restarted_task_instance: Option, + pub(crate) clean_boundary_available: bool, + pub(crate) requires_whole_process_restart: bool, + pub(crate) active_task: bool, + pub(crate) completed_event_observed: bool, + pub(crate) message: String, +} + +pub(crate) enum RuntimeContinuationOutcome { + Breakpoint(RuntimeLaunchRecord), + Terminal(RuntimeLaunchRecord), +} + +pub(crate) fn run_local_services_runtime( + state: &AdapterState, +) -> Result<(RuntimeLaunchRecord, LocalRuntimeSession)> { + let repo = std::env::current_dir()?; + let mut coordinator_command = local_tool_command( + "DISASMER_COORDINATOR_BIN", + "disasmer-coordinator", + "disasmer-coordinator", + &repo, + ); + let mut coordinator = coordinator_command + .args(["--listen", "127.0.0.1:0", "--allow-local-trusted-loopback"]) + .current_dir(&repo) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let result = (|| { + let stdout = coordinator + .stdout + .take() + .ok_or_else(|| anyhow!("coordinator stdout was not captured"))?; + let mut ready_line = String::new(); + BufReader::new(stdout).read_line(&mut ready_line)?; + let ready: Value = serde_json::from_str(&ready_line)?; + let listen = ready + .get("listen") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("coordinator did not report a listen address"))? + .to_owned(); + coordinator_request( + &listen, + json!({ + "type": "create_project", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "name": "DAP local services project", + }), + )?; + let enrollment = coordinator_request( + &listen, + json!({ + "type": "create_node_enrollment_grant", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "ttl_seconds": 60, + }), + )?; + let enrollment_grant = enrollment + .get("grant") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("local coordinator omitted the node enrollment grant"))? + .to_owned(); + + let mut worker_command = + local_tool_command("DISASMER_NODE_BIN", "disasmer-node", "disasmer-node", &repo); + let mut worker = ChildGuard::new( + worker_command + .args([ + "--coordinator", + &listen, + "--tenant", + state.tenant.as_str(), + "--project-id", + state.project_id.as_str(), + "--node", + "dap-node", + "--enrollment-grant", + &enrollment_grant, + "--worker", + "--project-root", + &state.project, + "--assignment-poll-ms", + "20", + ]) + .current_dir(&repo) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()?, + ); + wait_for_local_node(&listen, state, "dap-node", worker.child_mut())?; + + let record = launch_services_debug_entrypoint(&listen, state, &repo)?; + Ok((record, worker.take())) + })(); + match result { + Ok((record, worker)) => Ok(( + record, + LocalRuntimeSession { + coordinator: Some(coordinator), + worker: Some(worker), + }, + )), + Err(error) => { + let _ = coordinator.kill(); + let _ = coordinator.wait(); + Err(error) + } + } +} + +fn build_debug_bundle(state: &AdapterState, repo: &Path) -> Result { + let mut command = local_tool_command("DISASMER_CLI_BIN", "disasmer", "disasmer-cli", repo); + let output = command + .args(["build", "--project", &state.project, "--json"]) + .current_dir(repo) + .output()?; + if !output.status.success() { + return Err(anyhow!( + "Disasmer bundle build failed before debug launch: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let report: Value = serde_json::from_slice(&output.stdout)?; + let module_path = report + .pointer("/bundle_artifact/module") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("bundle build omitted module path"))?; + let module_path = if Path::new(module_path).is_absolute() { + Path::new(module_path).to_path_buf() + } else { + repo.join(module_path) + }; + let module = std::fs::read(&module_path)?; + let entrypoints: Value = serde_json::from_slice(&std::fs::read( + module_path + .parent() + .ok_or_else(|| anyhow!("bundle module path has no parent"))? + .join("entrypoints.json"), + )?)?; + let descriptor = entrypoints + .as_array() + .and_then(|descriptors| { + descriptors.iter().find(|descriptor| { + descriptor.get("name").and_then(Value::as_str) == Some(state.entry.as_str()) + }) + }) + .ok_or_else(|| anyhow!("bundle has no entrypoint `{}`", state.entry))?; + let entry_export = descriptor + .get("export") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("entrypoint `{}` omitted its Wasm export", state.entry))? + .to_owned(); + let entry_definition = descriptor + .get("stable_id") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow!( + "entrypoint `{}` omitted its stable definition ID", + state.entry + ) + })? + .to_owned(); + let digest = report + .pointer("/bundle_artifact/bundle_digest") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("bundle build omitted bundle digest"))? + .to_owned(); + Ok(DebugBundle { + module_base64: BASE64_STANDARD.encode(module), + digest, + entry_export, + entry_definition, + }) +} + +fn launch_services_debug_entrypoint( + coordinator: &str, + state: &AdapterState, + repo: &Path, +) -> Result { + let bundle = build_debug_bundle(state, repo)?; + let started = coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "start_process", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "restart": state.restart_existing, + }), + ), + )?; + let epoch = started + .get("epoch") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("coordinator did not report a process epoch"))?; + let probe_symbols = state.requested_probe_symbols(); + if probe_symbols.is_empty() { + return Err(anyhow!( + "no executable Disasmer probe corresponds to the configured source breakpoints" + )); + } + coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "set_debug_breakpoints", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "probe_symbols": probe_symbols, + }), + ), + )?; + let launch = coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "launch_task", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "task_spec": { + "tenant": state.tenant, + "project": state.project_id, + "process": state.process.as_str(), + "task_definition": bundle.entry_definition, + "task_instance": format!("ti:{}:main", state.process), + "dispatch": { + "kind": "coordinator_node_wasm", + "export": bundle.entry_export, + "abi": "entrypoint_v1", + }, + "environment_id": null, + "environment": null, + "environment_digest": null, + "required_capabilities": [], + "dependency_cache": null, + "source_snapshot": null, + "required_artifacts": [], + "args": [], + "vfs_epoch": epoch, + "bundle_digest": bundle.digest, + }, + "wait_for_node": true, + "artifact_path": "/vfs/artifacts/dap-output.txt", + "wasm_module_base64": bundle.module_base64, + }), + ), + )?; + if launch.get("type").and_then(Value::as_str) != Some("main_launched") { + return Err(anyhow!( + "coordinator did not start the capless main runtime: {}", + serde_json::to_string(&launch)? + )); + } + let breakpoint = wait_for_breakpoint_hit(coordinator, state)?; + let debug_epoch = breakpoint + .get("hit_epoch") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("breakpoint status omitted the hit debug epoch"))?; + let frozen = wait_for_debug_epoch_state_at(coordinator, state, debug_epoch, true)?; + let expected = frozen + .get("expected_tasks") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let acknowledged = frozen + .get("acknowledgements") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + if expected == 0 || acknowledged != expected { + return Err(anyhow!( + "debug epoch {debug_epoch} claimed frozen without every active participant" + )); + } + let process_statuses = coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "list_processes", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + }), + ), + )?; + let process_status = process_statuses + .get("processes") + .and_then(Value::as_array) + .and_then(|processes| { + processes.iter().find(|process| { + process.get("process").and_then(Value::as_str) == Some(state.process.as_str()) + }) + }) + .cloned(); + let node = frozen + .get("acknowledgements") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("node")) + .and_then(Value::as_str) + .unwrap_or("coordinator-main") + .to_owned(); + Ok(RuntimeLaunchRecord { + coordinator: coordinator.to_owned(), + node, + node_report: json!({ + "process": started, + "task_launch": launch, + "breakpoint": breakpoint, + "debug_epoch": frozen, + "process_status": process_status, + "process_statuses": process_statuses, + }), + task_events: json!({ "events": [] }), + placed_task_launched: true, + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count: 0, + debug_epoch: Some(debug_epoch), + stopped_task: breakpoint + .get("hit_task") + .and_then(Value::as_str) + .map(str::to_owned), + stopped_probe_symbol: breakpoint + .get("hit_probe_symbol") + .and_then(Value::as_str) + .map(str::to_owned), + all_participants_frozen: true, + }) +} + +fn wait_for_local_node( + coordinator: &str, + state: &AdapterState, + node: &str, + worker: &mut Child, +) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = worker.try_wait()? { + return Err(anyhow!( + "local Disasmer worker `{node}` exited before attaching ({status}){}", + child_stderr_suffix(worker) + )); + } + let response = coordinator_request( + coordinator, + json!({ + "type": "list_node_descriptors", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + }), + )?; + if response + .get("descriptors") + .and_then(Value::as_array) + .is_some_and(|descriptors| { + descriptors + .iter() + .any(|descriptor| descriptor.get("id").and_then(Value::as_str) == Some(node)) + }) + { + return Ok(()); + } + if Instant::now() >= deadline { + let _ = worker.kill(); + let _ = worker.wait(); + return Err(anyhow!( + "local Disasmer worker `{node}` did not attach within 30 seconds{}", + child_stderr_suffix(worker) + )); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn wait_for_breakpoint_hit(coordinator: &str, state: &AdapterState) -> Result { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let response = coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "inspect_debug_breakpoints", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + ), + )?; + if response.get("hit_epoch").and_then(Value::as_u64).is_some() { + return Ok(response); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "executing Wasm did not reach any configured source breakpoint within 30 seconds" + )); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn wait_for_debug_epoch_state_at( + coordinator: &str, + state: &AdapterState, + epoch: u64, + frozen: bool, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let response = coordinator_request( + coordinator, + client_user_request( + state, + json!({ + "type": "inspect_debug_epoch", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "epoch": epoch, + }), + ), + )?; + if response.get("failed").and_then(Value::as_bool) == Some(true) { + return Err(anyhow!( + "debug epoch {epoch} participant failed: {}", + response + .get("failure_messages") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>() + .join("; ") + )); + } + let ready_field = if frozen { + "fully_frozen" + } else { + "fully_resumed" + }; + if response.get(ready_field).and_then(Value::as_bool) == Some(true) { + return Ok(response); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "debug epoch {epoch} did not reach {ready_field} within 60 seconds" + )); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +pub(crate) fn run_live_services_runtime(state: &AdapterState) -> Result { + let coordinator = + crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint); + let repo = std::env::current_dir()?; + launch_services_debug_entrypoint(&coordinator, state, &repo) +} + +pub(crate) fn relaunch_services_main_runtime(state: &AdapterState) -> Result { + let coordinator = + crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint); + let repo = std::env::current_dir()?; + let mut restart_state = state.clone(); + restart_state.restart_existing = true; + launch_services_debug_entrypoint(&coordinator, &restart_state, &repo) +} + +pub(crate) fn attach_services_runtime(state: &AdapterState) -> Result { + let coordinator = + crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint); + let debug_attach = coordinator_request( + &coordinator, + client_user_request( + state, + json!({ + "type": "debug_attach", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + ), + )?; + let allowed = debug_attach + .get("authorization") + .and_then(|authorization| authorization.get("allowed")) + .and_then(Value::as_bool) + .unwrap_or(false); + if !allowed { + let reason = debug_attach + .get("authorization") + .and_then(|authorization| authorization.get("reason")) + .and_then(Value::as_str) + .unwrap_or("debug attach was denied by coordinator authorization"); + return Err(anyhow!("debug attach denied: {reason}")); + } + let events = coordinator_request( + &coordinator, + client_user_request( + state, + json!({ + "type": "list_task_events", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + ), + )?; + let event_count = events + .get("events") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let process_statuses = coordinator_request( + &coordinator, + client_user_request( + state, + json!({ + "type": "list_processes", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + }), + ), + )?; + let process_status = process_statuses + .get("processes") + .and_then(Value::as_array) + .and_then(|processes| { + processes.iter().find(|process| { + process.get("process").and_then(Value::as_str) == Some(state.process.as_str()) + }) + }) + .cloned(); + let node = process_status + .as_ref() + .and_then(|status| status.get("connected_nodes")) + .and_then(Value::as_array) + .and_then(|nodes| nodes.first()) + .and_then(Value::as_str) + .unwrap_or("coordinator-main") + .to_owned(); + let active_main = process_status + .as_ref() + .and_then(|status| status.get("main_task_instance")) + .and_then(Value::as_str) + .is_some(); + + Ok(RuntimeLaunchRecord { + coordinator, + node, + node_report: json!({ + "debug_attach": debug_attach, + "process_status": process_status, + "process_statuses": process_statuses, + }), + task_events: events, + placed_task_launched: active_main || event_count > 0, + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count, + debug_epoch: None, + stopped_task: None, + stopped_probe_symbol: None, + all_participants_frozen: false, + }) +} + +pub(crate) fn create_debug_epoch( + state: &AdapterState, + stopped_task: &TaskInstanceId, + reason: &str, +) -> Result { + let response = coordinator_debug_epoch_request( + state, + client_user_request( + state, + json!({ + "type": "create_debug_epoch", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "stopped_task": stopped_task.as_str(), + "reason": reason, + }), + ), + )?; + parse_debug_epoch_response(response) +} + +pub(crate) fn resume_debug_epoch(state: &AdapterState, epoch: u64) -> Result { + let response = coordinator_debug_epoch_request( + state, + client_user_request( + state, + json!({ + "type": "resume_debug_epoch", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "epoch": epoch, + }), + ), + )?; + parse_debug_epoch_response(response) +} + +pub(crate) fn wait_for_debug_epoch_frozen( + state: &AdapterState, + epoch: u64, +) -> Result { + wait_for_debug_epoch_state(state, epoch, true) +} + +pub(crate) fn wait_for_debug_epoch_resumed( + state: &AdapterState, + epoch: u64, +) -> Result { + wait_for_debug_epoch_state(state, epoch, false) +} + +pub(crate) fn wait_for_services_runtime_outcome( + state: &AdapterState, + previous_debug_epoch: u64, +) -> Result { + let coordinator = + crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint); + let deadline = Instant::now() + Duration::from_secs(120); + loop { + // Terminal task events remain inspectable after the active process slot is + // released. Read them before active-process debug state so a fast main exit + // cannot turn a successful continuation into an authorization error. + let events = coordinator_request( + &coordinator, + client_user_request( + state, + json!({ + "type": "list_task_events", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + ), + )?; + if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) { + return Ok(outcome); + } + + let breakpoint = match coordinator_request( + &coordinator, + client_user_request( + state, + json!({ + "type": "inspect_debug_breakpoints", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + ), + ) { + Ok(breakpoint) => breakpoint, + Err(inspect_error) => { + // Main completion records its terminal event and clears ephemeral + // debug state in one coordinator pump. That pump can occur between + // the event read above and this inspection request. Re-read the + // durable event stream before treating the missing debug state as + // an adapter failure. + let events = coordinator_request( + &coordinator, + client_user_request( + state, + json!({ + "type": "list_task_events", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + ), + )?; + if let Some(outcome) = terminal_runtime_outcome(&coordinator, state, &events) { + return Ok(outcome); + } + return Err(inspect_error); + } + }; + if let Some(epoch) = breakpoint.get("hit_epoch").and_then(Value::as_u64) { + if epoch > previous_debug_epoch { + let frozen = wait_for_debug_epoch_state_at(&coordinator, state, epoch, true)?; + let node = frozen + .get("acknowledgements") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("node")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_owned(); + return Ok(RuntimeContinuationOutcome::Breakpoint( + RuntimeLaunchRecord { + coordinator, + node, + node_report: json!({ + "breakpoint": breakpoint, + "debug_epoch": frozen, + }), + task_events: json!({ "events": [] }), + placed_task_launched: true, + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count: state.runtime_event_count, + debug_epoch: Some(epoch), + stopped_task: breakpoint + .get("hit_task") + .and_then(Value::as_str) + .map(str::to_owned), + stopped_probe_symbol: breakpoint + .get("hit_probe_symbol") + .and_then(Value::as_str) + .map(str::to_owned), + all_participants_frozen: true, + }, + )); + } + } + + if Instant::now() >= deadline { + return Err(anyhow!( + "continued virtual process {} neither reached another executing Wasm breakpoint nor recorded terminal entrypoint state within 120 seconds", + state.process + )); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn terminal_runtime_outcome( + coordinator: &str, + state: &AdapterState, + events: &Value, +) -> Option { + let event = events + .get("events") + .and_then(Value::as_array)? + .get(state.runtime_event_count..)? + .iter() + .rev() + .find(|event| event.get("executor").and_then(Value::as_str) == Some("coordinator_main"))?; + let status_code = event + .get("status_code") + .and_then(Value::as_i64) + .map(|status| status as i32) + .or_else( + || match event.get("terminal_state").and_then(Value::as_str) { + Some("completed") => Some(0), + Some("failed" | "cancelled") => Some(1), + _ => None, + }, + ); + Some(RuntimeContinuationOutcome::Terminal(RuntimeLaunchRecord { + coordinator: coordinator.to_owned(), + node: event + .get("node") + .and_then(Value::as_str) + .unwrap_or("coordinator-main") + .to_owned(), + node_report: json!({ "terminal_event": event }), + task_events: events.clone(), + placed_task_launched: true, + status_code, + stdout_bytes: event + .get("stdout_bytes") + .and_then(Value::as_u64) + .unwrap_or(0), + stderr_bytes: event + .get("stderr_bytes") + .and_then(Value::as_u64) + .unwrap_or(0), + stdout_tail: event + .get("stdout_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + stderr_tail: event + .get("stderr_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + stdout_truncated: event + .get("stdout_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + stderr_truncated: event + .get("stderr_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + artifact_path: event + .get("artifact_path") + .and_then(Value::as_str) + .map(str::to_owned), + event_count: events + .get("events") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0), + debug_epoch: None, + stopped_task: None, + stopped_probe_symbol: None, + all_participants_frozen: false, + })) +} + +pub(crate) fn restart_task( + state: &AdapterState, + task: &TaskInstanceId, +) -> Result { + let repo = std::env::current_dir()?; + let replacement = build_debug_bundle(state, &repo)?; + let response = coordinator_debug_epoch_request( + state, + client_user_request( + state, + json!({ + "type": "restart_task", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "task": task.as_str(), + "replacement_bundle": { + "bundle_digest": replacement.digest, + "wasm_module_base64": replacement.module_base64, + }, + }), + ), + )?; + parse_task_restart_response(response) +} diff --git a/crates/disasmer-dap/src/runtime_client/debug_protocol.rs b/crates/disasmer-dap/src/runtime_client/debug_protocol.rs new file mode 100644 index 0000000..c00b2b1 --- /dev/null +++ b/crates/disasmer-dap/src/runtime_client/debug_protocol.rs @@ -0,0 +1,177 @@ +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use disasmer_core::TaskInstanceId; +use serde_json::{json, Value}; + +use crate::virtual_model::AdapterState; + +use super::{ + client_user_request, coordinator_request, DebugEpochRecord, DebugEpochStatusRecord, + TaskRestartRecord, +}; + +pub(super) fn coordinator_debug_epoch_request( + state: &AdapterState, + payload: Value, +) -> Result { + let coordinator = + crate::view_state::normalize_coordinator_endpoint(&state.coordinator_endpoint); + coordinator_request(&coordinator, payload) +} + +pub(super) fn parse_debug_epoch_response(response: Value) -> Result { + let epoch = response + .get("epoch") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("coordinator debug epoch response did not include an epoch"))?; + let command = response + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("coordinator debug epoch response did not include a command"))? + .to_owned(); + let affected_tasks = response + .get("affected_tasks") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + Ok(DebugEpochRecord { + epoch, + command, + affected_tasks, + }) +} + +pub(super) fn wait_for_debug_epoch_state( + state: &AdapterState, + epoch: u64, + frozen: bool, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let response = coordinator_debug_epoch_request( + state, + client_user_request( + state, + json!({ + "type": "inspect_debug_epoch", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "epoch": epoch, + }), + ), + )?; + let status = parse_debug_epoch_status(response)?; + if status.failed { + return Err(anyhow!( + "debug epoch {epoch} participant failed: {}", + status.failure_messages.join("; ") + )); + } + if (frozen && status.fully_frozen) || (!frozen && status.fully_resumed) { + return Ok(status); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "debug epoch {epoch} did not receive {}/{} signed participant acknowledgements for {} within 60 seconds", + status.acknowledgements.len(), + status.expected_tasks, + if frozen { "frozen state" } else { "resumed state" } + )); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn parse_debug_epoch_status(response: Value) -> Result { + let epoch = response + .get("epoch") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("coordinator debug epoch status omitted epoch"))?; + let command = response + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("coordinator debug epoch status omitted command"))? + .to_owned(); + let expected_tasks = response + .get("expected_tasks") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let acknowledgements = response + .get("acknowledgements") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let failure_messages = response + .get("failure_messages") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + Ok(DebugEpochStatusRecord { + epoch, + command, + expected_tasks, + acknowledgements, + fully_frozen: response + .get("fully_frozen") + .and_then(Value::as_bool) + .unwrap_or(false), + fully_resumed: response + .get("fully_resumed") + .and_then(Value::as_bool) + .unwrap_or(false), + failed: response + .get("failed") + .and_then(Value::as_bool) + .unwrap_or(false), + failure_messages, + }) +} + +pub(crate) fn parse_task_restart_response(response: Value) -> Result { + Ok(TaskRestartRecord { + accepted: response + .get("accepted") + .and_then(Value::as_bool) + .ok_or_else(|| anyhow!("coordinator task restart response did not include accepted"))?, + restarted_task_instance: response + .get("restarted_task_instance") + .and_then(Value::as_str) + .map(TaskInstanceId::new), + clean_boundary_available: response + .get("clean_boundary_available") + .and_then(Value::as_bool) + .ok_or_else(|| { + anyhow!("coordinator task restart response did not include clean_boundary_available") + })?, + requires_whole_process_restart: response + .get("requires_whole_process_restart") + .and_then(Value::as_bool) + .ok_or_else(|| { + anyhow!( + "coordinator task restart response did not include requires_whole_process_restart" + ) + })?, + active_task: response + .get("active_task") + .and_then(Value::as_bool) + .ok_or_else(|| anyhow!("coordinator task restart response did not include active_task"))?, + completed_event_observed: response + .get("completed_event_observed") + .and_then(Value::as_bool) + .ok_or_else(|| { + anyhow!("coordinator task restart response did not include completed_event_observed") + })?, + message: response + .get("message") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("coordinator task restart response did not include message"))? + .to_owned(), + }) +} diff --git a/crates/disasmer-dap/src/runtime_client/local_tools.rs b/crates/disasmer-dap/src/runtime_client/local_tools.rs new file mode 100644 index 0000000..e68bc99 --- /dev/null +++ b/crates/disasmer-dap/src/runtime_client/local_tools.rs @@ -0,0 +1,63 @@ +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; + +pub(super) fn child_stderr_suffix(child: &mut Child) -> String { + let mut stderr = String::new(); + if let Some(mut stream) = child.stderr.take() { + let _ = stream.read_to_string(&mut stderr); + } + let stderr = stderr.trim(); + if stderr.is_empty() { + String::new() + } else { + format!("; worker stderr: {stderr}") + } +} + +pub(super) fn local_tool_command( + env_var: &str, + binary: &str, + package: &str, + repo: &Path, +) -> Command { + if let Some(path) = std::env::var_os(env_var) { + return Command::new(path); + } + let current_exe = std::env::current_exe().ok(); + if !workspace_development_executable(current_exe.as_deref(), repo) { + if let Some(path) = sibling_tool_path(current_exe.as_deref(), binary) { + return Command::new(path); + } + } + + // A workspace-built adapter must ask Cargo for the matching service binary: + // a sibling executable can be older than the adapter even though both happen + // to live in target/debug. Installed releases take the sibling path above. + let mut command = Command::new("cargo"); + command.args(["run", "-q", "-p", package, "--bin", binary, "--"]); + command +} + +fn sibling_tool_path(current_exe: Option<&Path>, binary: &str) -> Option { + let mut sibling = current_exe?.to_path_buf(); + sibling.set_file_name(format!("{binary}{}", std::env::consts::EXE_SUFFIX)); + sibling.is_file().then_some(sibling) +} + +fn workspace_development_executable(current_exe: Option<&Path>, repo: &Path) -> bool { + let Some(current_exe) = current_exe else { + return false; + }; + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + repo.join(path) + } + }) + .unwrap_or_else(|| repo.join("target")); + current_exe.starts_with(target) +} diff --git a/crates/disasmer-dap/src/runtime_client/transport.rs b/crates/disasmer-dap/src/runtime_client/transport.rs new file mode 100644 index 0000000..431cef2 --- /dev/null +++ b/crates/disasmer-dap/src/runtime_client/transport.rs @@ -0,0 +1,38 @@ +use anyhow::{anyhow, Result}; +use disasmer_control::ControlSession; +use disasmer_core::coordinator_wire_request; +use serde_json::{json, Value}; + +use crate::virtual_model::AdapterState; + +pub(crate) fn client_user_request(state: &AdapterState, mut request: Value) -> Value { + let Some(session_secret) = state.client_session_secret.as_deref() else { + return request; + }; + if let Value::Object(fields) = &mut request { + fields.remove("tenant"); + fields.remove("project"); + fields.remove("actor_user"); + } + json!({ + "type": "authenticated", + "session_secret": session_secret, + "request": request, + }) +} + +pub(super) fn coordinator_request(addr: &str, request: Value) -> Result { + let mut session = ControlSession::connect(addr)?; + let wire_request = coordinator_wire_request("dap-1", request); + let response = session.request(&wire_request)?; + if response.get("type").and_then(Value::as_str) == Some("error") { + return Err(anyhow!( + "{}", + response + .get("message") + .and_then(Value::as_str) + .unwrap_or("coordinator returned an error") + )); + } + Ok(response) +} diff --git a/crates/disasmer-dap/src/source.rs b/crates/disasmer-dap/src/source.rs new file mode 100644 index 0000000..1a3e019 --- /dev/null +++ b/crates/disasmer-dap/src/source.rs @@ -0,0 +1,71 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::virtual_model::AdapterState; + +pub(crate) fn infer_source_path(project: &str) -> String { + if Path::new(project).join("src/build.rs").is_file() { + "src/build.rs".to_owned() + } else if Path::new(project).join("src/main.rs").is_file() { + "src/main.rs".to_owned() + } else if Path::new(project).join("src/lib.rs").is_file() { + "src/lib.rs".to_owned() + } else { + "src/main.rs".to_owned() + } +} + +pub(crate) fn source_name(source_path: &str) -> &str { + Path::new(source_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(source_path) +} + +pub(crate) fn stack_source_path(state: &AdapterState) -> String { + normalized_source_path(&state.project, &state.source_path) + .to_string_lossy() + .into_owned() +} + +pub(crate) fn source_paths_match(project: &str, configured: &str, requested: &str) -> bool { + normalized_source_path(project, configured) == normalized_source_path(project, requested) +} + +pub(crate) fn source_response(state: &AdapterState, request: &Value) -> Result { + let source_path = request + .get("arguments") + .and_then(|arguments| arguments.get("source")) + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .unwrap_or(&state.source_path); + let content = fs::read_to_string(resolve_source_path(&state.project, source_path))?; + Ok(json!({ + "content": content, + "mimeType": "text/x-rustsrc", + })) +} + +pub(crate) fn resolve_source_path(project: &str, source_path: &str) -> PathBuf { + let path = Path::new(source_path); + if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(project).join(path) + } +} + +fn normalized_source_path(project: &str, source_path: &str) -> PathBuf { + let resolved = resolve_source_path(project, source_path); + let absolute = if resolved.is_absolute() { + resolved + } else { + std::env::current_dir() + .unwrap_or_else(|_| Path::new(".").to_path_buf()) + .join(resolved) + }; + absolute.canonicalize().unwrap_or(absolute) +} diff --git a/crates/disasmer-dap/src/tests.rs b/crates/disasmer-dap/src/tests.rs new file mode 100644 index 0000000..a3e806e --- /dev/null +++ b/crates/disasmer-dap/src/tests.rs @@ -0,0 +1,817 @@ +#![allow(clippy::field_reassign_with_default)] + +use std::fs; +use std::io::Cursor; + +use serde_json::json; + +use super::*; + +#[test] +fn reads_standard_dap_content_length_frame_from_generic_client() { + let payload = json!({ + "seq": 7, + "type": "request", + "command": "threads" + }) + .to_string(); + let frame = format!("Content-Length: {}\r\n\r\n{payload}", payload.len()); + let mut reader = Cursor::new(frame.into_bytes()); + + let message = read_message(&mut reader) + .unwrap() + .expect("DAP frame should contain a request"); + + assert_eq!(message["seq"], 7); + assert_eq!(message["type"], "request"); + assert_eq!(message["command"], "threads"); +} + +#[test] +fn initialize_capabilities_use_standard_dap_flags() { + let capabilities = initialize_capabilities(); + let capabilities = capabilities + .as_object() + .expect("initialize capabilities should be an object"); + + assert_eq!( + capabilities.get("supportsConfigurationDoneRequest"), + Some(&json!(true)) + ); + assert_eq!(capabilities.get("supportsRestartFrame"), Some(&json!(true))); + assert_eq!(capabilities.get("supportsStepBack"), Some(&json!(false))); + assert!(capabilities + .keys() + .all(|key| key.starts_with("supports") && !key.contains("disasmer"))); +} + +#[test] +fn runtime_backend_defaults_to_local_services_and_requires_explicit_demo() { + assert_eq!( + runtime_backend_from_launch_arg(None).unwrap(), + RuntimeBackend::LocalServices + ); + assert_eq!( + runtime_backend_from_launch_arg(Some("simulated")).unwrap(), + RuntimeBackend::Simulated + ); + assert_eq!( + runtime_backend_from_launch_arg(Some("demo")).unwrap(), + RuntimeBackend::Simulated + ); + assert!(runtime_backend_from_launch_arg(Some("unknown")).is_err()); +} + +#[test] +fn live_dap_uses_matching_stored_cli_session_and_omits_claimed_scope() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("examples/demo"); + fs::create_dir_all(temp.path().join(".disasmer")).unwrap(); + fs::create_dir_all(&project).unwrap(); + fs::write( + temp.path().join(".disasmer/session.json"), + serde_json::to_vec(&json!({ + "coordinator": "https://hosted.example.test", + "tenant": "tenant-session", + "project": "project-session", + "user": "user-session", + "session_secret": "dap-session-secret" + })) + .unwrap(), + ) + .unwrap(); + + let mut state = AdapterState::default(); + state + .configure_launch( + project.to_string_lossy().into_owned(), + "build".to_owned(), + RuntimeBackend::LiveServices, + Some("https://hosted.example.test/".to_owned()), + None, + ) + .unwrap(); + assert_eq!(state.tenant.as_str(), "tenant-session"); + assert_eq!(state.project_id.as_str(), "project-session"); + assert_eq!(state.actor_user.as_str(), "user-session"); + assert_eq!( + state.client_session_secret.as_deref(), + Some("dap-session-secret") + ); + + let request = client_user_request( + &state, + json!({ + "type": "debug_attach", + "tenant": "forged-tenant", + "project": "forged-project", + "actor_user": "forged-user", + "process": "vp" + }), + ); + assert_eq!(request["type"], "authenticated"); + assert_eq!(request["session_secret"], "dap-session-secret"); + assert_eq!(request["request"]["type"], "debug_attach"); + assert!(request["request"].get("tenant").is_none()); + assert!(request["request"].get("project").is_none()); + assert!(request["request"].get("actor_user").is_none()); + + let mismatch = state.configure_launch( + project.to_string_lossy().into_owned(), + "build".to_owned(), + RuntimeBackend::LiveServices, + Some("https://different.example.test".to_owned()), + None, + ); + assert!(mismatch + .unwrap_err() + .to_string() + .contains("does not match the authenticated CLI session")); +} + +#[test] +fn compatible_restart_frame_does_not_require_whole_process_restart() { + let request = json!({ + "command": "restartFrame", + "arguments": { + "frameId": 1, + "sourceEdit": { + "compatibility": "compatible" + } + } + }); + + assert!(!restart_requires_whole_process(&request)); +} + +#[test] +fn incompatible_source_edit_requires_whole_process_restart() { + for arguments in [ + json!({ "sourceCompatibility": "incompatible" }), + json!({ "sourceEdit": { "compatibility": "whole-process" } }), + json!({ "requiresWholeProcessRestart": true }), + ] { + let request = json!({ + "command": "restartFrame", + "arguments": arguments, + }); + + assert!(restart_requires_whole_process(&request)); + } +} + +#[test] +fn task_restart_response_preserves_coordinator_boundary_decision() { + let record = parse_task_restart_response(json!({ + "type": "task_restart", + "accepted": false, + "clean_boundary_available": false, + "requires_whole_process_restart": true, + "active_task": false, + "completed_event_observed": true, + "message": "selected task has only terminal event metadata; restart requires a captured checkpoint boundary" + })) + .unwrap(); + + assert!(!record.accepted); + assert!(!record.clean_boundary_available); + assert!(record.requires_whole_process_restart); + assert!(!record.active_task); + assert!(record.completed_event_observed); + assert!(record.message.contains("checkpoint boundary")); +} + +#[test] +fn nonzero_local_runtime_record_marks_linux_task_failed() { + let mut state = AdapterState::default(); + state.runtime_backend = RuntimeBackend::LocalServices; + + state.apply_runtime_record(RuntimeLaunchRecord { + coordinator: "127.0.0.1:7999".to_owned(), + node: "node".to_owned(), + node_report: json!({ "terminal_event": { + "task": "compile-linux-1", + "task_definition": "compile-linux" + } }), + task_events: json!({ "events": [] }), + placed_task_launched: true, + status_code: Some(1), + stdout_bytes: 0, + stderr_bytes: 12, + stdout_tail: String::new(), + stderr_tail: "failed".to_owned(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count: 1, + debug_epoch: None, + stopped_task: None, + stopped_probe_symbol: None, + all_participants_frozen: false, + }); + let linux = state + .threads + .get(&LINUX_THREAD) + .expect("linux task should exist"); + + assert!(state.last_task_failed); + assert!(state + .command_status + .contains("failed through local services")); + assert!(matches!(linux.state, DebugRuntimeState::Failed(_))); + assert!(linux + .recent_output + .iter() + .any(|line| line.contains("task failed"))); +} + +#[test] +fn freeze_all_reports_failure_without_claiming_all_stop() { + let mut state = AdapterState::default(); + state + .threads + .get_mut(&PACKAGE_THREAD) + .expect("package thread should exist") + .freeze_supported = false; + + let error = freeze_all(&mut state, LINUX_THREAD, None).unwrap_err(); + + assert_eq!(error.thread_id, PACKAGE_THREAD); + assert_eq!(error.task, TaskInstanceId::from("package-release")); + assert!(error.message().contains("could not freeze")); + assert_eq!(state.epoch, 0); + assert!(state + .threads + .values() + .all(|thread| thread.state == DebugRuntimeState::Running)); +} + +#[test] +fn freeze_all_success_freezes_every_running_participant() { + let mut state = AdapterState::default(); + + freeze_all(&mut state, LINUX_THREAD, None).unwrap(); + + assert_eq!(state.epoch, 1); + assert!(state + .threads + .values() + .all(|thread| thread.state == DebugRuntimeState::Frozen)); +} + +#[test] +fn breakpoint_line_selects_main_or_spawned_virtual_thread() { + let mut state = AdapterState::default(); + + state.breakpoints = vec![12]; + assert_eq!(stopped_thread_for_breakpoint(&state), MAIN_THREAD); + + state.breakpoints = vec![42]; + assert_eq!(stopped_thread_for_breakpoint(&state), LINUX_THREAD); +} + +#[test] +fn breakpoint_resolution_uses_bundle_debug_probe_metadata() { + let mut state = AdapterState::default(); + state.debug_probes = vec![BundleDebugProbe { + id: "probe-linux".to_owned(), + source_path: "src/build.rs".to_owned(), + line_start: 20, + line_end: 30, + function: "compile_linux".to_owned(), + task: disasmer_core::TaskDefinitionId::from("compile-linux"), + }]; + + let resolved = resolve_breakpoints(&mut state, vec![22, 80]); + + assert_eq!(resolved.len(), 2); + assert!(resolved[0].verified); + assert!(resolved[0].message.contains("probe-linux")); + assert!(!resolved[1].verified); + assert!(resolved[1].message.contains("No Disasmer debug probe")); + assert_eq!(state.breakpoints, vec![22]); + assert_eq!(stopped_thread_for_breakpoint(&state), LINUX_THREAD); +} + +#[test] +fn breakpoint_updates_from_another_source_do_not_replace_configured_breakpoints() { + let project = tempfile::tempdir().unwrap(); + let source_dir = project.path().join("src"); + fs::create_dir_all(&source_dir).unwrap(); + fs::write(source_dir.join("build.rs"), "pub fn build() {}\n").unwrap(); + fs::write(source_dir.join("main.rs"), "fn main() {}\n").unwrap(); + + let mut state = AdapterState::default(); + state.project = project.path().to_string_lossy().into_owned(); + state.source_path = "src/build.rs".to_owned(); + state.breakpoints = vec![1]; + + let requested_source = source_dir.join("main.rs"); + let resolved = resolve_breakpoints_for_source(&mut state, requested_source.to_str(), vec![1]); + + assert_eq!(resolved.len(), 1); + assert!(!resolved[0].verified); + assert!(resolved[0] + .message + .contains("configured for `src/build.rs`")); + assert_eq!(state.breakpoints, vec![1]); +} + +#[test] +fn launch_threads_include_windows_task_in_same_virtual_process() { + let state = AdapterState::default(); + let windows = state + .threads + .get(&WINDOWS_THREAD) + .expect("windows task should be debugger-visible"); + + assert_eq!(state.threads.len(), 4); + assert_eq!(windows.id, WINDOWS_THREAD); + assert_eq!(windows.task, TaskInstanceId::from("compile-windows")); + assert_eq!(windows.name, "compile windows"); + assert_eq!(windows.line, 52); + assert_eq!(process_id(&state.project, &state.entry), state.process); +} + +#[test] +fn vscode_and_dap_share_stable_workspace_process_identity() { + assert_eq!( + process_id("/workspace/app", "build"), + disasmer_core::ProcessId::from("vp-e4bd6ef50539") + ); +} + +#[test] +fn windows_thread_runtime_variables_share_virtual_process() { + let state = AdapterState::default(); + let windows = state + .threads + .get(&WINDOWS_THREAD) + .expect("windows task should be debugger-visible"); + + let variables = variables_response(&state, windows.runtime_ref); + let variables = variables["variables"] + .as_array() + .expect("variables response should be an array"); + + assert!(variables.iter().any(|variable| { + variable["name"] == "virtual_process_id" && variable["value"] == state.process.to_string() + })); + assert!(variables.iter().any(|variable| { + variable["name"] == "virtual_thread" && variable["value"] == "compile windows" + })); +} + +#[test] +fn variables_expose_mvp_runtime_handles_and_output_tails() { + let mut state = AdapterState::default(); + state.runtime_backend = RuntimeBackend::LocalServices; + state.apply_runtime_record(RuntimeLaunchRecord { + coordinator: "127.0.0.1:7999".to_owned(), + node: "node".to_owned(), + node_report: json!({ "terminal_event": { + "task": "compile-linux-1", + "task_definition": "compile-linux" + } }), + task_events: json!({ "events": [] }), + placed_task_launched: true, + status_code: Some(0), + stdout_bytes: 11, + stderr_bytes: 7, + stdout_tail: "hello tail\n".to_owned(), + stderr_tail: "warn\n".to_owned(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/dap-output.txt".to_owned()), + event_count: 1, + debug_epoch: None, + stopped_task: None, + stopped_probe_symbol: None, + all_participants_frozen: false, + }); + { + let linux = state.threads.get_mut(&LINUX_THREAD).unwrap(); + linux.runtime_task_args = vec![("source".to_owned(), "source://checkout".to_owned())]; + linux.runtime_handles = vec![( + "artifact".to_owned(), + "artifact://runtime/output".to_owned(), + )]; + linux.runtime_command_status = Some("frozen native command pid 42".to_owned()); + } + let linux = state + .threads + .get(&LINUX_THREAD) + .expect("linux task should exist"); + + let args = variables_response(&state, linux.args_ref); + let args = args["variables"] + .as_array() + .expect("variables response should be an array"); + assert!(args.iter().any(|variable| { + variable["name"] == "source" + && variable["value"] == "source://checkout" + && variable["type"] == "task-argument" + })); + assert!(args.iter().any(|variable| { + variable["name"] == "artifact" + && variable["value"] == "artifact://runtime/output" + && variable["type"] == "runtime-handle" + })); + + let runtime = variables_response(&state, linux.runtime_ref); + let runtime = runtime["variables"].as_array().unwrap(); + let command_ref = runtime + .iter() + .find(|variable| variable["name"] == "command_spec") + .and_then(|variable| variable["variablesReference"].as_i64()) + .expect("command spec should have child variables"); + assert!(runtime + .iter() + .any(|variable| variable["name"] == "stdout_tail" && variable["value"] == "hello tail\n")); + assert!(runtime + .iter() + .any(|variable| variable["name"] == "stderr_tail" && variable["value"] == "warn\n")); + + let command = variables_response(&state, command_ref); + let command = command["variables"].as_array().unwrap(); + assert!(command.iter().any(|variable| { + variable["name"] == "status" && variable["value"] == "frozen native command pid 42" + })); + assert!(!command.iter().any(|variable| { + variable["name"] == "program" || variable["name"] == "required_capability" + })); +} + +#[test] +fn attach_record_replaces_demo_threads_with_coordinator_task_events() { + let mut state = AdapterState::default(); + state.runtime_backend = RuntimeBackend::LiveServices; + state.apply_attach_record(RuntimeLaunchRecord { + coordinator: "127.0.0.1:9443".to_owned(), + node: "coordinator-debug-attach".to_owned(), + node_report: json!({ + "debug_attach": { + "authorization": { + "allowed": true, + "reason": "debug attach allowed" + } + } + }), + task_events: json!({ + "events": [{ + "tenant": "tenant", + "project": "project", + "process": state.process.to_string(), + "node": "worker-linux", + "executor": "node", + "task_definition": "compile-linux", + "task": "compile-linux-1", + "terminal_state": "completed", + "status_code": 0, + "stdout_bytes": 4, + "stderr_bytes": 0, + "stdout_tail": "done", + "stderr_tail": "", + "stdout_truncated": false, + "stderr_truncated": false, + "artifact_path": "/vfs/artifacts/from-attach.txt", + "artifact_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifact_size_bytes": 4 + }] + }), + placed_task_launched: true, + status_code: Some(0), + stdout_bytes: 4, + stderr_bytes: 0, + stdout_tail: "done".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/from-attach.txt".to_owned()), + event_count: 1, + debug_epoch: None, + stopped_task: None, + stopped_probe_symbol: None, + all_participants_frozen: false, + }); + + assert_eq!(state.threads.len(), 2); + assert!(state.threads.contains_key(&MAIN_THREAD)); + assert!(!state.threads.contains_key(&WINDOWS_THREAD)); + let linux = state + .threads + .get(&LINUX_THREAD) + .expect("coordinator task event should become debugger thread"); + assert_eq!(linux.task, TaskInstanceId::from("compile-linux-1")); + assert_eq!( + linux.task_definition, + disasmer_core::TaskDefinitionId::from("compile-linux") + ); + assert_eq!(linux.stdout_tail, "done"); + assert_eq!(linux.state, DebugRuntimeState::Completed); + assert_eq!( + state.runtime_artifact_path.as_deref(), + Some("/vfs/artifacts/from-attach.txt") + ); + assert!(state + .command_status + .contains("attached to existing virtual process")); + assert!(state.command_status.contains("coordinator task event")); +} + +#[test] +fn attach_projects_active_coordinator_main_without_inventing_a_worker_task() { + let mut state = AdapterState::default(); + state.runtime_backend = RuntimeBackend::LiveServices; + let main_instance = format!("ti:{}:main", state.process); + state.apply_attach_record(RuntimeLaunchRecord { + coordinator: "127.0.0.1:9443".to_owned(), + node: "coordinator-main".to_owned(), + node_report: json!({ + "debug_attach": { "authorization": { "allowed": true } }, + "process_status": { + "process": state.process.to_string(), + "state": "running", + "main_task_definition": "sha256:main-definition", + "main_task_instance": main_instance, + "main_state": "running", + "main_debug_epoch": null, + "connected_nodes": [], + "coordinator_epoch": 1 + } + }), + task_events: json!({ "events": [] }), + placed_task_launched: true, + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count: 0, + debug_epoch: None, + stopped_task: None, + stopped_probe_symbol: None, + all_participants_frozen: false, + }); + + assert_eq!(state.threads.len(), 1); + let main = state.threads.get(&MAIN_THREAD).unwrap(); + assert_eq!(main.task, TaskInstanceId::from(main_instance.as_str())); + assert_eq!( + main.task_definition, + disasmer_core::TaskDefinitionId::from("sha256:main-definition") + ); + assert_eq!(main.state, DebugRuntimeState::Running); + assert!(main.name.contains("coordinator main")); +} + +#[test] +fn coordinator_main_terminal_event_updates_main_thread_instead_of_adding_one() { + let mut state = AdapterState::default(); + state.runtime_backend = RuntimeBackend::LiveServices; + let main_instance = format!("ti:{}:main", state.process); + state.apply_attach_record(RuntimeLaunchRecord { + coordinator: "127.0.0.1:9443".to_owned(), + node: "coordinator-main".to_owned(), + node_report: json!({ "debug_attach": { "authorization": { "allowed": true } } }), + task_events: json!({ + "events": [ + { + "node": "worker-linux", + "executor": "node", + "task_definition": "compile_linux", + "task": "ti:child:1", + "terminal_state": "completed", + "status_code": 0 + }, + { + "node": "coordinator-main", + "executor": "coordinator_main", + "task_definition": "sha256:main-definition", + "task": main_instance, + "terminal_state": "completed", + "status_code": 0 + } + ] + }), + placed_task_launched: true, + status_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count: 2, + debug_epoch: None, + stopped_task: None, + stopped_probe_symbol: None, + all_participants_frozen: false, + }); + + assert_eq!(state.threads.len(), 2); + let main = state.threads.get(&MAIN_THREAD).unwrap(); + assert_eq!(main.task, TaskInstanceId::from(main_instance.as_str())); + assert_eq!(main.state, DebugRuntimeState::Completed); + assert!(state + .threads + .get(&LINUX_THREAD) + .is_some_and(|thread| thread.task == TaskInstanceId::from("ti:child:1"))); +} + +#[test] +fn source_locals_infer_disasmer_api_values_from_runtime_state() { + let mut state = AdapterState::default(); + let project = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/launch-build-demo") + .canonicalize() + .unwrap(); + state.project = project.to_string_lossy().into_owned(); + state.source_path = "src/build.rs".to_owned(); + let source = fs::read_to_string(project.join(&state.source_path)).unwrap(); + let inspection_line = source + .lines() + .position(|line| line.contains("let package_artifact = package.join()")) + .map(|index| index as i64 + 1) + .expect("flagship source must expose the post-join locals inspection point"); + state.threads.get_mut(&MAIN_THREAD).unwrap().line = inspection_line; + let thread = state.threads[&MAIN_THREAD].clone(); + + let locals = variables_response(&state, thread.locals_ref); + let locals = locals["variables"].as_array().unwrap(); + + assert!(locals.iter().any(|variable| { + variable["name"] == "linux" + && variable["value"].as_str().is_some_and(|value| { + value.contains("TaskHandle") + && value.contains("compile-linux") + && value.contains("virtual_thread_id = 2") + }) + })); + assert!(locals + .iter() + .any(|variable| { variable["name"] == "linux_thread" && variable["value"] == "2" })); + assert!(locals.iter().any(|variable| { + variable["name"] == "linux_artifact" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("Artifact")) + })); + assert!(locals.iter().any(|variable| { + variable["name"] == "unavailable-local-diagnostic" + && variable["type"] == "unavailable-local" + })); +} + +#[test] +fn source_locals_infer_task_with_arg_values_from_runtime_state() { + let project = + std::env::temp_dir().join(format!("disasmer-dap-task-with-arg-{}", std::process::id())); + let src = project.join("src"); + fs::create_dir_all(&src).unwrap(); + fs::write( + src.join("main.rs"), + r#"use disasmer::{Artifact, EnvRef, SourceSnapshot}; + +async fn build_release() -> Result<(), disasmer::TaskArgError> { + let source = prepare_source_snapshot(); + + let compile = disasmer::spawn::task_with_arg(source.clone(), compile_linux) + .name("compile linux") + .env(linux_command_env()) + .start() + .await?; + + let compile_thread = compile.virtual_thread_id(); + let linux_artifact = compile.join().await; + + let package = disasmer::spawn::task_with_arg(vec![linux_artifact.clone()], package_release) + .name("package release") + .env(coordinator_env()) + .start() + .await?; + + let package_thread = package.virtual_thread_id(); + let release_artifact = package.join().await; + Ok(()) +} + +fn prepare_source_snapshot() -> SourceSnapshot { + SourceSnapshot { + digest: "source://coordinator/quick-test-checkout".to_owned(), + } +} + +fn linux_command_env() -> EnvRef { + disasmer::env!("linux-command") +} + +fn coordinator_env() -> EnvRef { + disasmer::env!("coordinator") +} + +fn compile_linux(source: SourceSnapshot) -> Artifact { + Artifact { id: source.digest } +} + +fn package_release(inputs: Vec) -> Artifact { + inputs.into_iter().next().unwrap() +} +"#, + ) + .unwrap(); + + let mut state = AdapterState::default(); + state.project = project.to_string_lossy().into_owned(); + state.source_path = "src/main.rs".to_owned(); + state.threads.get_mut(&MAIN_THREAD).unwrap().line = 23; + let thread = state.threads[&MAIN_THREAD].clone(); + + let locals = variables_response(&state, thread.locals_ref); + let locals = locals["variables"].as_array().unwrap(); + + assert!(locals.iter().any(|variable| { + variable["name"] == "source" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("source://coordinator/quick-test-checkout")) + })); + assert!(locals.iter().any(|variable| { + variable["name"] == "compile" + && variable["value"].as_str().is_some_and(|value| { + value.contains("TaskHandle") + && value.contains("compile-linux") + && value.contains("virtual_thread_id = 2") + && value.contains("linux-command") + }) + })); + assert!(locals + .iter() + .any(|variable| variable["name"] == "compile_thread" && variable["value"] == "2")); + assert!(locals.iter().any(|variable| { + variable["name"] == "linux_artifact" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("Artifact")) + })); + assert!(locals.iter().any(|variable| { + variable["name"] == "package" + && variable["value"].as_str().is_some_and(|value| { + value.contains("TaskHandle") + && value.contains("package-release") + && value.contains("virtual_thread_id = 4") + && value.contains("coordinator") + }) + })); + assert!(locals + .iter() + .any(|variable| variable["name"] == "package_thread" && variable["value"] == "4")); + assert!(locals.iter().any(|variable| { + variable["name"] == "release_artifact" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("Artifact")) + })); + + let _ = fs::remove_dir_all(project); +} + +#[test] +fn wasm_frame_locals_expose_only_values_from_the_node_snapshot() { + let mut state = AdapterState::default(); + state.project = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/launch-build-demo") + .to_string_lossy() + .into_owned(); + state.source_path = "src/build.rs".to_owned(); + let source = fs::read_to_string(Path::new(&state.project).join(&state.source_path)).unwrap(); + state.threads.get_mut(&MAIN_THREAD).unwrap().line = source + .lines() + .position(|line| line.contains("input + 1")) + .map(|line| line as i64 + 1) + .expect("task_add_one source line"); + state + .threads + .get_mut(&MAIN_THREAD) + .unwrap() + .wasm_local_values = vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())]; + let thread = state.threads[&MAIN_THREAD].clone(); + + let locals = variables_response(&state, thread.wasm_locals_ref); + let locals = locals["variables"].as_array().unwrap(); + + assert!(locals.iter().any(|variable| { + variable["name"] == "wasm_local_0" + && variable["type"] == "wasm-frame-local" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("41")) + })); + assert!(!locals + .iter() + .any(|variable| variable["name"] == "wasm-local-diagnostic")); +} diff --git a/crates/disasmer-dap/src/variables.rs b/crates/disasmer-dap/src/variables.rs new file mode 100644 index 0000000..57fc58c --- /dev/null +++ b/crates/disasmer-dap/src/variables.rs @@ -0,0 +1,735 @@ +use std::collections::BTreeMap; +use std::fs; + +use disasmer_core::{Digest, TaskInstanceId}; +use serde_json::{json, Value}; + +use crate::breakpoints::parse_rust_function_name; +use crate::demo_backend::{LINUX_THREAD, MAIN_THREAD, PACKAGE_THREAD, WINDOWS_THREAD}; +use crate::virtual_model::{AdapterState, VirtualThread}; + +pub(crate) fn variables_response(state: &AdapterState, reference: i64) -> Value { + let Some(thread) = state.threads.values().find(|thread| { + thread.locals_ref == reference + || thread.wasm_locals_ref == reference + || thread.args_ref == reference + || thread.runtime_ref == reference + || thread.output_ref == reference + || thread.target_ref == reference + || thread.vfs_ref == reference + || thread.command_ref == reference + }) else { + return json!({ "variables": [] }); + }; + + if thread.locals_ref == reference { + return json!({ "variables": source_locals_variables(state, thread) }); + } + + if thread.wasm_locals_ref == reference { + return json!({ "variables": wasm_frame_local_variables(state, thread) }); + } + + if thread.args_ref == reference { + let mut variables = thread + .runtime_task_args + .iter() + .map(|(name, value)| { + json!({ + "name": name, + "value": value, + "type": "task-argument", + "variablesReference": 0, + }) + }) + .chain(thread.runtime_handles.iter().map(|(name, value)| { + json!({ + "name": name, + "value": value, + "type": "runtime-handle", + "variablesReference": 0, + }) + })) + .collect::>(); + if variables.is_empty() { + variables.push(json!({ + "name": "runtime-boundary-diagnostic", + "value": "the frozen runtime participant reported no task arguments or handles at this probe", + "type": "diagnostic", + "variablesReference": 0, + })); + } + return json!({ "variables": variables }); + } + + if thread.target_ref == reference { + let runtime_backed = + state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated; + return json!({ + "variables": [ + { + "name": "task", + "value": thread.task.to_string(), + "variablesReference": 0 + }, + { + "name": "environment", + "value": if runtime_backed { "not reported by the frozen node participant" } else { task_environment(thread) }, + "variablesReference": 0 + }, + { + "name": "kind", + "value": if thread.id == MAIN_THREAD { "wasm entrypoint" } else { "wasm task" }, + "variablesReference": 0 + }, + { + "name": "required_capability", + "value": if runtime_backed { "not reported by the frozen node participant" } else if thread.id == MAIN_THREAD { "none" } else { "Command" }, + "variablesReference": 0 + } + ] + }); + } + + if thread.vfs_ref == reference { + if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated { + return json!({ + "variables": [ + { + "name": "runtime-vfs-diagnostic", + "value": state.runtime_artifact_path.as_deref().map_or( + "the frozen node participant did not report a VFS or artifact handle snapshot", + |_| "the runtime reported a terminal artifact path; no broader VFS snapshot was reported" + ), + "variablesReference": 0 + }, + { + "name": "reported_artifact_path", + "value": state.runtime_artifact_path.as_deref().unwrap_or("unavailable"), + "variablesReference": 0 + } + ] + }); + } + return json!({ + "variables": [ + { + "name": "/vfs/artifacts", + "value": artifact_handle_value(state, thread), + "variablesReference": 0 + }, + { + "name": "/vfs/sources", + "value": format!("source://local-checkout/{}", project_snapshot_suffix(&state.project)), + "variablesReference": 0 + }, + { + "name": "/vfs/blobs", + "value": blob_digest(state, thread), + "variablesReference": 0 + }, + { + "name": "durability", + "value": "best-effort until explicitly exported or stored", + "variablesReference": 0 + } + ] + }); + } + + if thread.runtime_ref == reference { + return json!({ + "variables": [ + { + "name": "virtual_process_id", + "value": state.process.to_string(), + "variablesReference": 0 + }, + { + "name": "virtual_thread", + "value": thread.name, + "variablesReference": 0 + }, + { + "name": "debug_epoch", + "value": state.epoch, + "variablesReference": 0 + }, + { + "name": "state", + "value": format!("{:?}", thread.state), + "variablesReference": 0 + }, + { + "name": "runtime_backend", + "value": format!("{:?}", state.runtime_backend), + "variablesReference": 0 + }, + { + "name": "coordinator_task_events", + "value": state.runtime_event_count, + "variablesReference": 0 + }, + { + "name": "command_status", + "value": thread.runtime_command_status.as_deref().unwrap_or(&state.command_status), + "variablesReference": 0 + }, + { + "name": "command_spec", + "value": command_spec_summary(state, thread), + "variablesReference": if thread.runtime_command_status.is_some() { thread.command_ref } else { 0 } + }, + { + "name": "stdout_tail", + "value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"), + "variablesReference": 0 + }, + { + "name": "stderr_tail", + "value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"), + "variablesReference": 0 + } + ] + }); + } + + if thread.command_ref == reference { + if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated { + return json!({ + "variables": [ + { + "name": "status", + "value": thread.runtime_command_status.as_deref().unwrap_or("no native command state was reported by this participant"), + "variablesReference": 0 + }, + { + "name": "command-spec-diagnostic", + "value": "the node debug snapshot does not yet report native command program, arguments, or capability requirements", + "variablesReference": 0 + } + ] + }); + } + return json!({ + "variables": [ + { + "name": "program", + "value": command_program(thread), + "variablesReference": 0 + }, + { + "name": "args", + "value": command_args_summary(state, thread), + "variablesReference": 0 + }, + { + "name": "required_capability", + "value": if thread.id == MAIN_THREAD { "none" } else { "Command" }, + "variablesReference": 0 + }, + { + "name": "status", + "value": state.command_status, + "variablesReference": 0 + } + ] + }); + } + + let mut variables = vec![ + json!({ + "name": "stdout_tail", + "value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"), + "variablesReference": 0 + }), + json!({ + "name": "stderr_tail", + "value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"), + "variablesReference": 0 + }), + ]; + variables.extend( + thread + .recent_output + .iter() + .enumerate() + .map(|(index, output)| { + json!({ + "name": format!("line_{index}"), + "value": output, + "variablesReference": 0 + }) + }), + ); + + json!({ "variables": variables }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SourceLocal { + name: String, + line: i64, + value: Option, +} + +fn source_locals_variables(state: &AdapterState, thread: &VirtualThread) -> Vec { + let (locals, diagnostic) = source_locals_for_thread(state, thread); + let mut variables = locals + .into_iter() + .map(|local| { + let has_value = local.value.is_some(); + let value = local.value.unwrap_or_else(|| { + format!( + "cannot be inspected: source-level local from line {} is known, but DWARF/runtime value snapshot is unavailable", + local.line + ) + }); + json!({ + "name": local.name, + "value": value, + "type": if has_value { "disasmer-source-local" } else { "unavailable-local" }, + "variablesReference": 0 + }) + }) + .collect::>(); + variables.push(json!({ + "name": "unavailable-local-diagnostic", + "value": diagnostic, + "type": "unavailable-local", + "variablesReference": 0 + })); + variables +} + +fn wasm_frame_local_variables(_state: &AdapterState, thread: &VirtualThread) -> Vec { + if thread.wasm_local_values.is_empty() { + return vec![json!({ + "name": "wasm-local-diagnostic", + "value": "the frozen node participant did not report inspectable Wasm frame locals at this safepoint", + "type": "unavailable-local", + "variablesReference": 0 + })]; + } + thread + .wasm_local_values + .iter() + .map(|(name, value)| { + json!({ + "name": name, + "value": value, + "type": "wasm-frame-local", + "variablesReference": 0 + }) + }) + .collect() +} + +fn source_locals_for_thread( + state: &AdapterState, + thread: &VirtualThread, +) -> (Vec, String) { + let source_path = crate::source::resolve_source_path(&state.project, &state.source_path); + let Ok(source) = fs::read_to_string(&source_path) else { + return ( + Vec::new(), + format!( + "cannot be inspected: source file `{}` is unavailable; task args and handles remain visible", + source_path.display() + ), + ); + }; + let lines = source.lines().collect::>(); + if lines.is_empty() { + return ( + Vec::new(), + "cannot be inspected: source file is empty; task args and handles remain visible" + .to_owned(), + ); + } + let current = usize::try_from(thread.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .unwrap_or(0) + .min(lines.len() - 1); + let function_start = lines[..=current] + .iter() + .rposition(|line| parse_rust_function_name(line).is_some()) + .unwrap_or(0); + let mut locals = Vec::new(); + let mut runtime_values = BTreeMap::new(); + let mut index = function_start; + while index <= current { + let Some(name) = parse_let_binding_name(lines[index]) else { + index += 1; + continue; + }; + + let line = (index + 1) as i64; + let mut statement = lines[index].to_owned(); + while !statement.contains(';') && index < current { + index += 1; + statement.push('\n'); + statement.push_str(lines[index]); + } + + let runtime_value = infer_disasmer_source_local_value(state, &statement, &runtime_values); + if let Some(value) = runtime_value.clone() { + runtime_values.insert(name.clone(), value); + } + locals.push(SourceLocal { + name, + line, + value: runtime_value.map(|value| value.display(state)), + }); + index += 1; + } + let diagnostic = if locals.is_empty() { + "cannot be inspected: no source-level `let` locals are in scope for this frame; task args and handles remain visible" + .to_owned() + } else if locals.iter().any(|local| local.value.is_some()) { + "selected source-level Disasmer API locals are populated from virtual-thread runtime state; non-Disasmer locals remain best-effort until DWARF value snapshots are available" + .to_owned() + } else { + "cannot be inspected: selected source-level local names are best-effort until DWARF/runtime value snapshots are available" + .to_owned() + }; + (locals, diagnostic) +} + +fn parse_let_binding_name(line: &str) -> Option { + let rest = line.trim_start().strip_prefix("let ")?; + let rest = rest + .trim_start() + .strip_prefix("mut ") + .unwrap_or(rest) + .trim_start(); + let name = rest + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + (!name.is_empty()).then_some(name) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum SourceLocalRuntimeValue { + SourceSnapshot(String), + TaskHandle { + task: TaskInstanceId, + thread_id: i64, + env: String, + state: String, + }, + ThreadId(i64), + Artifact(String), +} + +impl SourceLocalRuntimeValue { + fn display(&self, _state: &AdapterState) -> String { + match self { + SourceLocalRuntimeValue::SourceSnapshot(digest) => { + format!("SourceSnapshot {{ digest = \"{digest}\" }}") + } + SourceLocalRuntimeValue::TaskHandle { + task, + thread_id, + env, + state, + } => format!( + "TaskHandle {{ task = \"{task}\", virtual_thread_id = {thread_id}, env = \"{env}\", state = {state} }}" + ), + SourceLocalRuntimeValue::ThreadId(thread_id) => thread_id.to_string(), + SourceLocalRuntimeValue::Artifact(artifact) => { + format!("Artifact {{ id = \"{artifact}\" }}") + } + } + } +} + +fn infer_disasmer_source_local_value( + state: &AdapterState, + statement: &str, + runtime_values: &BTreeMap, +) -> Option { + if statement.contains("prepare_source_snapshot()") { + return Some(SourceLocalRuntimeValue::SourceSnapshot( + source_snapshot_digest_from_project(state).unwrap_or_else(|| { + format!( + "source://local-checkout/{}", + project_snapshot_suffix(&state.project) + ) + }), + )); + } + + if let Some(task_function) = extract_spawn_task_function(statement) { + let task_name = extract_string_argument(statement, ".name("); + let spawned_thread = + thread_for_spawn_statement(state, &task_function, task_name.as_deref())?; + return Some(SourceLocalRuntimeValue::TaskHandle { + task: spawned_thread.task.clone(), + thread_id: spawned_thread.id, + env: extract_env_name(statement) + .unwrap_or_else(|| task_environment(spawned_thread).to_owned()), + state: format!("{:?}", spawned_thread.state), + }); + } + + if let Some(receiver) = receiver_before_method(statement, ".virtual_thread_id()") { + if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) = + runtime_values.get(&receiver) + { + return Some(SourceLocalRuntimeValue::ThreadId(*thread_id)); + } + } + + if let Some(receiver) = receiver_before_method(statement, ".join()") { + if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) = + runtime_values.get(&receiver) + { + if let Some(thread) = state.threads.get(thread_id) { + return Some(SourceLocalRuntimeValue::Artifact(artifact_handle_value( + state, thread, + ))); + } + } + } + + None +} + +fn extract_spawn_task_function(statement: &str) -> Option { + for marker in ["disasmer::spawn::async_task(", "disasmer::spawn::task("] { + if let Some(args) = extract_call_arguments(statement, marker) { + return args.first().cloned(); + } + } + for marker in [ + "disasmer::spawn::async_task_with_arg(", + "disasmer::spawn::task_with_arg(", + ] { + if let Some(args) = extract_call_arguments(statement, marker) { + return args.get(1).cloned(); + } + } + None +} + +fn source_snapshot_digest_from_project(state: &AdapterState) -> Option { + let source = fs::read_to_string(crate::source::resolve_source_path( + &state.project, + &state.source_path, + )) + .ok()?; + let digest_marker = source.find("digest:")?; + let after_digest = &source[digest_marker..]; + let first_quote = after_digest.find('"')? + 1; + let rest = &after_digest[first_quote..]; + let end_quote = rest.find('"')?; + Some(rest[..end_quote].to_owned()) +} + +fn extract_call_arguments(statement: &str, marker: &str) -> Option> { + let start = statement.find(marker)? + marker.len(); + let rest = &statement[start..]; + let mut args = Vec::new(); + let mut current = String::new(); + let mut depth = 0_i32; + let mut in_string = false; + let mut escaped = false; + + for ch in rest.chars() { + if in_string { + current.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => { + in_string = true; + current.push(ch); + } + '(' | '[' | '{' => { + depth += 1; + current.push(ch); + } + ')' => { + if depth == 0 { + let value = current.trim(); + if !value.is_empty() { + args.push(value.to_owned()); + } + return (!args.is_empty()).then_some(args); + } + depth -= 1; + current.push(ch); + } + ']' | '}' => { + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + let value = current.trim(); + if !value.is_empty() { + args.push(value.to_owned()); + } + current.clear(); + } + _ => current.push(ch), + } + } + + None +} + +fn extract_string_argument(statement: &str, marker: &str) -> Option { + let start = statement.find(marker)? + marker.len(); + let rest = &statement[start..]; + let quoted = rest.strip_prefix('"')?; + let end = quoted.find('"')?; + Some(quoted[..end].to_owned()) +} + +fn extract_env_name(statement: &str) -> Option { + if statement.contains("windows_env") || statement.contains("env!(\"windows\")") { + return Some("windows".to_owned()); + } + if statement.contains("linux_command_env") || statement.contains("env!(\"linux-command\")") { + return Some("linux-command".to_owned()); + } + if statement.contains("linux_env") || statement.contains("env!(\"linux\")") { + return Some("linux".to_owned()); + } + if statement.contains("coordinator_env") || statement.contains("env!(\"coordinator\")") { + return Some("coordinator".to_owned()); + } + None +} + +fn receiver_before_method(statement: &str, method: &str) -> Option { + let before = statement.split(method).next()?.trim_end(); + let receiver = before + .chars() + .rev() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::() + .chars() + .rev() + .collect::(); + (!receiver.is_empty()).then_some(receiver) +} + +fn thread_for_spawn_statement<'a>( + state: &'a AdapterState, + task_function: &str, + task_name: Option<&str>, +) -> Option<&'a VirtualThread> { + if let Some(task_name) = task_name { + if let Some(thread) = state + .threads + .values() + .find(|thread| thread.name == task_name) + { + return Some(thread); + } + } + let normalized = task_function.replace('_', "-"); + state.threads.values().find(|thread| { + thread.task.as_str() == normalized + || (thread.id == PACKAGE_THREAD && normalized == "package-release") + }) +} + +fn task_environment(thread: &VirtualThread) -> &'static str { + match thread.id { + WINDOWS_THREAD => "windows", + MAIN_THREAD => "coordinator", + PACKAGE_THREAD => "coordinator", + LINUX_THREAD => "linux", + _ => "unconstrained", + } +} + +fn artifact_handle_value(state: &AdapterState, thread: &VirtualThread) -> String { + if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated { + return state.runtime_artifact_path.clone().unwrap_or_else(|| { + "unavailable: runtime participant did not report an artifact handle".to_owned() + }); + } + if thread.id == LINUX_THREAD { + return state.runtime_artifact_path.clone().unwrap_or_else(|| { + format!("artifact://{}/{}/app.tar.zst", state.process, thread.task) + }); + } + if thread.id == PACKAGE_THREAD { + return "artifact://package/release.tar.zst".to_owned(); + } + format!("artifact://{}/{}/app.tar.zst", state.process, thread.task) +} + +fn blob_digest(state: &AdapterState, thread: &VirtualThread) -> String { + Digest::from_parts([ + b"blob:v1".as_slice(), + state.project.as_bytes(), + thread.task.as_str().as_bytes(), + ]) + .as_str() + .to_owned() +} + +fn command_spec_summary(state: &AdapterState, thread: &VirtualThread) -> String { + if state.runtime_backend != crate::virtual_model::RuntimeBackend::Simulated { + return thread + .runtime_command_status + .clone() + .unwrap_or_else(|| "no native command state reported".to_owned()); + } + format!( + "{} {} ({})", + command_program(thread), + command_args_summary(state, thread), + state.command_status + ) +} + +fn command_program(thread: &VirtualThread) -> &'static str { + if thread.id == MAIN_THREAD { + "coordinator-side wasm" + } else { + "cargo" + } +} + +fn command_args_summary(state: &AdapterState, thread: &VirtualThread) -> String { + if thread.id == MAIN_THREAD { + return format!("entry={}", state.entry); + } + format!("test --quiet --manifest-path {}/Cargo.toml", state.project) +} + +fn output_tail_display(tail: &str, bytes: u64, truncated: bool, stream: &str) -> String { + if tail.is_empty() { + return format!("no {stream} captured ({bytes} bytes)"); + } + if truncated { + format!("{tail}\n... truncated") + } else { + tail.to_owned() + } +} + +fn project_snapshot_suffix(project: &str) -> String { + Digest::from_parts([b"source-snapshot:v1".as_slice(), project.as_bytes()]) + .as_str() + .trim_start_matches("sha256:") + .chars() + .take(12) + .collect() +} diff --git a/crates/disasmer-dap/src/view_state.rs b/crates/disasmer-dap/src/view_state.rs new file mode 100644 index 0000000..dc92487 --- /dev/null +++ b/crates/disasmer-dap/src/view_state.rs @@ -0,0 +1,89 @@ +use std::fs; +use std::path::Path; + +use anyhow::Result; +use serde_json::json; + +use crate::virtual_model::{AdapterState, RuntimeLaunchRecord}; + +pub(crate) fn normalize_coordinator_endpoint(endpoint: &str) -> String { + endpoint.trim().trim_end_matches('/').to_owned() +} + +pub(crate) fn write_view_state(state: &AdapterState, record: &RuntimeLaunchRecord) -> Result<()> { + let dir = Path::new(&state.project).join(".disasmer"); + fs::create_dir_all(&dir)?; + let node_status = if record.placed_task_launched { + if record.status_code == Some(0) { + "completed" + } else { + "failed" + } + } else { + "not-required-yet" + }; + let process_status = if record.placed_task_launched { + if record.status_code == Some(0) { + "completed" + } else { + "failed" + } + } else { + "running" + }; + let log_message = if record.placed_task_launched { + "node task output was staged as a VFS artifact" + } else { + "coordinator-side virtual process started; capability task not launched" + }; + let artifact_status = if record.placed_task_launched { + "best-effort retained on the attached node" + } else { + "not produced until a capability task is placed" + }; + let view_state = json!({ + "nodes": [{ + "id": record.node, + "status": node_status, + "capabilities": format!("connected to {}", record.coordinator), + }], + "processes": [{ + "id": state.process.as_str(), + "status": process_status, + "entry": state.entry, + }], + "logs": [{ + "task": "compile-linux", + "message": log_message, + "bytes": format!("stdout={} stderr={}", record.stdout_bytes, record.stderr_bytes), + }], + "artifacts": [{ + "path": record.artifact_path.clone().unwrap_or_else(|| "/vfs/artifacts/dap-output.txt".to_owned()), + "status": artifact_status, + "size": "reported by node", + }], + "inspector": [ + { + "label": "Runtime backend", + "value": state.runtime_backend.description(), + }, + { + "label": "Coordinator", + "value": record.coordinator, + }, + { + "label": "Node report", + "value": record.node_report.to_string(), + }, + { + "label": "Task events", + "value": record.task_events.to_string(), + } + ] + }); + fs::write( + dir.join("views.json"), + serde_json::to_string_pretty(&view_state)?, + )?; + Ok(()) +} diff --git a/crates/disasmer-dap/src/virtual_model.rs b/crates/disasmer-dap/src/virtual_model.rs new file mode 100644 index 0000000..a90b3fd --- /dev/null +++ b/crates/disasmer-dap/src/virtual_model.rs @@ -0,0 +1,775 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{anyhow, Result}; +use disasmer_core::{ + BundleDebugProbe, DebugRuntimeState, Digest, ProcessId, ProjectId, TaskDefinitionId, + TaskInstanceId, TenantId, UserId, +}; +use serde_json::Value; + +use crate::demo_backend::{launch_threads, LINUX_THREAD, MAIN_THREAD}; + +#[derive(Clone)] +pub(crate) struct VirtualThread { + pub(crate) id: i64, + pub(crate) frame_id: i64, + pub(crate) locals_ref: i64, + pub(crate) wasm_locals_ref: i64, + pub(crate) args_ref: i64, + pub(crate) runtime_ref: i64, + pub(crate) output_ref: i64, + pub(crate) target_ref: i64, + pub(crate) vfs_ref: i64, + pub(crate) command_ref: i64, + pub(crate) task: TaskInstanceId, + pub(crate) task_definition: TaskDefinitionId, + pub(crate) name: String, + pub(crate) line: i64, + pub(crate) state: DebugRuntimeState, + pub(crate) freeze_supported: bool, + pub(crate) recent_output: Vec, + pub(crate) stdout_bytes: u64, + pub(crate) stderr_bytes: u64, + pub(crate) stdout_tail: String, + pub(crate) stderr_tail: String, + pub(crate) stdout_truncated: bool, + pub(crate) stderr_truncated: bool, + pub(crate) runtime_stack_frames: Vec, + pub(crate) wasm_local_values: Vec<(String, String)>, + pub(crate) runtime_task_args: Vec<(String, String)>, + pub(crate) runtime_handles: Vec<(String, String)>, + pub(crate) runtime_command_status: Option, +} + +#[derive(Clone)] +pub(crate) struct AdapterState { + pub(crate) process: ProcessId, + pub(crate) epoch: u64, + pub(crate) entry: String, + pub(crate) project: String, + pub(crate) source_path: String, + pub(crate) runtime_backend: RuntimeBackend, + pub(crate) coordinator_endpoint: String, + pub(crate) tenant: TenantId, + pub(crate) project_id: ProjectId, + pub(crate) actor_user: UserId, + pub(crate) client_session_secret: Option, + pub(crate) session_mode: DapSessionMode, + pub(crate) restart_existing: bool, + pub(crate) command_status: String, + pub(crate) last_task_failed: bool, + pub(crate) runtime_artifact_path: Option, + pub(crate) runtime_event_count: usize, + pub(crate) coordinator_debug_epoch: Option, + pub(crate) stopped_task: Option, + pub(crate) debug_probes: Vec, + pub(crate) breakpoints: Vec, + pub(crate) threads: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RuntimeBackend { + Simulated, + LocalServices, + LiveServices, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DapSessionMode { + Launch, + Attach, +} + +impl RuntimeBackend { + pub(crate) fn description(&self) -> &'static str { + match self { + RuntimeBackend::Simulated => "simulated debug adapter state", + RuntimeBackend::LocalServices => "local services", + RuntimeBackend::LiveServices => "live services", + } + } +} + +impl Default for AdapterState { + fn default() -> Self { + let entry = "build".to_owned(); + let project = ".".to_owned(); + Self { + process: process_id(&project, &entry), + epoch: 0, + threads: launch_threads(&entry), + entry, + project, + source_path: "src/build.rs".to_owned(), + runtime_backend: RuntimeBackend::Simulated, + coordinator_endpoint: "https://disasmer.michelpaulissen.com".to_owned(), + tenant: TenantId::from("tenant"), + project_id: ProjectId::from("project"), + actor_user: UserId::from("dap"), + client_session_secret: None, + session_mode: DapSessionMode::Launch, + restart_existing: false, + command_status: "waiting for launch".to_owned(), + last_task_failed: false, + runtime_artifact_path: None, + runtime_event_count: 0, + coordinator_debug_epoch: None, + stopped_task: None, + debug_probes: Vec::new(), + breakpoints: Vec::new(), + } + } +} + +impl AdapterState { + pub(crate) fn requested_probe_symbols(&self) -> Vec { + let mut symbols = self + .breakpoints + .iter() + .filter_map(|line| { + self.debug_probes.iter().find(|probe| { + probe.source_path == self.source_path + && *line >= i64::from(probe.line_start) + && *line <= i64::from(probe.line_end) + }) + }) + .map(|probe| format!("disasmer.probe.{}", probe.function)) + .collect::>(); + symbols.sort(); + symbols.dedup(); + symbols + } + + pub(crate) fn configure_launch( + &mut self, + project: String, + entry: String, + runtime_backend: RuntimeBackend, + coordinator_endpoint: Option, + source_path: Option, + ) -> Result<()> { + self.process = process_id(&project, &entry); + self.entry = entry; + self.source_path = + source_path.unwrap_or_else(|| crate::source::infer_source_path(&project)); + self.project = project; + self.runtime_backend = runtime_backend; + let project_scope = load_project_scope(&self.project); + if self.runtime_backend == RuntimeBackend::LiveServices { + let session = load_client_session(&self.project).ok_or_else(|| { + anyhow!( + "no authenticated CLI session was found for this workspace; run `disasmer login --browser` from {}", + self.project + ) + })?; + if let Some(configured) = project_scope.as_ref() { + let mismatches = scope_mismatches(configured, &session); + if !mismatches.is_empty() { + return Err(anyhow!( + "the stored CLI session does not match this workspace ({mismatches}); run `disasmer login --browser` from {}", + self.project + )); + } + } + if let Some(requested) = coordinator_endpoint.as_deref() { + if crate::view_state::normalize_coordinator_endpoint(requested) + != crate::view_state::normalize_coordinator_endpoint(&session.coordinator) + { + return Err(anyhow!( + "the debug coordinator does not match the authenticated CLI session coordinator" + )); + } + } + self.coordinator_endpoint = + crate::view_state::normalize_coordinator_endpoint(&session.coordinator); + self.tenant = TenantId::new(session.tenant); + self.project_id = ProjectId::new(session.project); + self.actor_user = UserId::new(session.user); + self.client_session_secret = Some(session.session_secret); + } else { + self.coordinator_endpoint = crate::view_state::normalize_coordinator_endpoint( + coordinator_endpoint.as_deref().unwrap_or("127.0.0.1:0"), + ); + if let Some(scope) = project_scope { + self.tenant = TenantId::new(scope.tenant); + self.project_id = ProjectId::new(scope.project); + self.actor_user = UserId::new(scope.user); + } + self.client_session_secret = None; + } + self.session_mode = DapSessionMode::Launch; + self.restart_existing = false; + self.command_status = "waiting for configurationDone".to_owned(); + self.last_task_failed = false; + self.runtime_artifact_path = None; + self.runtime_event_count = 0; + self.coordinator_debug_epoch = None; + self.stopped_task = None; + self.debug_probes = + crate::breakpoints::load_bundle_debug_probes(&self.project, &self.source_path); + self.epoch = 0; + self.breakpoints.clear(); + self.threads = launch_threads(&self.entry); + if self.runtime_backend != RuntimeBackend::Simulated { + self.threads.retain(|id, _| *id == MAIN_THREAD); + if let Some(main) = self.threads.get_mut(&MAIN_THREAD) { + main.task = TaskInstanceId::new(self.entry.clone()); + main.name = format!("{} virtual process", self.entry); + } + } + Ok(()) + } + + pub(crate) fn apply_runtime_record(&mut self, record: RuntimeLaunchRecord) { + self.coordinator_endpoint = record.coordinator.clone(); + if !record.placed_task_launched { + self.command_status = format!( + "virtual process started through {}; no runtime task event observed yet", + self.runtime_backend.description() + ); + self.last_task_failed = false; + self.runtime_artifact_path = None; + self.runtime_event_count = record.event_count; + if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) { + thread.recent_output.push(format!( + "coordinator-side process started through {}", + self.runtime_backend.description() + )); + thread + .recent_output + .push("an attached worker will be required when the process reaches work that must be placed on a node".to_owned()); + } + let _ = crate::view_state::write_view_state(self, &record); + return; + } + + let task_failed = record.status_code.is_some_and(|status| status != 0); + self.command_status = if record.all_participants_frozen { + format!( + "frozen through {} at executing Wasm probe {} with debug epoch {}", + self.runtime_backend.description(), + record.stopped_probe_symbol.as_deref().unwrap_or("unknown"), + record.debug_epoch.unwrap_or_default() + ) + } else if let Some(status) = record.status_code { + format!( + "{} through {} with status {status}", + if task_failed { "failed" } else { "completed" }, + self.runtime_backend.description() + ) + } else { + format!("running through {}", self.runtime_backend.description()) + }; + self.last_task_failed = task_failed; + self.runtime_artifact_path = record.artifact_path.clone(); + self.runtime_event_count = record.event_count; + if record.all_participants_frozen { + self.epoch = record.debug_epoch.unwrap_or(self.epoch); + self.coordinator_debug_epoch = record.debug_epoch; + self.stopped_task = record.stopped_task.as_deref().map(TaskInstanceId::new); + if let Some(acknowledgements) = record + .node_report + .pointer("/debug_epoch/acknowledgements") + .and_then(Value::as_array) + { + let entry = self.entry.clone(); + for acknowledgement in acknowledgements { + let Some(task) = acknowledgement.get("task").and_then(Value::as_str) else { + continue; + }; + let Some(task_definition) = acknowledgement + .get("task_definition") + .and_then(Value::as_str) + else { + continue; + }; + let normalized = task.replace('_', "-"); + let coordinator_main = acknowledgement.get("node").and_then(Value::as_str) + == Some("coordinator-main"); + let thread_id = self + .threads + .values() + .find(|thread| { + thread.task.as_str() == task + || thread.task.as_str() == normalized + || thread.task_definition.as_str() == task_definition + || (thread.id == MAIN_THREAD + && (coordinator_main + || task_definition == entry + || task_definition == "main" + || task_definition == "build")) + }) + .map(|thread| thread.id) + .unwrap_or_else(|| self.insert_runtime_thread(task, task_definition)); + let thread = self + .threads + .get_mut(&thread_id) + .expect("runtime thread was found or inserted"); + thread.task = TaskInstanceId::new(task); + thread.task_definition = TaskDefinitionId::new(task_definition); + thread.runtime_stack_frames = acknowledgement + .get("stack_frames") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + thread.wasm_local_values = + value_string_pairs(acknowledgement.get("local_values")); + thread.runtime_task_args = value_string_pairs(acknowledgement.get("task_args")); + thread.runtime_handles = value_string_pairs(acknowledgement.get("handles")); + thread.runtime_command_status = acknowledgement + .get("command_status") + .and_then(Value::as_str) + .map(str::to_owned); + } + } + for thread in self.threads.values_mut() { + if thread.state == DebugRuntimeState::Running { + thread.state = DebugRuntimeState::Frozen; + } + } + if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) { + thread.recent_output.push(format!( + "executing task {} reported the breakpoint hit", + record.stopped_task.as_deref().unwrap_or("unknown") + )); + } + } + if let Some(terminal_event) = record.node_report.get("terminal_event") { + let terminal_task = terminal_event + .get("task") + .and_then(Value::as_str) + .unwrap_or(self.entry.as_str()) + .to_owned(); + let terminal_task_definition = terminal_event + .get("task_definition") + .and_then(Value::as_str) + .unwrap_or(self.entry.as_str()) + .to_owned(); + let terminal_thread_id = self + .threads + .values() + .find(|thread| { + thread.task.as_str() == terminal_task.as_str() + || thread.task_definition.as_str() == terminal_task_definition.as_str() + }) + .map(|thread| thread.id) + .unwrap_or_else(|| { + self.insert_runtime_thread(&terminal_task, &terminal_task_definition) + }); + if let Some(thread) = self.threads.get_mut(&terminal_thread_id) { + thread.stdout_bytes = record.stdout_bytes; + thread.stderr_bytes = record.stderr_bytes; + thread.stdout_tail = record.stdout_tail.clone(); + thread.stderr_tail = record.stderr_tail.clone(); + thread.stdout_truncated = record.stdout_truncated; + thread.stderr_truncated = record.stderr_truncated; + thread.recent_output.push(format!( + "{} task {} with {} stdout bytes and {} stderr bytes", + terminal_event + .get("executor") + .and_then(Value::as_str) + .unwrap_or("runtime"), + if task_failed { "failed" } else { "completed" }, + record.stdout_bytes, + record.stderr_bytes + )); + thread.recent_output.push(format!( + "coordinator recorded {} task event(s)", + record.event_count + )); + thread.state = if task_failed { + DebugRuntimeState::Failed(format!( + "local services task exited with status {:?}", + record.status_code + )) + } else { + DebugRuntimeState::Completed + }; + } + } + let _ = crate::view_state::write_view_state(self, &record); + } + + fn insert_runtime_thread(&mut self, task: &str, task_definition: &str) -> i64 { + let id = self + .threads + .keys() + .next_back() + .copied() + .unwrap_or(MAIN_THREAD) + + 1; + let line = self + .debug_probes + .iter() + .find(|probe| { + probe.task.as_str() == task_definition || probe.function == task_definition + }) + .map(|probe| i64::from(probe.line_start)) + .unwrap_or(1); + let definition_name = task_definition.replace(['_', '-'], " "); + let name = if task == task_definition { + definition_name + } else { + format!("{definition_name} ({task})") + }; + self.threads.insert( + id, + VirtualThread { + id, + frame_id: 1000 + id, + locals_ref: 5000 + id, + wasm_locals_ref: 9000 + id, + args_ref: 2000 + id, + runtime_ref: 3000 + id, + output_ref: 4000 + id, + target_ref: 6000 + id, + vfs_ref: 7000 + id, + command_ref: 8000 + id, + task: TaskInstanceId::new(task), + task_definition: TaskDefinitionId::new(task_definition), + name, + line, + state: DebugRuntimeState::Running, + freeze_supported: true, + recent_output: vec!["runtime participant reported by the node".to_owned()], + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + runtime_stack_frames: Vec::new(), + wasm_local_values: Vec::new(), + runtime_task_args: Vec::new(), + runtime_handles: Vec::new(), + runtime_command_status: None, + }, + ); + id + } + + pub(crate) fn apply_attach_record(&mut self, record: RuntimeLaunchRecord) { + self.coordinator_endpoint = record.coordinator.clone(); + self.command_status = format!( + "attached to existing virtual process through {}; coordinator task event(s): {}", + self.runtime_backend.description(), + record.event_count + ); + self.last_task_failed = false; + self.runtime_artifact_path = first_event_string(&record.task_events, "artifact_path"); + self.runtime_event_count = record.event_count; + self.threads = coordinator_threads_from_events( + &self.entry, + &record.task_events, + record.node_report.get("process_status"), + ); + if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) { + thread.recent_output.push(format!( + "attached to existing virtual process through {}", + self.runtime_backend.description() + )); + thread.recent_output.push(format!( + "coordinator returned {} task event(s) for this process", + record.event_count + )); + } + let _ = crate::view_state::write_view_state(self, &record); + } +} + +fn value_string_pairs(value: Option<&Value>) -> Vec<(String, String)> { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|pair| { + let pair = pair.as_array()?; + Some(( + pair.first()?.as_str()?.to_owned(), + pair.get(1)?.as_str()?.to_owned(), + )) + }) + .collect() +} + +#[derive(Clone, Debug)] +struct ProjectScope { + coordinator: Option, + tenant: String, + project: String, + user: String, +} + +#[derive(Clone, Debug)] +struct ClientSession { + coordinator: String, + tenant: String, + project: String, + user: String, + session_secret: String, +} + +fn load_project_scope(project: &str) -> Option { + for ancestor in Path::new(project).ancestors() { + let file = ancestor.join(".disasmer/project.json"); + let Ok(bytes) = std::fs::read(file) else { + continue; + }; + let Ok(config) = serde_json::from_slice::(&bytes) else { + continue; + }; + return Some(ProjectScope { + coordinator: config + .get("coordinator") + .and_then(Value::as_str) + .map(str::to_owned), + tenant: config.get("tenant")?.as_str()?.to_owned(), + project: config.get("project")?.as_str()?.to_owned(), + user: config.get("user")?.as_str()?.to_owned(), + }); + } + None +} + +fn load_client_session(project: &str) -> Option { + for ancestor in Path::new(project).ancestors() { + let file = ancestor.join(".disasmer/session.json"); + let Ok(bytes) = std::fs::read(file) else { + continue; + }; + let Ok(session) = serde_json::from_slice::(&bytes) else { + continue; + }; + let secret = session + .get("session_secret") + .and_then(Value::as_str) + .filter(|secret| !secret.trim().is_empty())?; + return Some(ClientSession { + coordinator: session.get("coordinator")?.as_str()?.to_owned(), + tenant: session.get("tenant")?.as_str()?.to_owned(), + project: session.get("project")?.as_str()?.to_owned(), + user: session.get("user")?.as_str()?.to_owned(), + session_secret: secret.to_owned(), + }); + } + None +} + +fn scope_mismatches(project: &ProjectScope, session: &ClientSession) -> String { + let mut fields = Vec::new(); + if project.coordinator.as_deref().is_some_and(|coordinator| { + crate::view_state::normalize_coordinator_endpoint(coordinator) + != crate::view_state::normalize_coordinator_endpoint(&session.coordinator) + }) { + fields.push("coordinator"); + } + if project.tenant != session.tenant { + fields.push("tenant"); + } + if project.project != session.project { + fields.push("project"); + } + if project.user != session.user { + fields.push("user"); + } + fields.join(", ") +} + +#[derive(Clone, Debug)] +pub(crate) struct RuntimeLaunchRecord { + pub(crate) coordinator: String, + pub(crate) node: String, + pub(crate) node_report: Value, + pub(crate) task_events: Value, + pub(crate) placed_task_launched: bool, + pub(crate) status_code: Option, + pub(crate) stdout_bytes: u64, + pub(crate) stderr_bytes: u64, + pub(crate) stdout_tail: String, + pub(crate) stderr_tail: String, + pub(crate) stdout_truncated: bool, + pub(crate) stderr_truncated: bool, + pub(crate) artifact_path: Option, + pub(crate) event_count: usize, + pub(crate) debug_epoch: Option, + pub(crate) stopped_task: Option, + pub(crate) stopped_probe_symbol: Option, + pub(crate) all_participants_frozen: bool, +} + +pub(crate) fn process_id(project: &str, entry: &str) -> ProcessId { + let digest = Digest::from_parts([ + b"dap-process:v1".as_slice(), + project.as_bytes(), + entry.as_bytes(), + ]); + let suffix = digest + .as_str() + .trim_start_matches("sha256:") + .chars() + .take(12) + .collect::(); + ProcessId::new(format!("vp-{suffix}")) +} + +fn coordinator_threads_from_events( + entry: &str, + task_events: &Value, + process_status: Option<&Value>, +) -> BTreeMap { + let mut threads = BTreeMap::new(); + let mut main = launch_threads(entry) + .remove(&MAIN_THREAD) + .expect("launch thread model should include main"); + if let Some(status) = process_status { + if let Some(task) = status.get("main_task_instance").and_then(Value::as_str) { + main.task = TaskInstanceId::from(task); + } + if let Some(definition) = status.get("main_task_definition").and_then(Value::as_str) { + main.task_definition = TaskDefinitionId::from(definition); + } + main.name = format!("{entry} coordinator main ({})", main.task); + main.state = if status + .get("main_debug_epoch") + .and_then(Value::as_u64) + .is_some() + { + DebugRuntimeState::Frozen + } else { + DebugRuntimeState::Running + }; + main.recent_output = vec![format!( + "coordinator reports main state {}", + status + .get("main_state") + .and_then(Value::as_str) + .unwrap_or("running") + )]; + } + threads.insert(MAIN_THREAD, main); + + let Some(events) = task_events.get("events").and_then(Value::as_array) else { + return threads; + }; + let mut next_worker_thread = LINUX_THREAD; + for (index, event) in events.iter().enumerate() { + let coordinator_main = + event.get("executor").and_then(Value::as_str) == Some("coordinator_main"); + let id = if coordinator_main { + MAIN_THREAD + } else { + let id = next_worker_thread; + next_worker_thread += 1; + id + }; + let Some(mut thread) = coordinator_event_thread(id, index, event) else { + continue; + }; + if coordinator_main { + thread.name = format!("{entry} coordinator main ({})", thread.task); + thread.recent_output.push( + "terminal state was recorded by the coordinator-hosted main runtime".to_owned(), + ); + } + threads.insert(id, thread); + } + threads +} + +fn coordinator_event_thread(id: i64, event_index: usize, event: &Value) -> Option { + let task = event.get("task").and_then(Value::as_str)?; + let task_definition = event.get("task_definition").and_then(Value::as_str)?; + let definition_name = task_definition.replace(['_', '-'], " "); + let name = if task == task_definition { + definition_name + } else { + format!("{definition_name} ({task})") + }; + let stdout_tail = event + .get("stdout_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let stderr_tail = event + .get("stderr_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let stdout_bytes = event + .get("stdout_bytes") + .and_then(Value::as_u64) + .unwrap_or(0); + let stderr_bytes = event + .get("stderr_bytes") + .and_then(Value::as_u64) + .unwrap_or(0); + let status_code = event.get("status_code").and_then(Value::as_i64); + let state = match event.get("terminal_state").and_then(Value::as_str) { + Some("completed") => DebugRuntimeState::Completed, + Some("cancelled") => DebugRuntimeState::Failed(format!("task {task} was cancelled")), + Some("failed") => DebugRuntimeState::Failed(format!("task {task} failed")), + _ if status_code == Some(0) => DebugRuntimeState::Completed, + _ => { + DebugRuntimeState::Failed(format!("task {task} completed with status {status_code:?}")) + } + }; + Some(VirtualThread { + id, + frame_id: 1000 + id, + locals_ref: 5000 + id, + wasm_locals_ref: 9000 + id, + args_ref: 2000 + id, + runtime_ref: 3000 + id, + output_ref: 4000 + id, + target_ref: 6000 + id, + vfs_ref: 7000 + id, + command_ref: 8000 + id, + task: TaskInstanceId::from(task), + task_definition: TaskDefinitionId::from(task_definition), + name, + line: 42 + event_index as i64, + state, + freeze_supported: true, + recent_output: vec![format!( + "coordinator task event from {} {}", + event + .get("executor") + .and_then(Value::as_str) + .unwrap_or("node"), + event + .get("node") + .and_then(Value::as_str) + .unwrap_or("unknown") + )], + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated: event + .get("stdout_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + stderr_truncated: event + .get("stderr_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + runtime_stack_frames: Vec::new(), + wasm_local_values: Vec::new(), + runtime_task_args: Vec::new(), + runtime_handles: Vec::new(), + runtime_command_status: None, + }) +} + +fn first_event_string(task_events: &Value, field: &str) -> Option { + task_events + .get("events") + .and_then(Value::as_array) + .and_then(|events| events.iter().find_map(|event| event.get(field))) + .and_then(Value::as_str) + .map(str::to_owned) +} diff --git a/crates/disasmer-macros/Cargo.toml b/crates/disasmer-macros/Cargo.toml new file mode 100644 index 0000000..4fc912e --- /dev/null +++ b/crates/disasmer-macros/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "disasmer-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +hex.workspace = true +proc-macro2.workspace = true +quote.workspace = true +serde_json.workspace = true +sha2.workspace = true +syn.workspace = true diff --git a/crates/disasmer-macros/src/lib.rs b/crates/disasmer-macros/src/lib.rs new file mode 100644 index 0000000..f35e2b2 --- /dev/null +++ b/crates/disasmer-macros/src/lib.rs @@ -0,0 +1,453 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote, ToTokens}; +use sha2::{Digest as _, Sha256}; +use syn::{ + parse::Parser, parse_macro_input, Data, DeriveInput, Expr, Fields, FnArg, ItemFn, Lit, + LitByteStr, Meta, ReturnType, Token, Type, +}; + +#[proc_macro_derive(TaskArg)] +pub fn derive_task_arg(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as DeriveInput); + let name = &input.ident; + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + let collect = + match &input.data { + Data::Struct(data) => collect_struct_fields(&data.fields), + Data::Enum(data) => { + let arms = + data.variants.iter().map(|variant| { + let variant_name = &variant.ident; + match &variant.fields { + Fields::Named(fields) => { + let field_names = fields + .named + .iter() + .map(|field| field.ident.as_ref().expect("named field")) + .collect::>(); + let bindings = field_names + .iter() + .map(|field| format_ident!("__disasmer_{field}")) + .collect::>(); + let visits = bindings.iter().map(|binding| quote! { + ::disasmer::__private::CollectTaskHandles::collect_task_handles( + #binding, + handles, + )?; + }); + quote! { + Self::#variant_name { #(#field_names: #bindings),* } => { + #(#visits)* + } + } + } + Fields::Unnamed(fields) => { + let bindings = (0..fields.unnamed.len()) + .map(|index| format_ident!("__disasmer_field_{index}")) + .collect::>(); + let visits = bindings.iter().map(|binding| quote! { + ::disasmer::__private::CollectTaskHandles::collect_task_handles( + #binding, + handles, + )?; + }); + quote! { + Self::#variant_name(#(#bindings),*) => { + #(#visits)* + } + } + } + Fields::Unit => quote! { Self::#variant_name => {} }, + } + }); + quote! { match self { #(#arms),* } } + } + Data::Union(union) => { + return syn::Error::new_spanned( + union.union_token, + "TaskArg cannot be derived for unions", + ) + .into_compile_error() + .into(); + } + }; + quote! { + impl #impl_generics ::disasmer::__private::CollectTaskHandles + for #name #type_generics #where_clause + { + fn collect_task_handles( + &self, + handles: &mut ::std::vec::Vec<::disasmer::core::TaskBoundaryHandle>, + ) -> ::std::result::Result<(), ::disasmer::TaskArgError> { + #collect + Ok(()) + } + } + + impl #impl_generics ::disasmer::TaskArg for #name #type_generics #where_clause { + fn task_arg_kind(&self) -> ::disasmer::TaskArgKind { + ::disasmer::TaskArgKind::Structured + } + + fn task_boundary_value( + &self, + ) -> ::std::result::Result< + ::disasmer::core::TaskBoundaryValue, + ::disasmer::TaskArgError, + > { + ::disasmer::__private::structured_task_boundary(self) + } + } + } + .into() +} + +fn collect_struct_fields(fields: &Fields) -> proc_macro2::TokenStream { + match fields { + Fields::Named(fields) => { + let visits = fields.named.iter().map(|field| { + let field = field.ident.as_ref().expect("named field"); + quote! { + ::disasmer::__private::CollectTaskHandles::collect_task_handles( + &self.#field, + handles, + )?; + } + }); + quote! { #(#visits)* } + } + Fields::Unnamed(fields) => { + let visits = fields.unnamed.iter().enumerate().map(|(index, _)| { + let index = syn::Index::from(index); + quote! { + ::disasmer::__private::CollectTaskHandles::collect_task_handles( + &self.#index, + handles, + )?; + } + }); + quote! { #(#visits)* } + } + Fields::Unit => quote! {}, + } +} + +#[proc_macro_attribute] +pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { + let function = parse_macro_input!(item as ItemFn); + if !function.sig.inputs.is_empty() { + return syn::Error::new_spanned( + &function.sig, + "#[disasmer::main] requires a function with no arguments", + ) + .into_compile_error() + .into(); + } + let function_name = function.sig.ident.to_string(); + let (entrypoint_name, _) = descriptor_options( + attr, + function_name + .strip_suffix("_main") + .unwrap_or(&function_name), + ); + let descriptor = format_ident!( + "__DISASMER_ENTRYPOINT_{}", + function_name.to_ascii_uppercase() + ); + let argument_schema = argument_schema(&function); + let result_schema = result_schema(&function); + let stable_id = stable_digest( + "disasmer-entrypoint-id:v1", + &[ + &entrypoint_name, + &function_name, + &argument_schema, + &result_schema, + ], + ); + let export_name = format!( + "disasmer_entry_v1_{}", + stable_id.trim_start_matches("sha256:") + ); + let export_wrapper = format_ident!( + "__disasmer_entry_export_{}", + function_name.to_ascii_lowercase() + ); + let export_invocation = invocation_helper(&function, true, quote! { #stable_id }); + let probe_symbol = format!("disasmer.probe.{function_name}"); + let manifest = serde_json::json!({ + "kind": "entrypoint", + "name": entrypoint_name, + "function": function_name, + "export": export_name, + "stable_id": stable_id, + "argument_schema": argument_schema, + "result_schema": result_schema, + "abi_version": 1, + "probe_symbol": probe_symbol, + }); + let manifest = manifest_record(manifest); + let manifest_static = format_ident!( + "__DISASMER_ENTRYPOINT_MANIFEST_{}", + function_name.to_ascii_uppercase() + ); + + quote! { + #function + + #[doc(hidden)] + pub const #descriptor: ::disasmer::EntrypointDescriptor = ::disasmer::EntrypointDescriptor { + name: #entrypoint_name, + function: #function_name, + export: #export_name, + stable_id: #stable_id, + argument_schema: #argument_schema, + result_schema: #result_schema, + abi_version: 1, + bundle_manifest_entry: true, + }; + + #[doc(hidden)] + #[used] + #[cfg_attr(target_arch = "wasm32", unsafe(link_section = "disasmer.entrypoints"))] + pub static #manifest_static: [u8; #manifest.len()] = *#manifest; + + #[doc(hidden)] + #[cfg(target_arch = "wasm32")] + #[unsafe(export_name = #export_name)] + pub extern "C" fn #export_wrapper(input_pointer: u32, input_length: u32) -> u64 { + ::disasmer::debug::probe(#probe_symbol) + .expect("Disasmer entrypoint debug probe host call failed"); + #export_invocation + } + } + .into() +} + +#[proc_macro_attribute] +pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream { + let function = parse_macro_input!(item as ItemFn); + if function.sig.inputs.len() > 1 { + return syn::Error::new_spanned( + &function.sig, + "#[disasmer::task] requires a function with zero or one owned argument", + ) + .into_compile_error() + .into(); + } + let function_name = function.sig.ident.to_string(); + let (task_name, required_capabilities) = descriptor_options(attr, &function_name); + let descriptor = format_ident!("__DISASMER_TASK_{}", function_name.to_ascii_uppercase()); + let argument_schema = argument_schema(&function); + let result_schema = result_schema(&function); + let capability_schema = required_capabilities.join(","); + let stable_id = stable_digest( + "disasmer-task-id:v1", + &[&task_name, &function_name, &argument_schema, &result_schema], + ); + let restart_compatibility_hash = stable_digest( + "disasmer-task-restart:v1", + &[ + &task_name, + &argument_schema, + &result_schema, + &capability_schema, + "abi:1", + ], + ); + let export_name = format!( + "disasmer_task_v1_{}", + stable_id.trim_start_matches("sha256:") + ); + let export_wrapper = format_ident!( + "__disasmer_task_export_{}", + function_name.to_ascii_lowercase() + ); + let export_invocation = invocation_helper(&function, false, quote! { #task_name }); + let probe_symbol = format!("disasmer.probe.{function_name}"); + let manifest = serde_json::json!({ + "kind": "task", + "name": task_name, + "function": function_name, + "export": export_name, + "stable_id": stable_id, + "argument_schema": argument_schema, + "result_schema": result_schema, + "required_capabilities": required_capabilities, + "restart_compatibility_hash": restart_compatibility_hash, + "abi_version": 1, + "probe_symbol": probe_symbol, + }); + let manifest = manifest_record(manifest); + let manifest_static = format_ident!( + "__DISASMER_TASK_MANIFEST_{}", + function_name.to_ascii_uppercase() + ); + + quote! { + #function + + #[doc(hidden)] + pub const #descriptor: ::disasmer::TaskDescriptor = ::disasmer::TaskDescriptor { + name: #task_name, + function: #function_name, + export: #export_name, + stable_id: #stable_id, + argument_schema: #argument_schema, + result_schema: #result_schema, + required_capabilities: &[#(#required_capabilities),*], + restart_compatibility_hash: #restart_compatibility_hash, + abi_version: 1, + source_file: file!(), + source_line: line!(), + probe_symbol: #probe_symbol, + bundle_manifest_entry: true, + remotely_startable: true, + }; + + #[doc(hidden)] + #[used] + #[cfg_attr(target_arch = "wasm32", unsafe(link_section = "disasmer.tasks"))] + pub static #manifest_static: [u8; #manifest.len()] = *#manifest; + + #[doc(hidden)] + #[cfg(target_arch = "wasm32")] + #[unsafe(export_name = #export_name)] + pub extern "C" fn #export_wrapper(input_pointer: u32, input_length: u32) -> u64 { + ::disasmer::debug::probe(#probe_symbol) + .expect("Disasmer task debug probe host call failed"); + #export_invocation + } + } + .into() +} + +fn invocation_helper( + function: &ItemFn, + is_entrypoint: bool, + expected_definition: proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let function_ident = &function.sig.ident; + let asynchronous = function.sig.asyncness.is_some(); + let result_return = returns_result(&function.sig.output); + let helper = match (asynchronous, result_return, function.sig.inputs.len()) { + (false, false, 0) => format_ident!("invoke_task0_v1"), + (false, true, 0) => format_ident!("invoke_task0_result_v1"), + (true, false, 0) => format_ident!("invoke_task0_async_v1"), + (true, true, 0) => format_ident!("invoke_task0_async_result_v1"), + (false, false, 1) => format_ident!("invoke_task1_v1"), + (false, true, 1) => format_ident!("invoke_task1_result_v1"), + (true, false, 1) => format_ident!("invoke_task1_async_v1"), + (true, true, 1) => format_ident!("invoke_task1_async_result_v1"), + _ => unreachable!("attribute input count was validated"), + }; + match function.sig.inputs.first() { + None => quote! { + ::disasmer::__private::#helper( + #expected_definition, + input_pointer, + input_length, + || #function_ident(), + ) + }, + Some(FnArg::Typed(argument)) if !is_entrypoint => { + let argument_type = &argument.ty; + quote! { + ::disasmer::__private::#helper( + #expected_definition, + input_pointer, + input_length, + |argument: #argument_type| #function_ident(argument), + ) + } + } + Some(FnArg::Typed(_)) => unreachable!("entrypoint arguments were rejected"), + Some(FnArg::Receiver(receiver)) => { + syn::Error::new_spanned(receiver, "#[disasmer::task] cannot be applied to a method") + .into_compile_error() + } + } +} + +fn returns_result(output: &ReturnType) -> bool { + let ReturnType::Type(_, output) = output else { + return false; + }; + let Type::Path(path) = output.as_ref() else { + return false; + }; + path.path + .segments + .last() + .is_some_and(|segment| segment.ident == "Result") +} + +fn descriptor_options(attr: TokenStream, default: &str) -> (String, Vec) { + if attr.is_empty() { + return (default.to_owned(), Vec::new()); + } + + let parser = syn::punctuated::Punctuated::::parse_terminated; + let Ok(args) = parser.parse(attr) else { + return (default.to_owned(), Vec::new()); + }; + + let mut name = default.to_owned(); + let mut capabilities = Vec::new(); + for meta in args { + let Meta::NameValue(name_value) = meta else { + continue; + }; + if let Expr::Lit(expr) = name_value.value { + if let Lit::Str(value) = expr.lit { + if name_value.path.is_ident("name") { + name = value.value(); + } else if name_value.path.is_ident("capabilities") { + capabilities = value + .value() + .split(',') + .map(str::trim) + .filter(|capability| !capability.is_empty()) + .map(str::to_owned) + .collect(); + } + } + } + } + + (name, capabilities) +} + +fn argument_schema(function: &ItemFn) -> String { + function + .sig + .inputs + .iter() + .map(ToTokens::to_token_stream) + .map(|tokens| tokens.to_string()) + .collect::>() + .join(", ") +} + +fn result_schema(function: &ItemFn) -> String { + match &function.sig.output { + ReturnType::Default => "()".to_owned(), + ReturnType::Type(_, result) => result.to_token_stream().to_string(), + } +} + +fn stable_digest(domain: &str, parts: &[&str]) -> String { + let mut digest = Sha256::new(); + digest.update(domain.as_bytes()); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part.as_bytes()); + } + format!("sha256:{}", hex::encode(digest.finalize())) +} + +fn manifest_record(value: serde_json::Value) -> LitByteStr { + let mut record = serde_json::to_vec(&value).expect("descriptor manifest serializes"); + record.push(b'\n'); + LitByteStr::new(&record, proc_macro2::Span::call_site()) +} diff --git a/crates/disasmer-node/Cargo.toml b/crates/disasmer-node/Cargo.toml new file mode 100644 index 0000000..f65c4d2 --- /dev/null +++ b/crates/disasmer-node/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "disasmer-node" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +base64.workspace = true +disasmer-core = { path = "../disasmer-core" } +disasmer-control = { path = "../disasmer-control" } +disasmer-wasm-runtime = { path = "../disasmer-wasm-runtime" } +libc.workspace = true +quinn.workspace = true +rcgen.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true +tokio.workspace = true +wasmparser.workspace = true diff --git a/crates/disasmer-node/src/assignment_runner.rs b/crates/disasmer-node/src/assignment_runner.rs new file mode 100644 index 0000000..3ec434f --- /dev/null +++ b/crates/disasmer-node/src/assignment_runner.rs @@ -0,0 +1,820 @@ +use std::collections::{BTreeSet, HashMap}; +use std::io::Read; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +use crate::coordinator_session::CoordinatorSession; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::{ + Capability, CommandInvocation, Digest, EnvironmentRequirements, NodeId, ProcessId, ProjectId, + TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskJoinResult, TaskJoinState, TaskSpec, + TenantId, VfsManifest, WasmExportAbi, WasmHostDebugProbeRequest, WasmHostDebugProbeResult, + WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskHandle, + WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest, + WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, WasmTaskInvocation, + WasmTaskOutcome, +}; +use disasmer_node::{ + BackendError, CommandOutput, LinuxRootlessPodmanBackend, LocalCheckoutTaskRequest, + LocalSourceCheckout, PodmanCommand, ProcessOutput, ProcessRunner, WasmDebugControl, + WasmTaskHost, WasmtimeTaskRuntime, +}; + +const MAX_CHILD_TASK_HANDLES: usize = 256; +use crate::daemon::{runtime_task_from_assignment, Args, RuntimeTask}; +use crate::node_identity::signed_node_request_json; +use crate::source_snapshot::snapshot_project; +use crate::task_artifacts::{ + retained_result_artifact, task_output_root, NodeArtifactStore, TaskArtifactStore, +}; + +mod process_runner; +use process_runner::CoordinatorControlledProcessRunner; +mod control_watcher; +use control_watcher::{spawn_task_control_watcher, spawn_worker_shutdown_watcher}; +mod validation; +use validation::{ + authorize_command_network, capability_from_descriptor, is_secret_environment_name, + redact_configured_values, require_command_environment, resolve_task_export, task_descriptors, + verify_environment_digest, verify_source_snapshot, +}; + +pub(crate) fn run_verified_wasmtime_assignment( + args: &Args, + task: &RuntimeTask, + node_private_key: &str, +) -> Result<(CommandOutput, VfsManifest, Option), Box> { + if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + eprintln!( + "disasmer debug control: starting Wasm assignment for task {}", + task.task + ); + } + let expected_bundle_digest = task.bundle_digest.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "wasmtime task assignment includes module bytes but is missing bundle_digest", + ) + })?; + let module_base64 = task + .wasm_module_base64 + .as_ref() + .expect("caller checked wasm_module_base64"); + let module = BASE64_STANDARD.decode(module_base64)?; + let task_spec = task.task_spec.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Wasm task assignment is missing its versioned TaskSpec", + ) + })?; + let (declared_export, abi) = match &task_spec.dispatch { + TaskDispatch::CoordinatorNodeWasm { export, abi } => (export.as_deref(), abi), + }; + let resolved_export; + let export = match declared_export { + Some(export) => export, + None if abi == &WasmExportAbi::TaskV1 => { + resolved_export = resolve_task_export(&module, task_spec.task_definition.as_str())?; + &resolved_export + } + None => { + return Err("Wasm entrypoint assignment omitted its descriptor export".into()); + } + }; + let (stdout, boundary_result) = match abi { + WasmExportAbi::EntrypointV1 | WasmExportAbi::TaskV1 => { + let invocation = WasmTaskInvocation::new( + task_spec.task_definition.clone(), + task_spec.task_instance.clone(), + task_spec.args.clone(), + ); + let result = WasmtimeTaskRuntime::new()?.run_task_export_verified_with_task_host( + &module, + expected_bundle_digest, + export, + &invocation, + Box::new(CoordinatorWasmTaskHost::new( + args, + task, + node_private_key, + &module, + )?), + )?; + if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + eprintln!( + "disasmer debug control: Wasm assignment returned for task {}", + task.task + ); + } + match result.outcome { + WasmTaskOutcome::Completed => { + let boundary = result.result.ok_or("completed Wasm task omitted result")?; + ( + format!("{}\n", serde_json::to_string(&boundary)?), + Some(boundary), + ) + } + WasmTaskOutcome::Failed => { + return Err(result + .error + .unwrap_or_else(|| "Wasm task failed without an error".to_owned()) + .into()) + } + } + } + }; + let task_id = TaskInstanceId::new(task.task.clone()); + let artifacts = TaskArtifactStore::new(task_id.clone(), NodeId::new(args.node.clone())); + let manifest = artifacts.flush(); + Ok(( + CommandOutput { + virtual_thread: task_id, + status_code: Some(0), + stdout, + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + log_backpressured: false, + staged_artifact: None, + }, + manifest, + boundary_result, + )) +} + +struct CoordinatorWasmTaskHost { + args: Args, + process: String, + parent_task: String, + epoch: u64, + bundle_digest: disasmer_core::Digest, + wasm_module_base64: String, + node_private_key: String, + allow_command: bool, + allow_network: bool, + allow_source_snapshot: bool, + environment_id: Option, + environment_digest: Option, + source_snapshot: Option, + output_root: PathBuf, + task_descriptors: HashMap, + next_handle_id: u64, + handles: Arc>>, + command_status: Arc>>, + cancellation_requested: Arc, + abort_requested: Arc, + debug_control: Arc, + stop_control_watcher: Arc, +} + +impl CoordinatorWasmTaskHost { + fn new( + args: &Args, + parent: &RuntimeTask, + node_private_key: &str, + module: &[u8], + ) -> Result> { + let task_spec = parent + .task_spec + .as_ref() + .ok_or("Wasm task host requires a parent TaskSpec")?; + let cancellation_requested = Arc::new(AtomicBool::new(false)); + let abort_requested = Arc::new(AtomicBool::new(false)); + let debug_control = Arc::new(WasmDebugControl::default()); + let stop_control_watcher = Arc::new(AtomicBool::new(false)); + let handles = Arc::new(Mutex::new(HashMap::new())); + let command_status = Arc::new(Mutex::new(None)); + let task_instance = TaskInstanceId::from(parent.task.as_str()); + let output_root = + task_output_root(args.project_root.as_deref(), &args.node, &task_instance)?; + let task_args = task_spec + .args + .iter() + .enumerate() + .map(|(index, value)| (format!("arg_{index}"), format!("{value:?}"))) + .collect(); + spawn_task_control_watcher( + args.clone(), + parent.process.clone(), + parent.task.clone(), + task_spec.task_definition.as_str().to_owned(), + node_private_key.to_owned(), + Arc::clone(&cancellation_requested), + Arc::clone(&abort_requested), + Arc::clone(&debug_control), + task_args, + Arc::clone(&handles), + Arc::clone(&command_status), + Arc::clone(&stop_control_watcher), + ); + spawn_worker_shutdown_watcher( + Arc::clone(&abort_requested), + Arc::clone(&debug_control), + Arc::clone(&stop_control_watcher), + ); + Ok(Self { + args: args.clone(), + process: parent.process.clone(), + parent_task: parent.task.clone(), + epoch: parent.epoch.unwrap_or(task_spec.vfs_epoch), + bundle_digest: parent + .bundle_digest + .clone() + .ok_or("Wasm task host requires a bundle digest")?, + wasm_module_base64: BASE64_STANDARD.encode(module), + node_private_key: node_private_key.to_owned(), + allow_command: task_spec + .required_capabilities + .contains(&Capability::Command) + && disasmer_core::NodeCapabilities::detect_current() + .capabilities + .contains(&Capability::Command), + allow_network: task_spec + .required_capabilities + .contains(&Capability::Network) + && disasmer_core::NodeCapabilities::detect_current() + .capabilities + .contains(&Capability::Network), + allow_source_snapshot: task_spec + .required_capabilities + .contains(&Capability::SourceFilesystem) + && disasmer_core::NodeCapabilities::detect_current() + .capabilities + .contains(&Capability::SourceFilesystem), + environment_id: task_spec.environment_id.clone(), + environment_digest: task_spec.environment_digest.clone(), + source_snapshot: task_spec.source_snapshot.clone(), + output_root, + // Wasmtime accepts both binary Wasm and WAT in development tests. Descriptor + // discovery is required only if the guest actually invokes task_start_v1; module + // compilation/digest verification remains authoritative for malformed input. + task_descriptors: task_descriptors(module).unwrap_or_default(), + next_handle_id: 1, + handles, + command_status, + cancellation_requested, + abort_requested, + debug_control, + stop_control_watcher, + }) + } + + fn session(&self) -> Result { + CoordinatorSession::connect(&self.args.coordinator).map_err(|error| error.to_string()) + } + + fn signed_request( + &self, + request_kind: &str, + payload: serde_json::Value, + ) -> Result { + signed_node_request_json(&self.args, &self.node_private_key, request_kind, payload) + .map_err(|error| error.to_string()) + } + + fn execute_local_assignment(&self, assignment: &serde_json::Value) -> Result<(), String> { + let runtime_task = + runtime_task_from_assignment(assignment).map_err(|error| error.to_string())?; + let execution = + run_verified_wasmtime_assignment(&self.args, &runtime_task, &self.node_private_key); + let (terminal_state, status_code, stdout, stderr, result, retained) = match execution { + Ok((output, _manifest, result)) => { + let retained = retained_result_artifact( + self.args.project_root.as_deref(), + &self.args.node, + result.as_ref(), + ); + match retained { + Ok(retained) => ( + "completed", + output.status_code, + output.stdout, + output.stderr, + result, + retained, + ), + Err(error) => ("failed", Some(1), String::new(), error, None, None), + } + } + Err(error) => ( + "failed", + Some(1), + String::new(), + error.to_string(), + None, + None, + ), + }; + let artifact_path = retained + .as_ref() + .map(|artifact| format!("/vfs/artifacts/{}", artifact.id)); + let artifact_digest = retained.as_ref().map(|artifact| artifact.digest.clone()); + let artifact_size_bytes = retained.as_ref().map(|artifact| artifact.size_bytes); + let mut session = self.session()?; + session + .request(self.signed_request( + "task_completed", + serde_json::json!({ + "type": "task_completed", + "tenant": self.args.tenant, + "project": self.args.project, + "process": runtime_task.process, + "node": self.args.node, + "task": runtime_task.task, + "terminal_state": terminal_state, + "status_code": status_code, + "stdout_bytes": stdout.len(), + "stderr_bytes": stderr.len(), + "stdout_tail": stdout, + "stderr_tail": stderr, + "stdout_truncated": false, + "stderr_truncated": false, + "artifact_path": artifact_path, + "artifact_digest": artifact_digest, + "artifact_size_bytes": artifact_size_bytes, + "result": result, + }), + )?) + .map_err(|error| error.to_string())?; + Ok(()) + } + + fn poll_and_execute_assignment(&self) -> Result { + let mut session = self.session()?; + let response = session + .request(self.signed_request( + "poll_task_assignment", + serde_json::json!({ + "type": "poll_task_assignment", + "tenant": self.args.tenant, + "project": self.args.project, + "node": self.args.node, + }), + )?) + .map_err(|error| error.to_string())?; + let Some(assignment) = response.get("assignment").filter(|value| !value.is_null()) else { + return Ok(false); + }; + self.execute_local_assignment(assignment)?; + Ok(true) + } +} + +impl Drop for CoordinatorWasmTaskHost { + fn drop(&mut self) { + self.stop_control_watcher.store(true, Ordering::Release); + if std::fs::symlink_metadata(&self.output_root) + .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink()) + .unwrap_or(false) + { + let _ = std::fs::remove_dir_all(&self.output_root); + } + } +} + +impl WasmTaskHost for CoordinatorWasmTaskHost { + fn abort_signal(&self) -> Option> { + Some(Arc::clone(&self.abort_requested)) + } + + fn debug_control(&self) -> Option> { + Some(Arc::clone(&self.debug_control)) + } + + fn start_task( + &mut self, + request: WasmHostTaskStartRequest, + ) -> Result { + request.validate()?; + let descriptor = self + .task_descriptors + .get(request.task_definition.as_str()) + .ok_or_else(|| { + format!( + "bundle has no task descriptor named `{}`", + request.task_definition + ) + })?; + let export = descriptor + .get("export") + .and_then(serde_json::Value::as_str) + .filter(|export| !export.trim().is_empty()) + .ok_or_else(|| { + format!( + "task `{}` descriptor omitted its Wasm export", + request.task_definition + ) + })?; + let mut required_capabilities = descriptor + .get("required_capabilities") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .map(|value| { + capability_from_descriptor( + value + .as_str() + .ok_or("task capability descriptor is not a string")?, + ) + }) + .collect::, _>>()?; + let resolved_environment = request.environment_id.as_deref().and_then(|name| { + self.args.project_root.as_ref().and_then(|root| { + disasmer_core::discover_environments(root) + .ok()? + .into_iter() + .find(|environment| environment.name == name) + }) + }); + let environment = resolved_environment + .as_ref() + .map(|environment| environment.requirements.clone()) + .or_else(|| match request.environment_id.as_deref() { + Some(environment) if environment.eq_ignore_ascii_case("windows") => { + Some(EnvironmentRequirements::windows_command_dev()) + } + Some(_) => Some(EnvironmentRequirements::linux_container()), + None => Some(EnvironmentRequirements::unconstrained()), + }); + let environment_digest = resolved_environment + .as_ref() + .map(|environment| environment.digest.clone()); + if let Some(environment) = &environment { + required_capabilities.extend(environment.capabilities.iter().cloned()); + } + let handle_id = self.next_handle_id; + let task_instance = + disasmer_core::TaskInstanceId::new(format!("{}:child:{}", self.parent_task, handle_id)); + let required_artifacts = request + .args + .iter() + .flat_map(TaskBoundaryValue::required_artifacts) + .collect::>() + .into_iter() + .collect::>(); + let source_snapshots = request + .args + .iter() + .flat_map(TaskBoundaryValue::source_snapshots) + .collect::>(); + if source_snapshots.len() > 1 { + return Err( + "one child task invocation cannot require multiple distinct source snapshots" + .to_owned(), + ); + } + if self + .handles + .lock() + .map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())? + .len() + >= MAX_CHILD_TASK_HANDLES + { + return Err(format!( + "Wasm child task-handle limit of {MAX_CHILD_TASK_HANDLES} reached" + )); + } + let source_snapshot = source_snapshots.into_iter().next(); + let spec = TaskSpec { + tenant: TenantId::from(self.args.tenant.as_str()), + project: ProjectId::from(self.args.project.as_str()), + process: ProcessId::from(self.process.as_str()), + task_definition: request.task_definition.clone(), + task_instance: task_instance.clone(), + dispatch: TaskDispatch::CoordinatorNodeWasm { + export: Some(export.to_owned()), + abi: WasmExportAbi::TaskV1, + }, + environment_id: request.environment_id, + environment, + environment_digest, + required_capabilities, + dependency_cache: None, + source_snapshot, + required_artifacts, + args: request.args, + vfs_epoch: self.epoch, + bundle_digest: Some(self.bundle_digest.clone()), + }; + let mut session = self.session()?; + let response = session + .request(self.signed_request( + "launch_child_task", + serde_json::json!({ + "type": "launch_child_task", + "tenant": self.args.tenant, + "project": self.args.project, + "process": self.process, + "node": self.args.node, + "parent_task": self.parent_task, + "task_spec": spec, + "wait_for_node": true, + "artifact_path": format!("/vfs/artifacts/{}-result.json", spec.task_instance), + "wasm_module_base64": self.wasm_module_base64, + }), + )?) + .map_err(|error| error.to_string())?; + match response.get("type").and_then(serde_json::Value::as_str) { + Some("task_launched" | "task_queued") => {} + other => return Err(format!("unexpected child launch response {other:?}")), + } + self.next_handle_id = self.next_handle_id.saturating_add(1); + self.handles + .lock() + .map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())? + .insert(handle_id, spec.clone()); + Ok(WasmHostTaskHandle { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + handle_id, + task_spec: spec, + }) + } + + fn join_task( + &mut self, + request: WasmHostTaskJoinRequest, + ) -> Result { + if request.abi_version != disasmer_core::WASM_TASK_ABI_VERSION { + return Err(format!( + "unsupported Wasm task ABI version {}", + request.abi_version + )); + } + let spec = self + .handles + .lock() + .map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())? + .get(&request.handle_id) + .cloned() + .ok_or_else(|| format!("unknown Wasm task handle {}", request.handle_id))?; + let started = Instant::now(); + loop { + let mut session = self.session()?; + let response = session + .request(self.signed_request( + "join_child_task", + serde_json::json!({ + "type": "join_child_task", + "tenant": self.args.tenant, + "project": self.args.project, + "process": self.process, + "node": self.args.node, + "parent_task": self.parent_task, + "task": spec.task_instance, + }), + )?) + .map_err(|error| error.to_string())?; + let join: TaskJoinResult = serde_json::from_value( + response + .get("join") + .cloned() + .ok_or("child task join response omitted join state")?, + ) + .map_err(|error| error.to_string())?; + match join.state { + TaskJoinState::Completed => { + let result = join + .result + .ok_or("completed child task omitted its boundary result")?; + self.handles + .lock() + .map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())? + .remove(&request.handle_id); + return Ok(WasmHostTaskJoinResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + task_instance: spec.task_instance, + result, + }); + } + TaskJoinState::Failed | TaskJoinState::Cancelled => { + self.handles + .lock() + .map_err(|_| "Wasm task handle registry lock was poisoned".to_owned())? + .remove(&request.handle_id); + return Err(join.message); + } + TaskJoinState::Pending => { + if started.elapsed() > Duration::from_secs(120) { + return Err(format!( + "timed out waiting for child task {}", + spec.task_instance + )); + } + if !self.poll_and_execute_assignment()? { + thread::sleep(Duration::from_millis(10)); + } + } + } + } + } + + fn run_command( + &mut self, + request: disasmer_core::WasmHostCommandRequest, + ) -> Result { + request.validate()?; + if !self.allow_command { + return Err( + "Wasm task did not declare Command capability or the selected node did not grant it" + .to_owned(), + ); + } + authorize_command_network(&request.network, self.allow_network)?; + let environment_id = require_command_environment(self.environment_id.as_deref())?; + let project_root = self.args.project_root.as_ref().ok_or_else(|| { + format!( + "task selected environment `{environment_id}`, but this node has no project checkout or prepared source snapshot; attach it with --project-root" + ) + })?; + let expected_snapshot = self.source_snapshot.as_ref().ok_or_else(|| { + "native command task is missing an explicit SourceSnapshot handle".to_owned() + })?; + let actual_snapshot = verify_source_snapshot(project_root, expected_snapshot)?; + let expected_environment_digest = self.environment_digest.as_ref().ok_or_else(|| { + format!( + "task selected environment `{environment_id}` without a bundle-authoritative environment digest" + ) + })?; + let environment = + verify_environment_digest(project_root, environment_id, expected_environment_digest)?; + let checkout = project_root + .canonicalize() + .map_err(|error| format!("resolve node project checkout: {error}"))?; + let task_id = TaskInstanceId::from(self.parent_task.as_str()); + let mut artifacts = + TaskArtifactStore::new(task_id.clone(), NodeId::from(self.args.node.as_str())); + let configured_secrets = request + .environment_variables + .iter() + .filter(|(name, value)| is_secret_environment_name(name) && !value.is_empty()) + .map(|(_, value)| value.clone()) + .collect::>(); + let mut runner = CoordinatorControlledProcessRunner::new( + self, + Duration::from_millis(request.timeout_ms), + ); + let output = LinuxRootlessPodmanBackend + .execute_local_checkout_task( + LocalCheckoutTaskRequest { + process: ProcessId::from(self.process.as_str()), + virtual_thread: task_id, + invocation: &CommandInvocation { + program: request.program, + args: request.args, + working_directory: request.working_directory, + environment_variables: request.environment_variables, + timeout_ms: request.timeout_ms, + network: request.network, + env: Some(environment), + }, + checkout: LocalSourceCheckout { + snapshot: actual_snapshot.digest, + host_path: checkout, + }, + output_root: self.output_root.clone(), + stage_stdout_as: None, + }, + &mut runner, + artifacts.overlay_mut(), + ) + .map_err(|error| { + if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + eprintln!("disasmer command host failed: {error}"); + } + error.to_string() + })?; + let stdout = redact_configured_values(output.stdout, &configured_secrets); + let stderr = redact_configured_values(output.stderr, &configured_secrets); + if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + eprintln!( + "disasmer command host completed: status={:?} stdout={:?} stderr={:?}", + output.status_code, stdout, stderr + ); + } + return Ok(disasmer_core::WasmHostCommandResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + status_code: output.status_code, + stdout, + stderr, + stdout_truncated: output.stdout_truncated, + stderr_truncated: output.stderr_truncated, + }); + } + + fn poll_task_control( + &mut self, + request: disasmer_core::WasmHostTaskControlRequest, + ) -> Result { + request.validate()?; + Ok(disasmer_core::WasmHostTaskControlResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + cancellation_requested: self.cancellation_requested.load(Ordering::Acquire), + }) + } + + fn debug_probe( + &mut self, + request: WasmHostDebugProbeRequest, + ) -> Result { + request.validate()?; + let mut session = self.session()?; + let response = session + .request(self.signed_request( + "report_debug_probe_hit", + serde_json::json!({ + "type": "report_debug_probe_hit", + "tenant": self.args.tenant, + "project": self.args.project, + "process": self.process, + "node": self.args.node, + "task": self.parent_task, + "probe_symbol": request.symbol, + }), + )?) + .map_err(|error| error.to_string())?; + if response.get("type").and_then(serde_json::Value::as_str) != Some("debug_probe_hit") { + return Err(format!( + "coordinator returned invalid debug probe response: {response}" + )); + } + let result = WasmHostDebugProbeResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + breakpoint_matched: response + .get("breakpoint_matched") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + debug_epoch: response + .get("debug_epoch") + .and_then(serde_json::Value::as_u64), + }; + if result.breakpoint_matched { + if let Some(epoch) = result.debug_epoch { + self.debug_control.request_freeze(epoch); + } + } + Ok(result) + } + + fn vfs_operation(&mut self, request: WasmHostVfsRequest) -> Result { + request.validate()?; + let store = + NodeArtifactStore::for_runtime(self.args.project_root.as_deref(), &self.args.node)?; + let (retained, relative_path) = match request.operation { + WasmHostVfsOperation::FlushOutput { relative_path } => { + let retained = store.retain_output_file(&self.output_root, &relative_path)?; + (retained, relative_path) + } + WasmHostVfsOperation::MaterializeArtifact { + artifact, + relative_path, + } => { + store.materialize_into_output(&artifact, &self.output_root, &relative_path)?; + let retained = store + .metadata(&artifact.id)? + .ok_or_else(|| format!("artifact `{}` became unavailable", artifact.id))?; + (retained, relative_path) + } + }; + Ok(WasmHostVfsResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + artifact: disasmer_core::ArtifactHandle { + id: retained.id, + digest: retained.digest, + size_bytes: retained.size_bytes, + }, + relative_path, + }) + } + + fn snapshot_source( + &mut self, + request: WasmHostSourceSnapshotRequest, + ) -> Result { + request.validate()?; + if !self.allow_source_snapshot { + return Err( + "Wasm task did not declare SourceFilesystem capability or the selected node did not grant it" + .to_owned(), + ); + } + let project_root = self.args.project_root.as_deref().ok_or_else(|| { + "source snapshot task requires a node project checkout; attach it with --project-root" + .to_owned() + })?; + let snapshot = snapshot_project(project_root)?; + Ok(WasmHostSourceSnapshotResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + snapshot: snapshot.digest, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/disasmer-node/src/assignment_runner/control_watcher.rs b/crates/disasmer-node/src/assignment_runner/control_watcher.rs new file mode 100644 index 0000000..3503006 --- /dev/null +++ b/crates/disasmer-node/src/assignment_runner/control_watcher.rs @@ -0,0 +1,213 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use disasmer_core::TaskSpec; +use disasmer_node::WasmDebugControl; + +use crate::coordinator_session::CoordinatorSession; +use crate::daemon::{worker_shutdown_requested, Args}; +use crate::node_identity::signed_node_request_json; + +fn debug_handle_snapshot(handles: &Arc>>) -> Vec<(String, String)> { + let Ok(handles) = handles.lock() else { + return vec![( + "handle-registry-diagnostic".to_owned(), + "runtime task handle registry was unavailable".to_owned(), + )]; + }; + let mut snapshot = handles + .iter() + .map(|(handle_id, spec)| { + ( + format!("task_handle_{handle_id}"), + format!( + "definition={} instance={} state=active", + spec.task_definition, spec.task_instance + ), + ) + }) + .collect::>(); + snapshot.sort_by(|left, right| left.0.cmp(&right.0)); + snapshot +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn spawn_task_control_watcher( + args: Args, + process: String, + task: String, + task_definition: String, + node_private_key: String, + cancellation_requested: Arc, + abort_requested: Arc, + debug_control: Arc, + task_args: Vec<(String, String)>, + handles: Arc>>, + command_status: Arc>>, + stop: Arc, +) { + thread::spawn(move || { + let Ok(mut session) = CoordinatorSession::connect(&args.coordinator) else { + return; + }; + while !stop.load(Ordering::Acquire) { + let request = signed_node_request_json( + &args, + &node_private_key, + "poll_task_control", + serde_json::json!({ + "type": "poll_task_control", + "tenant": &args.tenant, + "project": &args.project, + "process": &process, + "node": &args.node, + "task": &task, + }), + ); + let Ok(request) = request else { + return; + }; + let Ok(response) = session.request(request) else { + return; + }; + cancellation_requested.store( + response + .get("cancel_requested") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + Ordering::Release, + ); + if response + .get("abort_requested") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + abort_requested.store(true, Ordering::Release); + return; + } + let debug_request = signed_node_request_json( + &args, + &node_private_key, + "poll_debug_command", + serde_json::json!({ + "type": "poll_debug_command", + "tenant": &args.tenant, + "project": &args.project, + "process": &process, + "node": &args.node, + "task": &task, + }), + ); + let Ok(debug_request) = debug_request else { + return; + }; + let Ok(debug_response) = session.request(debug_request) else { + return; + }; + if let (Some(epoch), Some(command)) = ( + debug_response + .get("epoch") + .and_then(serde_json::Value::as_u64), + debug_response + .get("command") + .and_then(serde_json::Value::as_str), + ) { + let (state, message) = match command { + "freeze" => { + if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + eprintln!( + "disasmer debug control: node received freeze for epoch {epoch} task {task} (debug={:?})", + Arc::as_ptr(&debug_control) + ); + } + debug_control.request_freeze(epoch); + if debug_control.wait_until_frozen(epoch, Duration::from_secs(60)) { + ("frozen", None) + } else { + debug_control.request_resume(epoch); + ( + "failed", + Some( + "node execution did not reach a freezeable Wasm safepoint or native process-group boundary within 60 seconds" + .to_owned(), + ), + ) + } + } + "resume" => { + debug_control.request_resume(epoch); + if debug_control.wait_until_running(epoch, Duration::from_secs(60)) { + ("running", None) + } else { + ( + "failed", + Some( + "node execution did not leave frozen state within 60 seconds" + .to_owned(), + ), + ) + } + } + _ => ( + "failed", + Some(format!("node received unknown debug command `{command}`")), + ), + }; + let report = signed_node_request_json( + &args, + &node_private_key, + "report_debug_state", + serde_json::json!({ + "type": "report_debug_state", + "tenant": &args.tenant, + "project": &args.project, + "process": &process, + "node": &args.node, + "task": &task, + "epoch": epoch, + "state": state, + "stack_frames": [format!("{}::wasm", task_definition)], + "local_values": [], + "task_args": &task_args, + "handles": debug_handle_snapshot(&handles), + "command_status": command_status + .lock() + .ok() + .and_then(|status| status.clone()), + "recent_output": [], + "message": message, + }), + ); + let Ok(report) = report else { + return; + }; + if session.request(report).is_err() { + return; + } + } + thread::sleep(Duration::from_millis(50)); + } + }); +} + +pub(super) fn spawn_worker_shutdown_watcher( + abort_requested: Arc, + debug_control: Arc, + stop: Arc, +) { + thread::spawn(move || { + while !stop.load(Ordering::Acquire) { + if worker_shutdown_requested() { + abort_requested.store(true, Ordering::Release); + if let Some(epoch) = debug_control.requested_epoch() { + debug_control.request_resume(epoch); + } + return; + } + thread::sleep(Duration::from_millis(10)); + } + }); +} diff --git a/crates/disasmer-node/src/assignment_runner/process_runner.rs b/crates/disasmer-node/src/assignment_runner/process_runner.rs new file mode 100644 index 0000000..358937b --- /dev/null +++ b/crates/disasmer-node/src/assignment_runner/process_runner.rs @@ -0,0 +1,265 @@ +use super::*; + +pub(super) struct CoordinatorControlledProcessRunner { + pub(super) args: Args, + pub(super) process: String, + pub(super) task: String, + pub(super) node_private_key: String, + pub(super) debug_control: Arc, + pub(super) command_status: Arc>>, + pub(super) timeout: Duration, +} + +impl CoordinatorControlledProcessRunner { + const MAX_CAPTURE_BYTES: usize = 256 * 1024 + 1; + + pub(super) fn new(host: &CoordinatorWasmTaskHost, timeout: Duration) -> Self { + Self { + args: host.args.clone(), + process: host.process.clone(), + task: host.parent_task.clone(), + node_private_key: host.node_private_key.clone(), + debug_control: Arc::clone(&host.debug_control), + command_status: Arc::clone(&host.command_status), + timeout, + } + } + + fn set_command_status(&self, status: impl Into) { + if let Ok(mut current) = self.command_status.lock() { + *current = Some(status.into()); + } + } + + fn abort_requested(&self, session: &mut CoordinatorSession) -> Result { + let request = signed_node_request_json( + &self.args, + &self.node_private_key, + "poll_task_control", + serde_json::json!({ + "type": "poll_task_control", + "tenant": &self.args.tenant, + "project": &self.args.project, + "process": &self.process, + "node": &self.args.node, + "task": &self.task, + }), + ) + .map_err(|error| BackendError::Command(error.to_string()))?; + let response = session + .request(request) + .map_err(|error| BackendError::Command(format!("poll task control: {error}")))?; + Ok(response + .get("abort_requested") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false)) + } + + fn terminate_process_group(child: &mut std::process::Child) { + #[cfg(unix)] + { + let process_group = -(child.id() as i32); + // The child is placed in a new process group before exec, so this reaches + // descendants such as rootless Podman and the command inside its container. + unsafe { + libc::kill(process_group, libc::SIGKILL); + } + } + let _ = child.kill(); + let _ = child.wait(); + } + + #[cfg(unix)] + fn freeze_process_group(child: &std::process::Child) -> Result<(), BackendError> { + let process_group = -(child.id() as i32); + let result = unsafe { libc::kill(process_group, libc::SIGSTOP) }; + if result == 0 { + Ok(()) + } else { + Err(BackendError::Command(format!( + "failed to freeze native process group {}: {}", + child.id(), + std::io::Error::last_os_error() + ))) + } + } + + #[cfg(unix)] + fn resume_process_group(child: &std::process::Child) -> Result<(), BackendError> { + let process_group = -(child.id() as i32); + let result = unsafe { libc::kill(process_group, libc::SIGCONT) }; + if result == 0 { + Ok(()) + } else { + Err(BackendError::Command(format!( + "failed to resume native process group {}: {}", + child.id(), + std::io::Error::last_os_error() + ))) + } + } + + fn drain_bounded( + mut reader: impl Read + Send + 'static, + maximum: usize, + ) -> thread::JoinHandle, String>> { + thread::spawn(move || { + let mut captured = Vec::new(); + let mut buffer = [0_u8; 16 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|error| error.to_string())?; + if count == 0 { + break; + } + let remaining = maximum.saturating_sub(captured.len()); + captured.extend_from_slice(&buffer[..count.min(remaining)]); + } + Ok(captured) + }) + } +} + +impl ProcessRunner for CoordinatorControlledProcessRunner { + fn run(&mut self, command: &PodmanCommand) -> Result { + self.set_command_status(format!( + "starting native command: {} {}", + command.program, + command.args.join(" ") + )); + let mut process = std::process::Command::new(&command.program); + process + .args(&command.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + process.process_group(0); + let mut child = process + .spawn() + .map_err(|error| BackendError::Command(error.to_string()))?; + self.set_command_status(format!( + "running native command pid {}: {} {}", + child.id(), + command.program, + command.args.join(" ") + )); + let stdout = Self::drain_bounded( + child + .stdout + .take() + .ok_or_else(|| BackendError::Command("command stdout pipe missing".to_owned()))?, + Self::MAX_CAPTURE_BYTES, + ); + let stderr = Self::drain_bounded( + child + .stderr + .take() + .ok_or_else(|| BackendError::Command("command stderr pipe missing".to_owned()))?, + Self::MAX_CAPTURE_BYTES, + ); + let mut session = match CoordinatorSession::connect(&self.args.coordinator) { + Ok(session) => session, + Err(error) => { + Self::terminate_process_group(&mut child); + return Err(BackendError::Command(format!( + "establish execution control channel: {error}" + ))); + } + }; + + let mut native_frozen_epoch = None; + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => {} + Err(error) => { + Self::terminate_process_group(&mut child); + return Err(BackendError::Command(error.to_string())); + } + } + if started.elapsed() >= self.timeout { + self.set_command_status(format!( + "native command pid {} exceeded wall-clock timeout of {} ms", + child.id(), + self.timeout.as_millis() + )); + Self::terminate_process_group(&mut child); + let _ = stdout.join(); + let _ = stderr.join(); + return Err(BackendError::Command(format!( + "native command exceeded wall-clock timeout of {} ms", + self.timeout.as_millis() + ))); + } + match self.abort_requested(&mut session) { + Ok(true) => { + self.set_command_status(format!( + "aborting native command pid {} at coordinator request", + child.id() + )); + Self::terminate_process_group(&mut child); + let _ = stdout.join(); + let _ = stderr.join(); + return Err(BackendError::Cancelled( + "coordinator requested cancellation or abort".to_owned(), + )); + } + Ok(false) => {} + Err(error) => { + Self::terminate_process_group(&mut child); + let _ = stdout.join(); + let _ = stderr.join(); + return Err(error); + } + } + if let Some(epoch) = self.debug_control.requested_epoch() { + if self.debug_control.resume_requested(epoch) { + if native_frozen_epoch == Some(epoch) { + #[cfg(unix)] + Self::resume_process_group(&child)?; + self.set_command_status(format!( + "running native command pid {} after debug epoch {epoch} resumed", + child.id() + )); + self.debug_control.mark_running(epoch); + native_frozen_epoch = None; + } + } else if native_frozen_epoch != Some(epoch) + && self.debug_control.frozen_epoch() != Some(epoch) + { + #[cfg(unix)] + { + Self::freeze_process_group(&child)?; + self.set_command_status(format!( + "frozen native command pid {} for debug epoch {epoch}", + child.id() + )); + self.debug_control.mark_frozen(epoch); + native_frozen_epoch = Some(epoch); + } + } + } + thread::sleep(Duration::from_millis(50)); + }; + let stdout = stdout + .join() + .map_err(|_| BackendError::Command("stdout reader panicked".to_owned()))? + .map_err(BackendError::Command)?; + let stderr = stderr + .join() + .map_err(|_| BackendError::Command("stderr reader panicked".to_owned()))? + .map_err(BackendError::Command)?; + self.set_command_status(format!( + "native command exited with status {:?}", + status.code() + )); + Ok(ProcessOutput { + status_code: status.code(), + stdout, + stderr, + }) + } +} diff --git a/crates/disasmer-node/src/assignment_runner/tests.rs b/crates/disasmer-node/src/assignment_runner/tests.rs new file mode 100644 index 0000000..4593342 --- /dev/null +++ b/crates/disasmer-node/src/assignment_runner/tests.rs @@ -0,0 +1,205 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::time::{Duration, Instant}; + +use super::*; + +fn start_running_control_server() -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let coordinator = listener.local_addr().unwrap().to_string(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut request = String::new(); + match reader.read_line(&mut request) { + Ok(0) | Err(_) => break, + Ok(_) => stream + .write_all(b"{\"type\":\"task_control\",\"process\":\"vp\",\"task\":\"task\",\"cancel_requested\":false,\"abort_requested\":false}\n") + .unwrap(), + } + } + }); + (coordinator, server) +} + +fn test_controlled_runner( + coordinator: String, + timeout: Duration, +) -> CoordinatorControlledProcessRunner { + CoordinatorControlledProcessRunner { + args: Args { + coordinator, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + project_root: None, + node: "node".to_owned(), + enrollment_grant: None, + public_key: None, + control_poll_ms: 0, + assignment_poll_ms: 1, + emit_ready: false, + worker: true, + }, + process: "vp".to_owned(), + task: "task".to_owned(), + node_private_key: disasmer_core::derive_ed25519_private_key_from_seed( + "controlled-runner-test", + ), + debug_control: Arc::new(WasmDebugControl::default()), + command_status: Arc::new(Mutex::new(None)), + timeout, + } +} + +#[cfg(unix)] +#[test] +fn controlled_process_runner_kills_running_group_when_abort_is_polled() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let coordinator = listener.local_addr().unwrap().to_string(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut request) + .unwrap(); + assert!(!request.trim().is_empty()); + stream + .write_all(b"{\"type\":\"task_control\",\"process\":\"vp\",\"task\":\"task\",\"cancel_requested\":false,\"abort_requested\":true}\n") + .unwrap(); + }); + let mut runner = CoordinatorControlledProcessRunner { + args: Args { + coordinator, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + project_root: None, + node: "node".to_owned(), + enrollment_grant: None, + public_key: None, + control_poll_ms: 0, + assignment_poll_ms: 1, + emit_ready: false, + worker: true, + }, + process: "vp".to_owned(), + task: "task".to_owned(), + node_private_key: disasmer_core::derive_ed25519_private_key_from_seed( + "controlled-runner-test", + ), + debug_control: Arc::new(WasmDebugControl::default()), + command_status: Arc::new(Mutex::new(None)), + timeout: Duration::from_secs(30), + }; + let started = Instant::now(); + let error = runner + .run(&PodmanCommand { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "sleep 30 & wait".to_owned()], + }) + .unwrap_err(); + server.join().unwrap(); + + assert!(matches!(error, BackendError::Cancelled(_))); + assert!(started.elapsed() < Duration::from_secs(5)); +} + +#[cfg(unix)] +#[test] +fn command_timeout_is_bounded_and_a_later_command_still_runs() { + let (coordinator, server) = start_running_control_server(); + let mut runner = test_controlled_runner(coordinator, Duration::from_millis(120)); + let started = Instant::now(); + let error = runner + .run(&PodmanCommand { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "sleep 30 & wait".to_owned()], + }) + .unwrap_err(); + assert!(error.to_string().contains("timeout")); + assert!(started.elapsed() < Duration::from_secs(5)); + drop(runner); + server.join().unwrap(); + + let (coordinator, server) = start_running_control_server(); + let mut runner = test_controlled_runner(coordinator, Duration::from_secs(5)); + let output = runner + .run(&PodmanCommand { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "printf healthy".to_owned()], + }) + .unwrap(); + assert_eq!(output.status_code, Some(0)); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "healthy"); + drop(runner); + server.join().unwrap(); +} + +#[test] +fn command_source_verification_rejects_a_changed_checkout() { + let checkout = tempfile::tempdir().unwrap(); + std::fs::write(checkout.path().join("source.c"), "int value = 1;\n").unwrap(); + let expected = snapshot_project(checkout.path()).unwrap().digest; + verify_source_snapshot(checkout.path(), &expected).unwrap(); + + std::fs::write(checkout.path().join("source.c"), "int value = 2;\n").unwrap(); + let error = verify_source_snapshot(checkout.path(), &expected).unwrap_err(); + + assert!(error.contains("source snapshot mismatch")); + assert!(error.contains(expected.as_str())); +} + +#[test] +fn command_environment_verification_rejects_a_changed_recipe() { + let checkout = tempfile::tempdir().unwrap(); + let environment = checkout.path().join("envs/linux"); + std::fs::create_dir_all(&environment).unwrap(); + std::fs::write( + environment.join("Containerfile"), + "FROM docker.io/library/alpine:3.20\n", + ) + .unwrap(); + let expected = disasmer_core::discover_environments(checkout.path()) + .unwrap() + .into_iter() + .find(|environment| environment.name == "linux") + .unwrap() + .digest; + verify_environment_digest(checkout.path(), "linux", &expected).unwrap(); + + std::fs::write( + environment.join("Containerfile"), + "FROM docker.io/library/alpine:3.21\n", + ) + .unwrap(); + let error = verify_environment_digest(checkout.path(), "linux", &expected).unwrap_err(); + + assert!(error.contains("does not match the bundle")); + assert!(error.contains(expected.as_str())); +} + +#[test] +fn configured_secret_values_are_redacted_before_guest_or_coordinator_logging() { + assert!(is_secret_environment_name("API_TOKEN")); + assert!(is_secret_environment_name("database_password")); + assert!(!is_secret_environment_name("SOURCE_DATE_EPOCH")); + assert_eq!( + redact_configured_values( + "token=correct-horse and again correct-horse".to_owned(), + &["correct-horse".to_owned()], + ), + "token=[REDACTED] and again [REDACTED]" + ); +} + +#[test] +fn native_command_requires_environment_and_network_grant() { + let error = require_command_environment(None).unwrap_err(); + assert!(error.contains("explicit command-capable environment")); + assert_eq!(require_command_environment(Some("linux")).unwrap(), "linux"); + + authorize_command_network(&disasmer_core::CommandNetworkPolicy::Disabled, false).unwrap(); + let error = authorize_command_network(&disasmer_core::CommandNetworkPolicy::Enabled, false) + .unwrap_err(); + assert!(error.contains("Network capability")); +} diff --git a/crates/disasmer-node/src/assignment_runner/validation.rs b/crates/disasmer-node/src/assignment_runner/validation.rs new file mode 100644 index 0000000..aec1c78 --- /dev/null +++ b/crates/disasmer-node/src/assignment_runner/validation.rs @@ -0,0 +1,164 @@ +use std::collections::HashMap; +use std::path::Path; + +use disasmer_core::{Capability, Digest}; +use wasmparser::{Parser, Payload}; + +use crate::source_snapshot::{snapshot_project, SourceSnapshotInventory}; + +pub(super) fn verify_source_snapshot( + project_root: &Path, + expected: &Digest, +) -> Result { + let actual = snapshot_project(project_root)?; + if &actual.digest != expected { + return Err(format!( + "node checkout source snapshot mismatch: task requires {expected}, but the current checkout is {}", + actual.digest + )); + } + Ok(actual) +} + +pub(super) fn authorize_command_network( + policy: &disasmer_core::CommandNetworkPolicy, + allow_network: bool, +) -> Result<(), String> { + if policy == &disasmer_core::CommandNetworkPolicy::Enabled && !allow_network { + return Err( + "Wasm task requested network access without an explicit granted Network capability" + .to_owned(), + ); + } + Ok(()) +} + +pub(super) fn require_command_environment(environment_id: Option<&str>) -> Result<&str, String> { + environment_id.ok_or_else(|| { + "native commands require an explicit command-capable environment; attach one with .env(disasmer::env!(\"name\"))" + .to_owned() + }) +} + +pub(super) fn verify_environment_digest( + project_root: &Path, + environment_id: &str, + expected: &Digest, +) -> Result { + let environment = disasmer_core::discover_environments(project_root) + .map_err(|error| error.to_string())? + .into_iter() + .find(|environment| environment.name == environment_id) + .ok_or_else(|| { + format!( + "node checkout has no environment `{environment_id}` under {}/envs", + project_root.display() + ) + })?; + if &environment.digest != expected { + return Err(format!( + "node environment `{environment_id}` does not match the bundle: task requires {expected}, but the current recipe is {}", + environment.digest + )); + } + Ok(environment) +} + +pub(super) fn is_secret_environment_name(name: &str) -> bool { + let name = name.to_ascii_uppercase(); + ["SECRET", "TOKEN", "PASSWORD", "CREDENTIAL", "PRIVATE_KEY"] + .iter() + .any(|marker| name.contains(marker)) +} + +pub(super) fn redact_configured_values(mut text: String, values: &[String]) -> String { + for value in values.iter().filter(|value| value.len() >= 4) { + text = text.replace(value, "[REDACTED]"); + } + text +} + +pub(super) fn capability_from_descriptor(value: &str) -> Result { + match value.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "command" => Ok(Capability::Command), + "containers" => Ok(Capability::Containers), + "rootless_podman" => Ok(Capability::RootlessPodman), + "source_filesystem" => Ok(Capability::SourceFilesystem), + "source_git" => Ok(Capability::SourceGit), + "host_filesystem" => Ok(Capability::HostFilesystem), + "network" => Ok(Capability::Network), + "secrets" => Ok(Capability::Secrets), + "inbound_ports" => Ok(Capability::InboundPorts), + "arbitrary_syscalls" => Ok(Capability::ArbitrarySyscalls), + "vfs_artifacts" => Ok(Capability::VfsArtifacts), + "windows_command_dev" => Ok(Capability::WindowsCommandDev), + "quic_direct" => Ok(Capability::QuicDirect), + other => Err(format!("unknown task capability `{other}`")), + } +} + +pub(super) fn task_descriptors( + module: &[u8], +) -> Result, Box> { + let mut descriptors = HashMap::new(); + for payload in Parser::new(0).parse_all(module) { + let Payload::CustomSection(section) = payload? else { + continue; + }; + if section.name() != "disasmer.tasks" { + continue; + } + for record in section + .data() + .split(|byte| *byte == b'\n' || *byte == 0) + .filter(|record| !record.is_empty()) + { + let descriptor: serde_json::Value = serde_json::from_slice(record)?; + let name = descriptor + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or("task descriptor omitted name")? + .to_owned(); + descriptors.insert(name, descriptor); + } + } + Ok(descriptors) +} + +pub(super) fn resolve_task_export( + module: &[u8], + task: &str, +) -> Result> { + for payload in Parser::new(0).parse_all(module) { + let Payload::CustomSection(section) = payload? else { + continue; + }; + if section.name() != "disasmer.tasks" { + continue; + } + for record in section + .data() + .split(|byte| *byte == b'\n' || *byte == 0) + .filter(|record| !record.is_empty()) + { + let descriptor: serde_json::Value = serde_json::from_slice(record)?; + if descriptor.get("name").and_then(serde_json::Value::as_str) != Some(task) { + continue; + } + if descriptor + .get("abi_version") + .and_then(serde_json::Value::as_u64) + != Some(disasmer_core::WASM_TASK_ABI_VERSION as u64) + { + return Err(format!("task `{task}` uses an unsupported Wasm ABI version").into()); + } + return descriptor + .get("export") + .and_then(serde_json::Value::as_str) + .filter(|export| !export.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("task `{task}` descriptor omitted its Wasm export").into()); + } + } + Err(format!("bundle has no task descriptor named `{task}`").into()) +} diff --git a/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs b/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs new file mode 100644 index 0000000..09f9f04 --- /dev/null +++ b/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs @@ -0,0 +1,95 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{ + CommandInvocation, Digest, EnvironmentKind, EnvironmentRequirements, EnvironmentResource, + NodeId, ProcessId, TaskInstanceId, VfsOverlay, VfsPath, +}; +use disasmer_node::{ + LinuxRootlessPodmanBackend, LocalCheckoutTaskRequest, LocalSourceCheckout, StdProcessRunner, +}; +use serde_json::json; + +fn main() -> Result<(), Box> { + let workspace = create_workspace()?; + let env_dir = workspace.join("envs/linux"); + fs::create_dir_all(&env_dir)?; + fs::write( + env_dir.join("Containerfile"), + "FROM docker.io/library/alpine:3.20\nWORKDIR /workspace\n", + )?; + fs::write(workspace.join("input.txt"), "node-local source\n")?; + + let env = EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: env_dir.join("Containerfile"), + context_path: env_dir, + digest: Digest::sha256("phase2-podman-smoke-linux-env"), + requirements: EnvironmentRequirements::linux_container(), + }; + let invocation = CommandInvocation { + program: "sh".to_owned(), + args: vec![ + "-c".to_owned(), + "printf 'podman-ok:' && cat input.txt".to_owned(), + ], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: Some(env), + }; + let checkout = LocalSourceCheckout { + host_path: workspace.clone(), + snapshot: Digest::sha256("phase2-podman-smoke-checkout"), + }; + let task = TaskInstanceId::from("podman-smoke"); + let output_root = workspace.join("task-output"); + fs::create_dir_all(&output_root)?; + let mut overlay = VfsOverlay::new(task.clone(), NodeId::from("node-podman-smoke")); + let mut runner = StdProcessRunner; + let output = LinuxRootlessPodmanBackend.execute_local_checkout_task( + LocalCheckoutTaskRequest { + process: ProcessId::from("vp-podman-smoke"), + virtual_thread: task, + invocation: &invocation, + checkout, + output_root, + stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/podman-smoke.txt")?), + }, + &mut runner, + &mut overlay, + )?; + let manifest = overlay.flush(); + + println!( + "{}", + serde_json::to_string(&json!({ + "podman_status": "completed", + "status_code": output.status_code, + "stdout": output.stdout, + "stderr": output.stderr, + "staged_artifact": output.staged_artifact, + "large_bytes_uploaded": manifest.large_bytes_uploaded, + "uses_full_repo_tarball": false, + "coordinator_routed_file_reads": false, + }))? + ); + let _ = fs::remove_dir_all(workspace); + Ok(()) +} + +fn create_workspace() -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let workspace = std::env::temp_dir().join(format!( + "disasmer-podman-smoke-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&workspace)?; + Ok(workspace) +} diff --git a/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs b/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs new file mode 100644 index 0000000..1a9f4b7 --- /dev/null +++ b/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs @@ -0,0 +1,138 @@ +use std::{net::SocketAddr, sync::Arc}; + +use disasmer_core::{ + ArtifactId, DataPlaneObject, DataPlaneScope, Digest, NativeQuicTransport, NodeEndpoint, NodeId, + ProcessId, ProjectId, RendezvousRequest, TenantId, Transport, +}; +use quinn::rustls::{ + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}, + RootCertStore, +}; +use quinn::{ClientConfig, Endpoint, ServerConfig}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct QuicTransferRequest { + scope: DataPlaneScope, + authorization_digest: Digest, + requested_bytes: u64, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let payload = b"artifact-bytes-over-rust-native-quic".to_vec(); + let (cert, key) = self_signed_localhost_cert()?; + let server_endpoint = Endpoint::server( + ServerConfig::with_single_cert(vec![cert.clone()], key)?, + "127.0.0.1:0".parse()?, + )?; + let server_addr = server_endpoint.local_addr()?; + + let scope = DataPlaneScope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("vp-quic"), + object: DataPlaneObject::Artifact(ArtifactId::from("quic-artifact")), + authorization_subject: "node-a-to-node-b".to_owned(), + }; + let transport = NativeQuicTransport; + let plan = transport.plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: scope.clone(), + source: endpoint("node-a", server_addr), + destination: endpoint("node-b", "127.0.0.1:0".parse()?), + }, + true, + "", + )?; + + let expected_scope = plan.scope.clone(); + let expected_digest = plan.authorization_digest.clone(); + let expected_payload = payload.clone(); + let server = tokio::spawn(async move { + let incoming = server_endpoint + .accept() + .await + .ok_or("server endpoint closed before accepting a QUIC connection")?; + let connection = incoming.await?; + let (mut send, mut recv) = connection.accept_bi().await?; + let request_bytes = recv.read_to_end(64 * 1024).await?; + let request: QuicTransferRequest = serde_json::from_slice(&request_bytes)?; + if request.scope != expected_scope { + return Err("QUIC request scope did not match the authorized data-plane scope".into()); + } + if request.authorization_digest != expected_digest { + return Err( + "QUIC request authorization digest did not match the rendezvous plan".into(), + ); + } + send.write_all(&expected_payload).await?; + send.finish()?; + server_endpoint.wait_idle().await; + Ok::>(request_bytes.len()) + }); + + let mut roots = RootCertStore::empty(); + roots.add(cert)?; + let client_config = ClientConfig::with_root_certificates(Arc::new(roots))?; + let mut client_endpoint = Endpoint::client("127.0.0.1:0".parse()?)?; + client_endpoint.set_default_client_config(client_config); + + let connection = client_endpoint.connect(server_addr, "localhost")?.await?; + let (mut send, mut recv) = connection.open_bi().await?; + let request = QuicTransferRequest { + scope: plan.scope.clone(), + authorization_digest: plan.authorization_digest.clone(), + requested_bytes: payload.len() as u64, + }; + let request_bytes = serde_json::to_vec(&request)?; + send.write_all(&request_bytes).await?; + send.finish()?; + let received = recv.read_to_end(64 * 1024).await?; + connection.close(0u32.into(), b"done"); + client_endpoint.wait_idle().await; + let server_received_request_bytes = server.await??; + + if received != payload { + return Err("QUIC artifact payload did not round trip".into()); + } + + println!( + "{}", + json!({ + "kind": "disasmer_quic_smoke", + "transport": format!("{:?}", transport.kind()), + "rust_native_quic": true, + "authenticated_direct_connection": transport.authenticated_direct_connections(), + "coordinator_assisted_rendezvous": plan.coordinator_assisted_rendezvous, + "coordinator_bulk_relay_allowed": plan.coordinator_bulk_relay_allowed, + "source_node": plan.source.node, + "destination_node": plan.destination.node, + "scope": plan.scope, + "request_bytes": request_bytes.len(), + "server_received_request_bytes": server_received_request_bytes, + "payload_bytes": received.len(), + "authorization_digest": plan.authorization_digest, + }) + ); + + Ok(()) +} + +fn self_signed_localhost_cert() -> Result< + (CertificateDer<'static>, PrivateKeyDer<'static>), + Box, +> { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()])?; + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())); + Ok((cert.cert.into(), key)) +} + +fn endpoint(name: &str, addr: SocketAddr) -> NodeEndpoint { + NodeEndpoint { + node: NodeId::from(name), + advertised_addr: addr.to_string(), + public_key_fingerprint: Digest::sha256(format!("{name}-public-key")), + } +} diff --git a/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs b/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs new file mode 100644 index 0000000..4b6d8cb --- /dev/null +++ b/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs @@ -0,0 +1,408 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use disasmer_core::{ + ArtifactHandle, ArtifactId, Digest, StructuredTaskBoundary, TaskBoundaryHandle, + TaskBoundaryValue, WasmHostCommandRequest, WasmHostCommandResult, + WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, WasmHostTaskControlRequest, + WasmHostTaskControlResult, WasmHostTaskHandle, WasmHostTaskJoinRequest, WasmHostTaskJoinResult, + WasmHostTaskStartRequest, WasmHostVfsOperation, WasmHostVfsRequest, WasmHostVfsResult, + WasmTaskInvocation, WasmTaskOutcome, +}; +use disasmer_node::{WasmTaskHost, WasmtimeTaskRuntime}; +use serde_json::json; +use wasmparser::{Parser, Payload}; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + if args.len() == 4 && args[1] == "--host-command" { + return run_host_command_smoke(&args[2], &args[3]); + } + if args.len() == 6 && args[1] == "--debug-freeze-resume" { + return run_debug_freeze_resume_smoke(&args[2], &args[3], &args[4], &args[5]); + } + if args.len() == 6 && args[1] == "--task-abi" { + return run_task_abi_smoke(&args[2], &args[3], &args[4], &args[5]); + } + if args.len() == 4 && args[1] == "--task-artifact" { + return run_task_artifact_smoke(&args[2], &args[3]); + } + if args.len() != 5 { + return Err( + "usage: disasmer-wasmtime-smoke | --host-command | --debug-freeze-resume | --task-abi | --task-artifact ".into(), + ); + } + + let module = PathBuf::from(&args[1]); + let export = &args[2]; + let arg: i32 = args[3].parse()?; + let expected: i32 = args[4].parse()?; + + let wasm = fs::read(&module)?; + let runtime = WasmtimeTaskRuntime::new()?; + let result = runtime.run_i32_export(&wasm, export, arg)?; + if result != expected { + return Err(format!("expected {expected}, got {result} from export `{export}`").into()); + } + + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasmtime_task_smoke", + "module": module, + "export": export, + "arg": arg, + "result": result, + }))? + ); + Ok(()) +} + +fn run_task_artifact_smoke(module: &str, task: &str) -> Result<(), Box> { + let module = PathBuf::from(module); + let wasm = fs::read(&module)?; + let export = task_export(&wasm, task)?; + let published = Arc::new(Mutex::new(None)); + let input_artifact = ArtifactHandle { + id: ArtifactId::from("compiler-output"), + digest: Digest::sha256("compiler-output"), + size_bytes: 15, + }; + let source_snapshot = Digest::sha256("standalone-source-snapshot"); + let package_input = TaskBoundaryValue::Structured(StructuredTaskBoundary { + value: serde_json::json!({ + "release_name": "release.tar", + "source": { "digest": source_snapshot.clone() }, + "executable": input_artifact.clone(), + "inputs": [input_artifact.clone()], + }), + handles: vec![ + TaskBoundaryHandle::SourceSnapshot(source_snapshot), + TaskBoundaryHandle::Artifact(input_artifact.id.clone()), + TaskBoundaryHandle::Artifact(input_artifact.id.clone()), + ], + }); + let result = WasmtimeTaskRuntime::new()?.run_task_export_verified_with_task_host( + &wasm, + &Digest::sha256(&wasm), + &export, + &WasmTaskInvocation::new( + disasmer_core::TaskDefinitionId::from(task), + disasmer_core::TaskInstanceId::new(format!("{task}-1")), + vec![package_input], + ), + Box::new(ArtifactRecordingHost { + published: Arc::clone(&published), + executed_command: Arc::new(Mutex::new(None)), + allow_command: true, + }), + )?; + if result.outcome != WasmTaskOutcome::Completed { + return Err(format!("artifact task failed: {:?}", result.error).into()); + } + let Some(TaskBoundaryValue::Artifact(result_artifact)) = result.result else { + return Err("artifact task did not return an Artifact handle".into()); + }; + let (name, host_artifact) = published + .lock() + .map_err(|_| "artifact recording host lock was poisoned")? + .clone() + .ok_or("Wasm task did not invoke disasmer.vfs_operation_v1 flush_output")?; + if result_artifact != host_artifact { + return Err("Wasm task returned a handle other than the host-issued artifact ID".into()); + } + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasmtime_task_artifact_smoke", + "module": module, + "task": task, + "export": export, + "artifact": result_artifact, + "artifact_name": name, + "artifact_digest": host_artifact.digest, + "artifact_size_bytes": host_artifact.size_bytes, + "host_import": "disasmer.vfs_operation_v1", + "host_issued_handle_returned": true, + }))? + ); + Ok(()) +} + +type PublishedArtifact = (String, ArtifactHandle); +type ExecutedCommand = (WasmHostCommandRequest, WasmHostCommandResult); + +struct ArtifactRecordingHost { + published: Arc>>, + executed_command: Arc>>, + allow_command: bool, +} + +impl WasmTaskHost for ArtifactRecordingHost { + fn start_task( + &mut self, + _request: WasmHostTaskStartRequest, + ) -> Result { + Err("artifact smoke task must not spawn".to_owned()) + } + + fn join_task( + &mut self, + _request: WasmHostTaskJoinRequest, + ) -> Result { + Err("artifact smoke task must not join".to_owned()) + } + + fn run_command( + &mut self, + request: WasmHostCommandRequest, + ) -> Result { + request.validate()?; + if !self.allow_command { + return Err("this smoke task was not granted Command capability".to_owned()); + } + let result = WasmHostCommandResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + status_code: Some(0), + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }; + *self + .executed_command + .lock() + .map_err(|_| "command recording host lock was poisoned".to_owned())? = + Some((request, result.clone())); + Ok(result) + } + + fn poll_task_control( + &mut self, + request: WasmHostTaskControlRequest, + ) -> Result { + request.validate()?; + Ok(WasmHostTaskControlResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + cancellation_requested: false, + }) + } + + fn vfs_operation(&mut self, request: WasmHostVfsRequest) -> Result { + request.validate()?; + let (artifact, relative_path) = match request.operation { + WasmHostVfsOperation::FlushOutput { relative_path } => { + let digest = Digest::sha256(format!("standalone-vfs-smoke:{relative_path}")); + let artifact = ArtifactHandle { + id: ArtifactId::new(format!( + "{}-{}", + relative_path.replace('/', "_"), + digest.as_str().trim_start_matches("sha256:") + )), + digest, + size_bytes: relative_path.len() as u64, + }; + *self + .published + .lock() + .map_err(|_| "artifact recording host lock was poisoned".to_owned())? = + Some((relative_path.clone(), artifact.clone())); + (artifact, relative_path) + } + WasmHostVfsOperation::MaterializeArtifact { + artifact, + relative_path, + } => (artifact, relative_path), + }; + Ok(WasmHostVfsResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + artifact, + relative_path, + }) + } + + fn snapshot_source( + &mut self, + request: WasmHostSourceSnapshotRequest, + ) -> Result { + request.validate()?; + Ok(WasmHostSourceSnapshotResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + snapshot: Digest::sha256("standalone-source-snapshot"), + }) + } +} + +fn task_export(wasm: &[u8], task: &str) -> Result> { + for payload in Parser::new(0).parse_all(wasm) { + let Payload::CustomSection(section) = payload? else { + continue; + }; + if section.name() != "disasmer.tasks" { + continue; + } + for record in section + .data() + .split(|byte| *byte == b'\n' || *byte == 0) + .filter(|record| !record.is_empty()) + { + let descriptor: serde_json::Value = serde_json::from_slice(record)?; + if descriptor.get("name").and_then(serde_json::Value::as_str) == Some(task) { + return descriptor + .get("export") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("task `{task}` descriptor omitted export").into()); + } + } + } + Err(format!("module has no task descriptor `{task}`").into()) +} + +fn run_task_abi_smoke( + module: &str, + export: &str, + task: &str, + args: &str, +) -> Result<(), Box> { + let module = PathBuf::from(module); + let wasm = fs::read(&module)?; + let args: Vec = serde_json::from_str(args)?; + let invocation = WasmTaskInvocation::new( + disasmer_core::TaskDefinitionId::from(task), + disasmer_core::TaskInstanceId::new(format!("{task}-1")), + args, + ); + let result = WasmtimeTaskRuntime::new()?.run_task_export_verified( + &wasm, + &Digest::sha256(&wasm), + export, + &invocation, + )?; + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasm_task_abi_smoke", + "module": module, + "export": export, + "invocation": invocation, + "result": result, + }))? + ); + Ok(()) +} + +fn run_debug_freeze_resume_smoke( + module: &str, + export: &str, + arg: &str, + expected: &str, +) -> Result<(), Box> { + let module = PathBuf::from(module); + let arg: i32 = arg.parse()?; + let expected: i32 = expected.parse()?; + let wasm = fs::read(&module)?; + let runtime = WasmtimeTaskRuntime::new()?; + let probe = runtime.freeze_resume_i32_export_probe(&wasm, export, arg)?; + if probe.result != expected { + return Err(format!( + "expected {expected}, got {} from export `{export}`", + probe.result + ) + .into()); + } + + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasmtime_debug_freeze_resume_smoke", + "module": module, + "export": export, + "task": probe.task, + "frozen_state": probe.frozen_state, + "resumed_state": probe.resumed_state, + "stack_frames": probe.stack_frames, + "local_values": probe.local_values, + "wasm_function": probe.wasm_function, + "wasm_pc": probe.wasm_pc, + "arg": arg, + "result": probe.result, + "node_runtime_reached_wasm_task": true, + "node_runtime_captured_wasm_locals": true, + }))? + ); + Ok(()) +} + +fn run_host_command_smoke(module: &str, task: &str) -> Result<(), Box> { + let module = PathBuf::from(module); + let wasm = fs::read(&module)?; + let export = task_export(&wasm, task)?; + let published = Arc::new(Mutex::new(None)); + let executed_command = Arc::new(Mutex::new(None)); + let result = WasmtimeTaskRuntime::new()?.run_task_export_verified_with_task_host( + &wasm, + &Digest::sha256(&wasm), + &export, + &WasmTaskInvocation::new( + disasmer_core::TaskDefinitionId::from(task), + disasmer_core::TaskInstanceId::new(format!("{task}-1")), + vec![TaskBoundaryValue::SourceSnapshot(Digest::sha256( + "standalone-source-snapshot", + ))], + ), + Box::new(ArtifactRecordingHost { + published: Arc::clone(&published), + executed_command: Arc::clone(&executed_command), + allow_command: true, + }), + )?; + if result.outcome != WasmTaskOutcome::Completed { + return Err(format!("command task failed: {:?}", result.error).into()); + } + let Some(TaskBoundaryValue::Artifact(result_artifact)) = result.result else { + return Err("command task did not return its published Artifact handle".into()); + }; + let (request, command_result) = executed_command + .lock() + .map_err(|_| "command recording host lock was poisoned")? + .clone() + .ok_or("Wasm task did not invoke disasmer.command_run_v1")?; + let (artifact_name, host_artifact) = published + .lock() + .map_err(|_| "artifact recording host lock was poisoned")? + .clone() + .ok_or("command task did not publish its command output")?; + if result_artifact != host_artifact { + return Err("command task returned a handle other than the published artifact".into()); + } + + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasmtime_host_command_smoke", + "module": module, + "task": task, + "export": export, + "program": request.program, + "args": request.args, + "working_directory": request.working_directory, + "environment_variables": request.environment_variables, + "timeout_ms": request.timeout_ms, + "network": request.network, + "status_code": command_result.status_code, + "stdout": command_result.stdout, + "artifact": result_artifact, + "artifact_name": artifact_name, + "artifact_digest": host_artifact.digest, + "artifact_size_bytes": host_artifact.size_bytes, + "node_host_import": "disasmer.command_run_v1", + "artifact_host_import": "disasmer.vfs_operation_v1", + "flagship_linux_build_task": true, + "node_executed_host_command": true, + "hosted_control_plane_ran_command": false, + }))? + ); + Ok(()) +} diff --git a/crates/disasmer-node/src/command_runner.rs b/crates/disasmer-node/src/command_runner.rs new file mode 100644 index 0000000..3b289eb --- /dev/null +++ b/crates/disasmer-node/src/command_runner.rs @@ -0,0 +1,130 @@ +use disasmer_core::{ + CommandInvocation, Digest, LogBuffer, NativeCommandPolicy, NodeId, TaskInstanceId, VfsObject, + VfsOverlay, VfsPath, +}; +use serde::{Deserialize, Serialize}; + +use super::BackendError; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapturedCommandLogs { + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub backpressured: bool, +} + +pub const DEFAULT_COMMAND_LOG_LIMIT_BYTES: usize = 256 * 1024; + +pub fn authorize_node_command( + hosted_control_plane: bool, + node_has_command_capability: bool, +) -> Result<(), BackendError> { + NativeCommandPolicy { + hosted_control_plane, + node_has_command_capability, + } + .authorize() + .map_err(BackendError::Denied) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VirtualThreadCommand { + pub virtual_thread: TaskInstanceId, + pub invocation: CommandInvocation, + pub stage_stdout_as: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandOutput { + pub virtual_thread: TaskInstanceId, + pub status_code: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub log_backpressured: bool, + pub staged_artifact: Option, +} + +#[derive(Clone, Debug)] +pub struct LocalCommandExecutor { + pub node: NodeId, + pub hosted_control_plane: bool, + pub has_command_capability: bool, +} + +impl LocalCommandExecutor { + pub fn run( + &self, + command: VirtualThreadCommand, + overlay: &mut VfsOverlay, + ) -> Result { + self.run_with_log_limit(command, overlay, DEFAULT_COMMAND_LOG_LIMIT_BYTES) + } + + pub fn run_with_log_limit( + &self, + command: VirtualThreadCommand, + overlay: &mut VfsOverlay, + max_log_bytes: usize, + ) -> Result { + authorize_node_command(self.hosted_control_plane, self.has_command_capability)?; + + let output = std::process::Command::new(&command.invocation.program) + .args(&command.invocation.args) + .output() + .map_err(|err| BackendError::Command(format!("{err:#}")))?; + + let logs = capture_command_logs( + &command.virtual_thread, + &output.stdout, + &output.stderr, + max_log_bytes, + ); + let staged_artifact = if let Some(path) = command.stage_stdout_as { + Some(overlay.write( + path, + Digest::sha256(&output.stdout), + output.stdout.len() as u64, + )) + } else { + None + }; + + Ok(CommandOutput { + virtual_thread: command.virtual_thread, + status_code: output.status.code(), + stdout: logs.stdout, + stderr: logs.stderr, + stdout_truncated: logs.stdout_truncated, + stderr_truncated: logs.stderr_truncated, + log_backpressured: logs.backpressured, + staged_artifact, + }) + } +} + +pub(super) fn capture_command_logs( + task: &TaskInstanceId, + stdout: &[u8], + stderr: &[u8], + max_log_bytes: usize, +) -> CapturedCommandLogs { + let mut logs = LogBuffer::new(max_log_bytes); + logs.push(task.clone(), stdout); + logs.push(task.clone(), stderr); + let records = logs.records(); + let stdout_record = &records[0]; + let stderr_record = &records[1]; + debug_assert_eq!(&stdout_record.task, task); + debug_assert_eq!(&stderr_record.task, task); + CapturedCommandLogs { + stdout: String::from_utf8_lossy(&stdout_record.bytes).into_owned(), + stderr: String::from_utf8_lossy(&stderr_record.bytes).into_owned(), + stdout_truncated: stdout_record.truncated, + stderr_truncated: stderr_record.truncated, + backpressured: logs.backpressured(), + } +} diff --git a/crates/disasmer-node/src/coordinator_session.rs b/crates/disasmer-node/src/coordinator_session.rs new file mode 100644 index 0000000..26f3ee5 --- /dev/null +++ b/crates/disasmer-node/src/coordinator_session.rs @@ -0,0 +1,38 @@ +#[cfg(test)] +use disasmer_control::endpoint_identity; +use disasmer_control::ControlSession; +use disasmer_core::coordinator_wire_request; +use serde_json::Value; + +pub(crate) struct CoordinatorSession { + inner: ControlSession, +} + +impl CoordinatorSession { + pub(crate) fn connect(addr: &str) -> Result> { + Ok(Self { + inner: ControlSession::connect(addr)?, + }) + } + + pub(crate) fn request(&mut self, value: Value) -> Result> { + let request_id = format!("node-{}", self.inner.requests() + 1); + let wire_request = coordinator_wire_request(request_id, value); + let response = self.inner.request(&wire_request)?; + if response.get("type").and_then(Value::as_str) == Some("error") { + return Err(format!("coordinator error: {response}").into()); + } + Ok(response) + } + + pub(crate) fn requests(&self) -> usize { + self.inner.requests() as usize + } +} + +#[cfg(test)] +pub(crate) fn control_endpoint_identity( + endpoint: &str, +) -> Result> { + Ok(endpoint_identity(endpoint)?) +} diff --git a/crates/disasmer-node/src/daemon.rs b/crates/disasmer-node/src/daemon.rs new file mode 100644 index 0000000..96f959a --- /dev/null +++ b/crates/disasmer-node/src/daemon.rs @@ -0,0 +1,734 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_core::{ + sign_node_request, signed_request_payload_digest, ArtifactId, Digest, NodeCapabilities, NodeId, + TaskSpec, MIN_SIGNED_NODE_POLL_INTERVAL_MS, +}; +use serde_json::{json, Value}; + +use crate::assignment_runner::run_verified_wasmtime_assignment; +#[cfg(test)] +use crate::coordinator_session::control_endpoint_identity; +use crate::coordinator_session::CoordinatorSession; +use crate::debug_agent::poll_task_cancellation; +use crate::node_identity::{ + establish_node_identity, node_nonce, node_private_key_for_runtime, signed_node_request_json, + unix_timestamp_seconds, +}; +#[cfg(test)] +use crate::node_identity::{load_or_create_local_node_credential, unix_timestamp_nanos}; +use crate::source_snapshot::snapshot_project; +use crate::task_artifacts::{ + clean_stale_task_output_roots, current_epoch_seconds, NodeArtifactRetentionLimits, + NodeArtifactStore, +}; +use crate::task_reports::{record_cancelled_task, record_completed_task, record_failed_task}; + +static WORKER_SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); + +pub(crate) fn worker_shutdown_requested() -> bool { + WORKER_SHUTDOWN_REQUESTED.load(Ordering::Acquire) +} + +#[cfg(unix)] +extern "C" fn request_worker_shutdown(_signal: libc::c_int) { + WORKER_SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst); +} + +fn install_worker_shutdown_handler() { + WORKER_SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst); + #[cfg(unix)] + unsafe { + libc::signal(libc::SIGINT, request_worker_shutdown as libc::sighandler_t); + libc::signal(libc::SIGTERM, request_worker_shutdown as libc::sighandler_t); + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Args { + pub(crate) coordinator: String, + pub(crate) tenant: String, + pub(crate) project: String, + pub(crate) project_root: Option, + pub(crate) node: String, + pub(crate) enrollment_grant: Option, + pub(crate) public_key: Option, + pub(crate) control_poll_ms: u64, + pub(crate) assignment_poll_ms: u64, + pub(crate) emit_ready: bool, + pub(crate) worker: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct RuntimeTask { + pub(crate) process: String, + pub(crate) task: String, + pub(crate) epoch: Option, + pub(crate) task_spec: Option, + pub(crate) bundle_digest: Option, + pub(crate) wasm_module_base64: Option, + pub(crate) task_assignment_response: Value, +} + +pub(crate) fn run() -> Result<(), Box> { + let args = parse_args()?; + + let mut session = CoordinatorSession::connect(&args.coordinator)?; + + let node_private_key = node_private_key_for_runtime(&args.node)?; + let registration = establish_node_identity(&mut session, &args, &node_private_key)?; + let heartbeat_request = json!({ + "type": "node_heartbeat", + "node": &args.node, + }); + let heartbeat_signature = sign_node_request( + &node_private_key, + &NodeId::from(args.node.as_str()), + "node_heartbeat", + &signed_request_payload_digest(&heartbeat_request), + node_nonce("node-heartbeat"), + unix_timestamp_seconds(), + ) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?; + let mut heartbeat_request = heartbeat_request; + heartbeat_request["node_signature"] = json!(heartbeat_signature); + let heartbeat = session.request(heartbeat_request)?; + clean_stale_task_output_roots(args.project_root.as_deref(), &args.node)?; + let artifact_store = NodeArtifactStore::for_runtime(args.project_root.as_deref(), &args.node)?; + let retention_limits = NodeArtifactRetentionLimits::from_environment()?; + artifact_store.garbage_collect(retention_limits, &BTreeSet::new(), current_epoch_seconds())?; + let capability_report = report_node_capabilities( + &args, + &mut session, + &node_private_key, + &artifact_store, + true, + )?; + + if !args.worker { + return Err( + "one-shot native command mode was removed; run `disasmer-node --worker` and launch a bundled Wasm task through the coordinator" + .into(), + ); + } + install_worker_shutdown_handler(); + worker_loop( + &args, + &mut session, + registration, + heartbeat, + capability_report, + &node_private_key, + ) +} + +fn worker_loop( + args: &Args, + session: &mut CoordinatorSession, + registration: Value, + heartbeat: Value, + capability_report: Value, + node_private_key: &str, +) -> Result<(), Box> { + const ARTIFACT_GC_INTERVAL: Duration = Duration::from_secs(30); + let artifact_store = NodeArtifactStore::for_runtime(args.project_root.as_deref(), &args.node)?; + let retention_limits = NodeArtifactRetentionLimits::from_environment()?; + let mut last_artifact_gc = Instant::now(); + let mut restart_pins = BTreeMap::::new(); + if args.emit_ready { + println!( + "{}", + serde_json::to_string(&json!({ + "node_status": "ready", + "mode": "worker", + "node": &args.node, + }))? + ); + std::io::stdout().flush()?; + } + + loop { + if worker_shutdown_requested() { + report_node_capabilities(args, session, node_private_key, &artifact_store, false)?; + return Ok(()); + } + if service_pending_artifact_transfer(args, session, node_private_key)? { + continue; + } + if last_artifact_gc.elapsed() >= ARTIFACT_GC_INTERVAL { + let now = Instant::now(); + restart_pins.retain(|_, expiry| *expiry > now); + let pinned = restart_pins.keys().cloned().collect::>(); + artifact_store.garbage_collect(retention_limits, &pinned, current_epoch_seconds())?; + report_node_capabilities(args, session, node_private_key, &artifact_store, true)?; + last_artifact_gc = Instant::now(); + } + let response = session.request(signed_node_request_json( + args, + node_private_key, + "poll_task_assignment", + json!({ + "type": "poll_task_assignment", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + }), + )?)?; + let Some(assignment) = response.get("assignment").filter(|value| !value.is_null()) else { + std::thread::sleep(Duration::from_millis(args.assignment_poll_ms)); + continue; + }; + let runtime_task = runtime_task_from_assignment(assignment)?; + if let Some(task_spec) = &runtime_task.task_spec { + let expiry = Instant::now() + .checked_add(Duration::from_secs(retention_limits.restart_pin_seconds)) + .unwrap_or_else(Instant::now); + for artifact in &task_spec.required_artifacts { + restart_pins.insert(artifact.clone(), expiry); + } + } + let report = run_runtime_task( + args, + session, + runtime_task, + registration.clone(), + heartbeat.clone(), + capability_report.clone(), + node_private_key, + )?; + if let Some(artifact) = report + .pointer("/vfs_metadata_response/artifact_path") + .and_then(Value::as_str) + .and_then(|path| path.strip_prefix("/vfs/artifacts/")) + { + let expiry = Instant::now() + .checked_add(Duration::from_secs(retention_limits.restart_pin_seconds)) + .unwrap_or_else(Instant::now); + restart_pins.insert(ArtifactId::new(artifact), expiry); + } + println!("{}", serde_json::to_string(&report)?); + std::io::stdout().flush()?; + } +} + +fn report_node_capabilities( + args: &Args, + session: &mut CoordinatorSession, + node_private_key: &str, + artifact_store: &NodeArtifactStore, + online: bool, +) -> Result> { + let artifact_locations = artifact_store + .artifact_ids()? + .into_iter() + .map(|artifact| artifact.as_str().to_owned()) + .collect::>(); + let source_snapshots = args + .project_root + .as_deref() + .map(snapshot_project) + .transpose()? + .into_iter() + .map(|snapshot| snapshot.digest) + .collect::>(); + Ok(session.request(signed_node_request_json( + args, + node_private_key, + "report_node_capabilities", + json!({ + "type": "report_node_capabilities", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + "capabilities": NodeCapabilities::detect_current(), + "cached_environment_digests": [], + "dependency_cache_digests": [], + "source_snapshots": source_snapshots, + "artifact_locations": artifact_locations, + "direct_connectivity": false, + "online": online, + }), + )?)?) +} + +fn service_pending_artifact_transfer( + args: &Args, + session: &mut CoordinatorSession, + node_private_key: &str, +) -> Result> { + let response = session.request(signed_node_request_json( + args, + node_private_key, + "poll_artifact_transfer", + json!({ + "type": "poll_artifact_transfer", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + }), + )?)?; + let Some(transfer) = response.get("transfer").filter(|value| !value.is_null()) else { + return Ok(false); + }; + let transfer_id = required_string(transfer, "transfer_id")?; + let artifact = ArtifactId::new(required_string(transfer, "artifact")?); + let expected_digest: Digest = serde_json::from_value( + transfer + .get("expected_digest") + .cloned() + .ok_or("artifact transfer omitted expected_digest")?, + )?; + let expected_size_bytes = transfer + .get("expected_size_bytes") + .and_then(Value::as_u64) + .ok_or("artifact transfer omitted expected_size_bytes")?; + let offset = transfer + .get("offset") + .and_then(Value::as_u64) + .ok_or("artifact transfer omitted offset")?; + let max_chunk_bytes = transfer + .get("max_chunk_bytes") + .and_then(Value::as_u64) + .ok_or("artifact transfer omitted max_chunk_bytes")?; + let read = NodeArtifactStore::for_runtime(args.project_root.as_deref(), &args.node).and_then( + |store| { + store.read_verified_chunk( + &artifact, + &expected_digest, + expected_size_bytes, + offset, + max_chunk_bytes, + ) + }, + ); + let content = match read { + Ok(content) => content, + Err(message) => { + session.request(signed_node_request_json( + args, + node_private_key, + "fail_artifact_transfer", + json!({ + "type": "fail_artifact_transfer", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + "transfer_id": transfer_id, + "artifact": artifact, + "message": message, + }), + )?)?; + return Ok(true); + } + }; + let eof = offset.saturating_add(content.len() as u64) == expected_size_bytes; + session.request(signed_node_request_json( + args, + node_private_key, + "upload_artifact_transfer_chunk", + json!({ + "type": "upload_artifact_transfer_chunk", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + "transfer_id": transfer_id, + "artifact": artifact, + "offset": offset, + "content_base64": BASE64_STANDARD.encode(&content), + "chunk_digest": Digest::sha256(&content), + "eof": eof, + }), + )?)?; + Ok(true) +} + +pub(crate) fn runtime_task_from_assignment( + value: &Value, +) -> Result> { + let task_spec: TaskSpec = serde_json::from_value( + value + .get("task_spec") + .cloned() + .ok_or("task assignment missing task_spec")?, + )?; + Ok(RuntimeTask { + process: required_string(value, "process")?, + task: required_string(value, "task")?, + epoch: value.get("epoch").and_then(Value::as_u64), + bundle_digest: task_spec.bundle_digest.clone(), + task_spec: Some(task_spec), + wasm_module_base64: Some(required_string(value, "wasm_module_base64")?), + task_assignment_response: value.clone(), + }) +} + +fn required_string(value: &Value, field: &str) -> Result> { + value + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("task assignment missing string field `{field}`").into()) +} + +fn run_runtime_task( + args: &Args, + session: &mut CoordinatorSession, + task: RuntimeTask, + registration: Value, + heartbeat: Value, + capability_report: Value, + node_private_key: &str, +) -> Result> { + let epoch = match task.epoch { + Some(epoch) => epoch, + None => { + let started = session.request(json!({ + "type": "start_process", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + }))?; + started + .get("epoch") + .and_then(Value::as_u64) + .ok_or("coordinator start_process response missing epoch")? + } + }; + session.request(signed_node_request_json( + args, + node_private_key, + "reconnect_node", + json!({ + "type": "reconnect_node", + "node": &args.node, + "process": &task.process, + "epoch": epoch, + }), + )?)?; + let debug_command = session.request(signed_node_request_json( + args, + node_private_key, + "poll_debug_command", + json!({ + "type": "poll_debug_command", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + }), + )?)?; + if args.emit_ready && !args.worker { + println!( + "{}", + serde_json::to_string(&json!({ + "node_status": "ready", + "node": &args.node, + "process": &task.process, + "task": &task.task, + }))? + ); + std::io::stdout().flush()?; + } + if args.control_poll_ms > 0 && poll_task_cancellation(session, args, &task, node_private_key)? { + return record_cancelled_task( + args, + session, + &task, + registration, + heartbeat, + capability_report, + debug_command, + node_private_key, + ); + } + + let execution = run_verified_wasmtime_assignment(args, &task, node_private_key); + match execution { + Ok((output, manifest, result)) => match crate::task_artifacts::retained_result_artifact( + args.project_root.as_deref(), + &args.node, + result.as_ref(), + ) { + Ok(retained) => record_completed_task( + args, + session, + task, + output, + manifest, + result, + retained, + registration, + heartbeat, + capability_report, + debug_command, + node_private_key, + ), + Err(error) => record_failed_task( + args, + session, + &task, + registration, + heartbeat, + capability_report, + debug_command, + node_private_key, + &error, + ), + }, + Err(error) => { + let error = error.to_string(); + if error.contains("task execution cancelled:") { + record_cancelled_task( + args, + session, + &task, + registration, + heartbeat, + capability_report, + debug_command, + node_private_key, + ) + } else { + record_failed_task( + args, + session, + &task, + registration, + heartbeat, + capability_report, + debug_command, + node_private_key, + &error, + ) + } + } + } +} + +fn parse_args() -> Result> { + let mut coordinator = None; + let mut tenant = "tenant".to_owned(); + let mut project = "project".to_owned(); + let mut project_root = None; + let mut node = "node".to_owned(); + let mut enrollment_grant = None; + let mut public_key = None; + let mut control_poll_ms = 0; + let mut assignment_poll_ms = 500; + let mut emit_ready = false; + let mut worker = false; + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--coordinator" => coordinator = args.next(), + "--tenant" => tenant = args.next().ok_or("--tenant requires a value")?, + "--project-id" => project = args.next().ok_or("--project-id requires a value")?, + "--node" => node = args.next().ok_or("--node requires a value")?, + "--enrollment-grant" => enrollment_grant = args.next(), + "--public-key" => public_key = args.next(), + "--control-poll-ms" => { + control_poll_ms = args + .next() + .ok_or("--control-poll-ms requires a value")? + .parse()? + } + "--assignment-poll-ms" => { + assignment_poll_ms = validate_assignment_poll_ms( + args.next() + .ok_or("--assignment-poll-ms requires a value")? + .parse()?, + )? + } + "--emit-ready" => emit_ready = true, + "--worker" => worker = true, + "--project-root" => { + project_root = Some(PathBuf::from( + args.next().ok_or("--project-root requires a path")?, + )); + } + other => return Err(format!("unknown argument: {other}").into()), + } + } + + Ok(Args { + coordinator: coordinator.ok_or("--coordinator is required")?, + tenant, + project, + project_root, + node, + enrollment_grant, + public_key, + control_poll_ms, + assignment_poll_ms, + emit_ready, + worker, + }) +} + +fn validate_assignment_poll_ms(value: u64) -> Result { + if value < MIN_SIGNED_NODE_POLL_INTERVAL_MS { + return Err(format!( + "--assignment-poll-ms must be at least {MIN_SIGNED_NODE_POLL_INTERVAL_MS} ms so signed polling remains within the coordinator's bounded replay window" + )); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + use disasmer_core::{ + Digest, ProcessId, ProjectId, TaskBoundaryValue, TaskDispatch, TaskInstanceId, TaskSpec, + TenantId, WasmExportAbi, WasmTaskResult, + }; + + use crate::assignment_runner::run_verified_wasmtime_assignment; + use disasmer_core::MIN_SIGNED_NODE_POLL_INTERVAL_MS; + + use super::{ + control_endpoint_identity, load_or_create_local_node_credential, + validate_assignment_poll_ms, Args, RuntimeTask, + }; + + #[test] + fn node_poll_interval_cannot_exhaust_the_bounded_replay_window() { + assert!(validate_assignment_poll_ms(MIN_SIGNED_NODE_POLL_INTERVAL_MS).is_ok()); + let error = validate_assignment_poll_ms(MIN_SIGNED_NODE_POLL_INTERVAL_MS - 1).unwrap_err(); + assert!(error.contains("bounded replay window")); + } + + #[test] + fn hosted_url_remains_an_https_control_endpoint() { + assert_eq!( + control_endpoint_identity("https://disasmer.michelpaulissen.com").unwrap(), + "https://disasmer.michelpaulissen.com/api/v1/control" + ); + assert_eq!( + control_endpoint_identity("https://disasmer.michelpaulissen.com/api/v1/control") + .unwrap(), + "https://disasmer.michelpaulissen.com/api/v1/control" + ); + assert_eq!( + control_endpoint_identity("127.0.0.1:7999").unwrap(), + "disasmer+tcp://127.0.0.1:7999" + ); + } + + #[test] + fn daemon_local_node_credential_is_durable_between_runs() { + let temp = std::env::temp_dir().join(format!( + "disasmer-node-credential-test-{}-{}", + std::process::id(), + super::unix_timestamp_nanos() + )); + std::fs::create_dir_all(&temp).unwrap(); + let first = load_or_create_local_node_credential(&temp, "daemon-node").unwrap(); + let second = load_or_create_local_node_credential(&temp, "daemon-node").unwrap(); + + assert_eq!(first, second); + assert!(temp.join(".disasmer").join("nodes").exists()); + + std::fs::remove_dir_all(&temp).unwrap(); + } + + #[test] + fn daemon_wasm_task_assignment_uses_abi_version_and_verifies_bundle_digest() { + let args = test_args(); + let task_instance = TaskInstanceId::from("task_add_one-1"); + let boundary = TaskBoundaryValue::SmallJson(serde_json::json!(2)); + let result = serde_json::to_string(&WasmTaskResult::completed( + task_instance.clone(), + boundary.clone(), + )) + .unwrap(); + let wat_result = result.replace('\\', "\\\\").replace('"', "\\\""); + let result_length = result.len(); + let packed = ((result_length as u64) << 32) | 2048; + let wasm = format!( + r#"(module + (memory (export "memory") 1) + (data (i32.const 2048) "{wat_result}") + (func (export "disasmer_alloc_v1") (param i32) (result i32) + i32.const 1024) + (func (export "task_add_one") (param i32 i32) (result i64) + i64.const {packed}))"# + ); + let task = RuntimeTask { + process: "vp".to_owned(), + task: task_instance.as_str().to_owned(), + epoch: Some(7), + task_spec: Some(TaskSpec { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("vp"), + task_definition: disasmer_core::TaskDefinitionId::from("task_add_one"), + task_instance, + dispatch: TaskDispatch::CoordinatorNodeWasm { + export: Some("task_add_one".to_owned()), + abi: WasmExportAbi::TaskV1, + }, + environment_id: None, + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::new(), + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + args: Vec::new(), + vfs_epoch: 7, + bundle_digest: Some(Digest::sha256(&wasm)), + }), + bundle_digest: Some(Digest::sha256(&wasm)), + wasm_module_base64: Some(BASE64_STANDARD.encode(&wasm)), + task_assignment_response: serde_json::json!({}), + }; + + let (output, manifest, result) = + run_verified_wasmtime_assignment(&args, &task, "test-node-private-key").unwrap(); + + assert_eq!(output.status_code, Some(0)); + assert_eq!( + output.stdout, + format!("{}\n", serde_json::to_string(&boundary).unwrap()) + ); + assert!(output.staged_artifact.is_none()); + assert!(manifest.objects.is_empty()); + assert!(!manifest.large_bytes_uploaded); + assert_eq!(result, Some(boundary)); + + let mismatch = RuntimeTask { + bundle_digest: Some(Digest::sha256("different bundle bytes")), + wasm_module_base64: Some(BASE64_STANDARD.encode("not valid wasm")), + ..task + }; + let error = run_verified_wasmtime_assignment(&args, &mismatch, "test-node-private-key") + .unwrap_err(); + assert!(error.to_string().contains("bundle digest mismatch")); + assert!(!error.to_string().contains("failed to parse")); + } + + fn test_args() -> Args { + Args { + coordinator: "127.0.0.1:1".to_owned(), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + project_root: None, + node: "node".to_owned(), + enrollment_grant: None, + public_key: None, + control_poll_ms: 0, + assignment_poll_ms: 1, + emit_ready: false, + worker: false, + } + } +} diff --git a/crates/disasmer-node/src/debug_agent.rs b/crates/disasmer-node/src/debug_agent.rs new file mode 100644 index 0000000..081789e --- /dev/null +++ b/crates/disasmer-node/src/debug_agent.rs @@ -0,0 +1,47 @@ +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use crate::coordinator_session::CoordinatorSession; +use crate::daemon::{Args, RuntimeTask}; +use crate::node_identity::signed_node_request_json; + +pub(crate) fn poll_task_cancellation( + session: &mut CoordinatorSession, + args: &Args, + task: &RuntimeTask, + node_private_key: &str, +) -> Result> { + let deadline = Instant::now() + Duration::from_millis(args.control_poll_ms); + loop { + let control = session.request(signed_node_request_json( + args, + node_private_key, + "poll_task_control", + json!({ + "type": "poll_task_control", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + }), + )?)?; + if control + .get("cancel_requested") + .and_then(Value::as_bool) + .unwrap_or(false) + || control + .get("abort_requested") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + std::thread::sleep((deadline - now).min(Duration::from_millis(100))); + } +} diff --git a/crates/disasmer-node/src/lib.rs b/crates/disasmer-node/src/lib.rs new file mode 100644 index 0000000..a45beae --- /dev/null +++ b/crates/disasmer-node/src/lib.rs @@ -0,0 +1,1005 @@ +use std::path::PathBuf; + +use disasmer_core::{ + Capability, CommandBackendKind, CommandInvocation, CommandPlan, Digest, EnvironmentKind, + GuestRuntimeKind, ProcessId, TaskInstanceId, VfsObject, VfsOverlay, VfsPath, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +mod command_runner; +mod windows_dev; +use command_runner::capture_command_logs; +pub use command_runner::{ + authorize_node_command, CapturedCommandLogs, CommandOutput, LocalCommandExecutor, + VirtualThreadCommand, DEFAULT_COMMAND_LOG_LIMIT_BYTES, +}; +pub use disasmer_wasm_runtime::{ + WasmDebugControl, WasmTaskError, WasmTaskHost, WasmtimeDebugProbe, WasmtimeTaskRuntime, +}; +pub use windows_dev::{WindowsCommandDevBackend, WindowsSandboxStubBackend}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaterializedEnvironment { + pub name: String, + pub backend: CommandBackendKind, + pub local_reference: String, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum BackendError { + #[error("native command denied: {0}")] + Denied(String), + #[error("environment is required for this backend")] + MissingEnvironment, + #[error("command failed to execute: {0}")] + Command(String), + #[error("task execution cancelled: {0}")] + Cancelled(String), + #[error("artifact staging failed: {0}")] + Artifact(String), + #[error("unsupported environment kind for backend")] + UnsupportedEnvironment, + #[error("node cannot freeze task `{task}` for the current debug epoch")] + DebugFreezeUnsupported { task: TaskInstanceId }, +} + +pub trait CommandBackend { + fn kind(&self) -> CommandBackendKind; + fn plan(&self, invocation: &CommandInvocation) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct LinuxRootlessPodmanBackend; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PodmanCommand { + pub program: String, + pub args: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessOutput { + pub status_code: Option, + pub stdout: Vec, + pub stderr: Vec, +} + +pub trait ProcessRunner { + fn run(&mut self, command: &PodmanCommand) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct StdProcessRunner; + +impl ProcessRunner for StdProcessRunner { + fn run(&mut self, command: &PodmanCommand) -> Result { + let output = std::process::Command::new(&command.program) + .args(&command.args) + .output() + .map_err(|err| BackendError::Command(format!("{err:#}")))?; + + Ok(ProcessOutput { + status_code: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PodmanEnvironmentMaterialization { + pub environment: String, + pub image_tag: String, + pub build: PodmanCommand, + pub rootless_user_podman: bool, + pub embeds_full_image_in_bundle: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalSourceCheckout { + pub host_path: PathBuf, + pub snapshot: Digest, +} + +#[derive(Clone, Debug)] +pub struct LocalCheckoutTaskRequest<'a> { + pub process: ProcessId, + pub virtual_thread: TaskInstanceId, + pub invocation: &'a CommandInvocation, + pub checkout: LocalSourceCheckout, + pub output_root: PathBuf, + pub stage_stdout_as: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceAccessMode { + LocalCheckoutBindMount { + host_path: PathBuf, + container_path: String, + read_only: bool, + snapshot: Digest, + }, + NodePreparedSnapshot { + node_path: PathBuf, + container_path: String, + snapshot: Digest, + }, +} + +impl SourceAccessMode { + pub fn uses_full_repo_tarball(&self) -> bool { + false + } + + pub fn coordinator_routed_file_reads(&self) -> bool { + false + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LinuxTaskState { + Running, + Frozen, + Cancelled, + Completed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinuxTaskLifecycle { + pub process: ProcessId, + pub virtual_thread: TaskInstanceId, + pub log_stream: String, + pub cancellation_token: String, + pub debug_participant: String, + pub freeze_supported: bool, + pub state: LinuxTaskState, +} + +impl LinuxTaskLifecycle { + pub fn new(process: ProcessId, virtual_thread: TaskInstanceId) -> Self { + Self { + log_stream: format!("process/{process}/task/{virtual_thread}/logs"), + cancellation_token: format!("cancel:{process}:{virtual_thread}"), + debug_participant: format!("debug:{process}:{virtual_thread}"), + process, + virtual_thread, + freeze_supported: true, + state: LinuxTaskState::Running, + } + } + + pub fn freeze_for_debug_epoch(&mut self) -> Result<(), BackendError> { + if !self.freeze_supported { + return Err(BackendError::DebugFreezeUnsupported { + task: self.virtual_thread.clone(), + }); + } + self.state = LinuxTaskState::Frozen; + Ok(()) + } + + pub fn resume_after_debug_epoch(&mut self) { + if self.state == LinuxTaskState::Frozen { + self.state = LinuxTaskState::Running; + } + } + + pub fn cancel(&mut self) { + self.state = LinuxTaskState::Cancelled; + } + + pub fn complete(&mut self) { + self.state = LinuxTaskState::Completed; + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinuxCommandRunPlan { + pub process: ProcessId, + pub virtual_thread: TaskInstanceId, + pub image_tag: String, + pub run: PodmanCommand, + pub source_access: SourceAccessMode, + pub output_root: PathBuf, + pub stage_stdout_as: Option, + pub uses_full_repo_tarball: bool, + pub coordinator_routed_file_reads: bool, + pub lifecycle: LinuxTaskLifecycle, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinuxCommandTaskOutput { + pub virtual_thread: TaskInstanceId, + pub status_code: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub log_backpressured: bool, + pub staged_artifact: Option, + pub lifecycle: LinuxTaskLifecycle, +} + +impl LinuxRootlessPodmanBackend { + pub fn materialize_environment( + &self, + env: &disasmer_core::EnvironmentResource, + ) -> Result { + match env.kind { + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {} + EnvironmentKind::NixFlake => return Err(BackendError::UnsupportedEnvironment), + } + + let image_tag = self.image_tag(env); + Ok(PodmanEnvironmentMaterialization { + environment: env.name.clone(), + image_tag: image_tag.clone(), + build: PodmanCommand { + program: "podman".to_owned(), + args: vec![ + "build".to_owned(), + "--pull=never".to_owned(), + "--tag".to_owned(), + image_tag, + "--file".to_owned(), + env.recipe_path.to_string_lossy().into_owned(), + env.context_path.to_string_lossy().into_owned(), + ], + }, + rootless_user_podman: true, + embeds_full_image_in_bundle: false, + }) + } + + pub fn plan_local_checkout_run( + &self, + process: ProcessId, + virtual_thread: TaskInstanceId, + invocation: &CommandInvocation, + checkout: LocalSourceCheckout, + output_root: PathBuf, + stage_stdout_as: Option, + ) -> Result { + let env = invocation + .env + .as_ref() + .ok_or(BackendError::MissingEnvironment)?; + let materialization = self.materialize_environment(env)?; + let source_access = SourceAccessMode::LocalCheckoutBindMount { + host_path: checkout.host_path.clone(), + container_path: "/workspace".to_owned(), + read_only: true, + snapshot: checkout.snapshot, + }; + let lifecycle = LinuxTaskLifecycle::new(process.clone(), virtual_thread.clone()); + let network = match invocation.network { + disasmer_core::CommandNetworkPolicy::Disabled => "none", + disasmer_core::CommandNetworkPolicy::Enabled => "slirp4netns", + }; + let mut args = vec![ + "run".to_owned(), + "--rm".to_owned(), + "--network".to_owned(), + network.to_owned(), + "--cpus".to_owned(), + "2".to_owned(), + "--memory".to_owned(), + "2g".to_owned(), + "--pids-limit".to_owned(), + "256".to_owned(), + "--security-opt".to_owned(), + "no-new-privileges".to_owned(), + "--cap-drop".to_owned(), + "all".to_owned(), + "--volume".to_owned(), + format!("{}:/workspace:ro,Z", checkout.host_path.to_string_lossy()), + "--volume".to_owned(), + format!("{}:/disasmer/output:rw,Z", output_root.to_string_lossy()), + "--env".to_owned(), + "CARGO_TARGET_DIR=/disasmer/output/target".to_owned(), + "--workdir".to_owned(), + invocation.working_directory.clone(), + ]; + for (name, value) in &invocation.environment_variables { + args.push("--env".to_owned()); + args.push(format!("{name}={value}")); + } + args.push(materialization.image_tag.clone()); + args.push(invocation.program.clone()); + args.extend(invocation.args.iter().cloned()); + + Ok(LinuxCommandRunPlan { + process, + virtual_thread, + image_tag: materialization.image_tag, + run: PodmanCommand { + program: "podman".to_owned(), + args, + }, + source_access, + output_root, + stage_stdout_as, + uses_full_repo_tarball: false, + coordinator_routed_file_reads: false, + lifecycle, + }) + } + + pub fn execute_environment_materialization( + &self, + env: &disasmer_core::EnvironmentResource, + runner: &mut impl ProcessRunner, + ) -> Result { + let materialization = self.materialize_environment(env)?; + let output = runner.run(&materialization.build)?; + if output.status_code != Some(0) { + return Err(BackendError::Command(format!( + "podman build for environment `{}` failed with status {:?}: {}", + materialization.environment, + output.status_code, + String::from_utf8_lossy(&output.stderr) + ))); + } + + Ok(MaterializedEnvironment { + name: materialization.environment, + backend: CommandBackendKind::LinuxRootlessPodman, + local_reference: materialization.image_tag, + }) + } + + pub fn execute_run_plan( + &self, + plan: LinuxCommandRunPlan, + runner: &mut impl ProcessRunner, + overlay: &mut VfsOverlay, + ) -> Result { + self.execute_run_plan_with_log_limit(plan, runner, overlay, DEFAULT_COMMAND_LOG_LIMIT_BYTES) + } + + pub fn execute_run_plan_with_log_limit( + &self, + mut plan: LinuxCommandRunPlan, + runner: &mut impl ProcessRunner, + overlay: &mut VfsOverlay, + max_log_bytes: usize, + ) -> Result { + let output = runner.run(&plan.run)?; + let logs = capture_command_logs( + &plan.virtual_thread, + &output.stdout, + &output.stderr, + max_log_bytes, + ); + let staged_artifact = if output.status_code == Some(0) { + if let Some(path) = plan.stage_stdout_as.take() { + Some(overlay.write( + path, + Digest::sha256(&output.stdout), + output.stdout.len() as u64, + )) + } else { + None + } + } else { + None + }; + + if output.status_code == Some(0) { + plan.lifecycle.complete(); + } + + Ok(LinuxCommandTaskOutput { + virtual_thread: plan.virtual_thread, + status_code: output.status_code, + stdout: logs.stdout, + stderr: logs.stderr, + stdout_truncated: logs.stdout_truncated, + stderr_truncated: logs.stderr_truncated, + log_backpressured: logs.backpressured, + staged_artifact, + lifecycle: plan.lifecycle, + }) + } + + pub fn execute_local_checkout_task( + &self, + request: LocalCheckoutTaskRequest<'_>, + runner: &mut impl ProcessRunner, + overlay: &mut VfsOverlay, + ) -> Result { + let env = request + .invocation + .env + .as_ref() + .ok_or(BackendError::MissingEnvironment)?; + self.execute_environment_materialization(env, runner)?; + let plan = self.plan_local_checkout_run( + request.process, + request.virtual_thread, + request.invocation, + request.checkout, + request.output_root, + request.stage_stdout_as, + )?; + self.execute_run_plan(plan, runner, overlay) + } + + fn image_tag(&self, env: &disasmer_core::EnvironmentResource) -> String { + let name = env + .name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .collect::(); + let digest = env + .digest + .as_str() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { + ch + } else { + '-' + } + }) + .take(24) + .collect::(); + format!("disasmer-env/{name}:{digest}") + } +} + +impl CommandBackend for LinuxRootlessPodmanBackend { + fn kind(&self) -> CommandBackendKind { + CommandBackendKind::LinuxRootlessPodman + } + + fn plan(&self, invocation: &CommandInvocation) -> Result { + let env = invocation + .env + .as_ref() + .ok_or(BackendError::MissingEnvironment)?; + match env.kind { + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => Ok(CommandPlan { + guest_runtime: GuestRuntimeKind::Wasmtime, + backend: CommandBackendKind::LinuxRootlessPodman, + required_capability: Capability::RootlessPodman, + user_attached_development_execution: false, + }), + EnvironmentKind::NixFlake => Err(BackendError::UnsupportedEnvironment), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::path::PathBuf; + + use disasmer_core::{ + DebugRuntimeState, Digest, EnvironmentRequirements, EnvironmentResource, NodeId, ProjectId, + TenantId, + }; + + use super::*; + + #[derive(Default)] + struct RecordingRunner { + commands: Vec, + outputs: VecDeque, + } + + impl RecordingRunner { + fn with_outputs(outputs: impl IntoIterator) -> Self { + Self { + commands: Vec::new(), + outputs: outputs.into_iter().collect(), + } + } + } + + impl ProcessRunner for RecordingRunner { + fn run(&mut self, command: &PodmanCommand) -> Result { + self.commands.push(command.clone()); + self.outputs.pop_front().ok_or_else(|| { + BackendError::Command("recording runner has no output queued".to_owned()) + }) + } + } + + fn success_output(stdout: impl Into>) -> ProcessOutput { + ProcessOutput { + status_code: Some(0), + stdout: stdout.into(), + stderr: Vec::new(), + } + } + + fn container_env() -> EnvironmentResource { + EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: PathBuf::from("envs/linux/Containerfile"), + context_path: PathBuf::from("envs/linux"), + digest: Digest::sha256("recipe"), + requirements: EnvironmentRequirements::linux_container(), + } + } + + #[test] + fn linux_backend_plans_rootless_podman_under_wasmtime_virtual_task() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: Some(container_env()), + }; + let plan = LinuxRootlessPodmanBackend.plan(&invocation).unwrap(); + + assert_eq!(plan.guest_runtime, GuestRuntimeKind::Wasmtime); + assert_eq!(plan.backend, CommandBackendKind::LinuxRootlessPodman); + assert_eq!(plan.required_capability, Capability::RootlessPodman); + } + + #[test] + fn linux_backend_materializes_containerfile_with_rootless_podman_without_vendored_image() { + let env = container_env(); + let materialization = LinuxRootlessPodmanBackend + .materialize_environment(&env) + .unwrap(); + + assert_eq!(materialization.environment, "linux"); + assert!(materialization.image_tag.starts_with("disasmer-env/linux:")); + assert_eq!(materialization.image_tag.matches(':').count(), 1); + assert_eq!(materialization.build.program, "podman"); + assert!(materialization.rootless_user_podman); + assert!(!materialization.embeds_full_image_in_bundle); + assert!(materialization.build.args.contains(&"build".to_owned())); + assert!(materialization + .build + .args + .contains(&"--pull=never".to_owned())); + assert!(materialization + .build + .args + .contains(&"envs/linux/Containerfile".to_owned())); + assert!(materialization + .build + .args + .contains(&"envs/linux".to_owned())); + } + + #[test] + fn linux_run_plan_keeps_local_checkout_local_and_avoids_coordinator_file_reads() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned(), "--release".to_owned()], + working_directory: "/workspace/crate".to_owned(), + environment_variables: std::collections::BTreeMap::from([( + "BUILD_MODE".to_owned(), + "release".to_owned(), + )]), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: Some(container_env()), + }; + let plan = LinuxRootlessPodmanBackend + .plan_local_checkout_run( + ProcessId::from("vp"), + TaskInstanceId::from("compile-linux"), + &invocation, + LocalSourceCheckout { + host_path: PathBuf::from("/work/example"), + snapshot: Digest::sha256("checkout"), + }, + PathBuf::from("/work/output"), + Some(VfsPath::new("/vfs/artifacts/app").unwrap()), + ) + .unwrap(); + + assert_eq!(plan.run.program, "podman"); + assert!(plan.run.args.contains(&"run".to_owned())); + assert!(plan.run.args.contains(&"--network".to_owned())); + assert!(plan.run.args.contains(&"none".to_owned())); + assert!(plan.run.args.contains(&"--cpus".to_owned())); + assert!(plan.run.args.contains(&"--memory".to_owned())); + assert!(plan.run.args.contains(&"--pids-limit".to_owned())); + assert!(plan.run.args.contains(&"no-new-privileges".to_owned())); + assert!(plan.run.args.contains(&"all".to_owned())); + assert!(plan.run.args.contains(&"/workspace/crate".to_owned())); + assert!(plan.run.args.contains(&"BUILD_MODE=release".to_owned())); + assert!(plan + .run + .args + .contains(&"/work/example:/workspace:ro,Z".to_owned())); + assert!(plan.run.args.contains(&"cargo".to_owned())); + assert!(plan.run.args.contains(&"--release".to_owned())); + assert!(!plan.uses_full_repo_tarball); + assert!(!plan.coordinator_routed_file_reads); + assert!(!plan.source_access.uses_full_repo_tarball()); + assert!(!plan.source_access.coordinator_routed_file_reads()); + assert_eq!( + plan.stage_stdout_as, + Some(VfsPath::new("/vfs/artifacts/app").unwrap()) + ); + assert_eq!(plan.lifecycle.process, ProcessId::from("vp")); + assert_eq!( + plan.lifecycle.virtual_thread, + TaskInstanceId::from("compile-linux") + ); + assert_eq!(plan.lifecycle.state, LinuxTaskState::Running); + + match &plan.source_access { + SourceAccessMode::LocalCheckoutBindMount { + host_path, + container_path, + read_only, + .. + } => { + assert_eq!(host_path, &PathBuf::from("/work/example")); + assert_eq!(container_path, "/workspace"); + assert!(*read_only); + } + SourceAccessMode::NodePreparedSnapshot { .. } => { + panic!("local Linux build should use the node-local checkout") + } + } + } + + #[test] + fn linux_backend_executes_podman_build_then_run_and_stages_artifact() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: Some(container_env()), + }; + let mut runner = + RecordingRunner::with_outputs([success_output([]), success_output(b"artifact-bytes")]); + let mut overlay = + VfsOverlay::new(TaskInstanceId::from("compile-linux"), NodeId::from("node")); + + let output = LinuxRootlessPodmanBackend + .execute_local_checkout_task( + LocalCheckoutTaskRequest { + process: ProcessId::from("vp"), + virtual_thread: TaskInstanceId::from("compile-linux"), + invocation: &invocation, + checkout: LocalSourceCheckout { + host_path: PathBuf::from("/work/demo"), + snapshot: Digest::sha256("checkout"), + }, + output_root: PathBuf::from("/work/output"), + stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap()), + }, + &mut runner, + &mut overlay, + ) + .unwrap(); + let manifest = overlay.flush(); + + assert_eq!(runner.commands.len(), 2); + assert_eq!(runner.commands[0].program, "podman"); + assert!(runner.commands[0].args.contains(&"build".to_owned())); + assert_eq!(runner.commands[1].program, "podman"); + assert!(runner.commands[1].args.contains(&"run".to_owned())); + assert!(runner.commands[1] + .args + .contains(&"/work/demo:/workspace:ro,Z".to_owned())); + assert_eq!(output.status_code, Some(0)); + assert_eq!(output.stdout, "artifact-bytes"); + assert_eq!(output.lifecycle.state, LinuxTaskState::Completed); + assert!(output.staged_artifact.is_some()); + assert!(manifest + .objects + .contains_key(&VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap())); + assert!(!manifest.large_bytes_uploaded); + } + + #[test] + fn linux_backend_caps_logs_without_truncating_staged_artifact_bytes() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: Some(container_env()), + }; + let mut runner = + RecordingRunner::with_outputs([success_output(b"abcdef"), success_output(b"unused")]); + let mut overlay = + VfsOverlay::new(TaskInstanceId::from("compile-linux"), NodeId::from("node")); + let plan = LinuxRootlessPodmanBackend + .plan_local_checkout_run( + ProcessId::from("vp"), + TaskInstanceId::from("compile-linux"), + &invocation, + LocalSourceCheckout { + host_path: PathBuf::from("/work/demo"), + snapshot: Digest::sha256("checkout"), + }, + PathBuf::from("/work/output"), + Some(VfsPath::new("/vfs/artifacts/app.txt").unwrap()), + ) + .unwrap(); + + let output = LinuxRootlessPodmanBackend + .execute_run_plan_with_log_limit(plan, &mut runner, &mut overlay, 4) + .unwrap(); + + assert_eq!(output.virtual_thread, TaskInstanceId::from("compile-linux")); + assert_eq!(output.stdout, "abcd"); + assert!(output.stdout_truncated); + assert!(output.log_backpressured); + assert_eq!(output.staged_artifact.as_ref().unwrap().size, 6); + } + + #[test] + fn failed_podman_materialization_returns_clear_error_before_command_run() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: Some(container_env()), + }; + let mut runner = RecordingRunner::with_outputs([ProcessOutput { + status_code: Some(125), + stdout: Vec::new(), + stderr: b"image build failed".to_vec(), + }]); + let mut overlay = + VfsOverlay::new(TaskInstanceId::from("compile-linux"), NodeId::from("node")); + + let error = LinuxRootlessPodmanBackend + .execute_local_checkout_task( + LocalCheckoutTaskRequest { + process: ProcessId::from("vp"), + virtual_thread: TaskInstanceId::from("compile-linux"), + invocation: &invocation, + checkout: LocalSourceCheckout { + host_path: PathBuf::from("/work/demo"), + snapshot: Digest::sha256("checkout"), + }, + output_root: PathBuf::from("/work/output"), + stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap()), + }, + &mut runner, + &mut overlay, + ) + .unwrap_err(); + + assert!(error.to_string().contains("podman build")); + assert!(error.to_string().contains("image build failed")); + assert_eq!(runner.commands.len(), 1); + } + + #[test] + fn linux_task_lifecycle_supports_cancel_and_all_stop_freeze_resume() { + let mut lifecycle = + LinuxTaskLifecycle::new(ProcessId::from("vp"), TaskInstanceId::from("compile-linux")); + + lifecycle.freeze_for_debug_epoch().unwrap(); + assert_eq!(lifecycle.state, LinuxTaskState::Frozen); + + lifecycle.resume_after_debug_epoch(); + assert_eq!(lifecycle.state, LinuxTaskState::Running); + + lifecycle.cancel(); + assert_eq!(lifecycle.state, LinuxTaskState::Cancelled); + + let mut unsupported = LinuxTaskLifecycle::new( + ProcessId::from("vp"), + TaskInstanceId::from("native-command"), + ); + unsupported.freeze_supported = false; + let error = unsupported.freeze_for_debug_epoch().unwrap_err(); + assert!(matches!(error, BackendError::DebugFreezeUnsupported { .. })); + } + + #[test] + fn windows_backend_is_labeled_user_attached_dev_execution() { + let invocation = CommandInvocation { + program: "cmd".to_owned(), + args: vec!["/C".to_owned(), "build.bat".to_owned()], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: None, + }; + let plan = WindowsCommandDevBackend.plan(&invocation).unwrap(); + + assert!(plan.user_attached_development_execution); + assert_eq!(plan.required_capability, Capability::WindowsCommandDev); + } + + #[test] + fn hosted_control_plane_native_command_is_denied() { + let error = authorize_node_command(true, true).unwrap_err(); + + assert!(matches!(error, BackendError::Denied(_))); + } + + #[test] + fn native_command_is_denied_without_command_capability() { + let error = authorize_node_command(false, false).unwrap_err(); + + assert!(matches!(error, BackendError::Denied(_))); + assert!(error + .to_string() + .contains("lacks native command capability")); + } + + #[test] + fn wasmtime_runtime_runs_named_task_export() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let wasm = r#" + (module + (func (export "task_add_one") (param i32) (result i32) + local.get 0 + i32.const 1 + i32.add)) + "#; + let result = runtime.run_i32_export(wasm, "task_add_one", 41).unwrap(); + + assert_eq!(result, 42); + + let verified = runtime + .run_i32_export_verified(wasm, &Digest::sha256(wasm), "task_add_one", 41) + .unwrap(); + assert_eq!(verified, 42); + } + + #[test] + fn wasmtime_runtime_rejects_bundle_digest_mismatch_before_compilation() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let error = runtime + .run_i32_export_verified( + "not a valid wasm module", + &Digest::sha256("different task bundle bytes"), + "task_add_one", + 41, + ) + .unwrap_err(); + + assert!(matches!(error, WasmTaskError::BundleDigestMismatch { .. })); + assert!(!error.to_string().contains("failed to parse")); + } + + #[test] + fn wasmtime_runtime_freezes_and_resumes_wasm_debug_participant() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let probe = runtime + .freeze_resume_i32_export_probe( + r#" + (module + (func (export "task_add_one") (param i32) (result i32) + local.get 0 + i32.const 1 + i32.add)) + "#, + "task_add_one", + 41, + ) + .unwrap(); + + assert_eq!(probe.task, TaskInstanceId::from("task_add_one")); + assert_eq!(probe.frozen_state, DebugRuntimeState::Frozen); + assert_eq!(probe.resumed_state, DebugRuntimeState::Running); + assert_eq!(probe.result, 42); + assert!(probe + .stack_frames + .iter() + .any(|frame| frame.contains("task_add_one"))); + assert!(probe + .local_values + .iter() + .any(|(name, value)| { name == "wasm_local_0" && value.contains("41") })); + assert!(probe.wasm_pc.is_some()); + } + + #[cfg(unix)] + #[test] + fn native_command_output_is_associated_with_virtual_thread_and_staged_to_vfs() { + let executor = LocalCommandExecutor { + node: disasmer_core::NodeId::from("node"), + hosted_control_plane: false, + has_command_capability: true, + }; + let mut overlay = + VfsOverlay::new(TaskInstanceId::from("compile-linux"), NodeId::from("node")); + let output = executor + .run( + VirtualThreadCommand { + virtual_thread: TaskInstanceId::from("compile-linux"), + invocation: CommandInvocation { + program: "sh".to_owned(), + args: vec![ + "-c".to_owned(), + "printf artifact; printf log >&2".to_owned(), + ], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: None, + }, + stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/app.txt").unwrap()), + }, + &mut overlay, + ) + .unwrap(); + let manifest = overlay.flush(); + + assert_eq!(output.virtual_thread, TaskInstanceId::from("compile-linux")); + assert_eq!(output.stdout, "artifact"); + assert_eq!(output.stderr, "log"); + assert!(output.staged_artifact.is_some()); + assert!(!manifest.large_bytes_uploaded); + assert!(manifest + .objects + .contains_key(&VfsPath::new("/vfs/artifacts/app.txt").unwrap())); + } + + #[cfg(unix)] + #[test] + fn local_command_executor_caps_logs_and_reports_backpressure_by_virtual_thread() { + let executor = LocalCommandExecutor { + node: disasmer_core::NodeId::from("node"), + hosted_control_plane: false, + has_command_capability: true, + }; + let mut overlay = + VfsOverlay::new(TaskInstanceId::from("compile-linux"), NodeId::from("node")); + let output = executor + .run_with_log_limit( + VirtualThreadCommand { + virtual_thread: TaskInstanceId::from("compile-linux"), + invocation: CommandInvocation { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "printf abcdef; printf err >&2".to_owned()], + working_directory: "/workspace".to_owned(), + environment_variables: Default::default(), + timeout_ms: 60_000, + network: disasmer_core::CommandNetworkPolicy::Disabled, + env: None, + }, + stage_stdout_as: None, + }, + &mut overlay, + 4, + ) + .unwrap(); + + assert_eq!(output.virtual_thread, TaskInstanceId::from("compile-linux")); + assert_eq!(output.stdout, "abcd"); + assert_eq!(output.stderr, ""); + assert!(output.stdout_truncated); + assert!(output.stderr_truncated); + assert!(output.log_backpressured); + } + + #[test] + fn public_node_crate_does_not_require_hosted_private_types() { + let _tenant = TenantId::from("tenant"); + let _project = ProjectId::from("project"); + let _backend = LinuxRootlessPodmanBackend; + } +} diff --git a/crates/disasmer-node/src/main.rs b/crates/disasmer-node/src/main.rs new file mode 100644 index 0000000..8f2960b --- /dev/null +++ b/crates/disasmer-node/src/main.rs @@ -0,0 +1,12 @@ +mod assignment_runner; +mod coordinator_session; +mod daemon; +mod debug_agent; +mod node_identity; +mod source_snapshot; +mod task_artifacts; +mod task_reports; + +fn main() -> Result<(), Box> { + daemon::run() +} diff --git a/crates/disasmer-node/src/node_identity.rs b/crates/disasmer-node/src/node_identity.rs new file mode 100644 index 0000000..75edb1b --- /dev/null +++ b/crates/disasmer-node/src/node_identity.rs @@ -0,0 +1,224 @@ +use std::path::{Path, PathBuf}; + +use disasmer_core::{ + generate_ed25519_private_key, node_ed25519_public_key_from_private_key, sign_node_request, + signed_request_payload_digest, NodeId, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::coordinator_session::CoordinatorSession; +use crate::daemon::Args; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct StoredNodeCredential { + kind: String, + node: String, + private_key: String, + public_key: String, + credential_scope: String, +} + +pub(crate) fn establish_node_identity( + session: &mut CoordinatorSession, + args: &Args, + node_private_key: &str, +) -> Result> { + let derived_public_key = + node_ed25519_public_key_from_private_key(node_private_key).map_err(invalid_key_error)?; + let public_key = args + .public_key + .clone() + .unwrap_or(derived_public_key.clone()); + if public_key != derived_public_key { + return Err( + "node --public-key must match DISASMER_NODE_PRIVATE_KEY or the stored local node credential" + .into(), + ); + } + if let Some(grant) = &args.enrollment_grant { + session.request(json!({ + "type": "exchange_node_enrollment_grant", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + "public_key": public_key, + "enrollment_grant": grant, + })) + } else { + Ok(json!({ + "type": "node_identity_reused", + "node": &args.node, + "credential_source": "stored_project_node_identity", + "subsequent_authentication": "signed_node_requests", + })) + } +} + +pub(crate) fn node_private_key_for_runtime( + node: &str, +) -> Result> { + if let Ok(private_key) = std::env::var("DISASMER_NODE_PRIVATE_KEY") { + return Ok(private_key); + } + load_or_create_local_node_credential(&std::env::current_dir()?, node) +} + +pub(crate) fn load_or_create_local_node_credential( + project: &Path, + node: &str, +) -> Result> { + let file = local_node_credential_file(project, node); + if credential_file_exists_without_symlink(&file)? { + let bytes = std::fs::read(&file)?; + let credential: StoredNodeCredential = serde_json::from_slice(&bytes)?; + if credential.node != node { + return Err(format!( + "stored node credential {} belongs to node `{}` instead of `{}`", + file.display(), + credential.node, + node + ) + .into()); + } + let public_key = node_ed25519_public_key_from_private_key(&credential.private_key) + .map_err(invalid_key_error)?; + if public_key != credential.public_key { + return Err(format!( + "stored node credential {} has a public key that does not match its private key", + file.display() + ) + .into()); + } + return Ok(credential.private_key); + } + + let private_key = generate_ed25519_private_key().map_err(invalid_key_error)?; + let public_key = + node_ed25519_public_key_from_private_key(&private_key).map_err(invalid_key_error)?; + let credential = StoredNodeCredential { + kind: "disasmer_node_credential".to_owned(), + node: node.to_owned(), + private_key: private_key.clone(), + public_key, + credential_scope: "local_project_node_identity".to_owned(), + }; + persist_node_credential(&file, &credential)?; + Ok(private_key) +} + +fn credential_file_exists_without_symlink(file: &Path) -> Result> { + match std::fs::symlink_metadata(file) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(format!( + "refusing to read node credential through symbolic link {}", + file.display() + ) + .into()), + Ok(metadata) if !metadata.is_file() => Err(format!( + "node credential path {} is not a regular file", + file.display() + ) + .into()), + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn persist_node_credential( + file: &Path, + credential: &StoredNodeCredential, +) -> Result<(), Box> { + use std::io::Write; + + let parent = file + .parent() + .ok_or_else(|| format!("node credential path {} has no parent", file.display()))?; + std::fs::create_dir_all(parent)?; + if std::fs::symlink_metadata(parent)?.file_type().is_symlink() { + return Err(format!( + "refusing to store node credential through symbolic-link directory {}", + parent.display() + ) + .into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + temporary + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + temporary.write_all(&serde_json::to_vec_pretty(credential)?)?; + temporary.as_file().sync_all()?; + temporary.persist_noclobber(file).map_err(|error| { + format!( + "refusing to overwrite node credential {}: {}", + file.display(), + error.error + ) + })?; + Ok(()) +} + +pub(crate) fn local_node_credential_file(project: &Path, node: &str) -> PathBuf { + let digest = disasmer_core::Digest::sha256(node); + let file_stem = digest.as_str().trim_start_matches("sha256:"); + project + .join(".disasmer") + .join("nodes") + .join(format!("{file_stem}.json")) +} + +pub(crate) fn node_nonce(prefix: &str) -> String { + format!("{prefix}-{}-{}", unix_timestamp_nanos(), std::process::id()) +} + +pub(crate) fn unix_timestamp_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +pub(crate) fn signed_node_request_json( + args: &Args, + node_private_key: &str, + request_kind: &str, + request: Value, +) -> Result> { + let payload_digest = signed_request_payload_digest(&request); + let node_signature = sign_node_request( + node_private_key, + &NodeId::from(args.node.as_str()), + request_kind, + &payload_digest, + node_nonce(request_kind), + unix_timestamp_seconds(), + ) + .map_err(invalid_key_error)?; + Ok(json!({ + "type": "signed_node", + "node": &args.node, + "node_signature": node_signature, + "request": request, + })) +} + +fn invalid_key_error(error: String) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidInput, error) +} + +pub(crate) fn unix_timestamp_nanos() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) +} diff --git a/crates/disasmer-node/src/source_snapshot.rs b/crates/disasmer-node/src/source_snapshot.rs new file mode 100644 index 0000000..5d05cfd --- /dev/null +++ b/crates/disasmer-node/src/source_snapshot.rs @@ -0,0 +1,424 @@ +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use disasmer_core::Digest; +use sha2::{Digest as _, Sha256}; + +const MAX_SNAPSHOT_FILES: usize = 20_000; +const MAX_SNAPSHOT_FILE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_SNAPSHOT_TOTAL_BYTES: u64 = 1024 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SourceSnapshotInventory { + pub(crate) digest: Digest, + pub(crate) provider: &'static str, + pub(crate) file_count: usize, + pub(crate) total_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SnapshotFile { + path: String, + kind: &'static str, + executable: bool, + digest: Digest, + size_bytes: u64, +} + +pub(crate) fn snapshot_project(project_root: &Path) -> Result { + let project_root = project_root + .canonicalize() + .map_err(|error| format!("resolve source checkout: {error}"))?; + if !project_root.is_dir() { + return Err("source checkout root is not a directory".to_owned()); + } + if let Some(repository_root) = git_repository_root(&project_root)? { + snapshot_git_project(&repository_root, &project_root) + } else { + snapshot_filesystem_project(&project_root) + } +} + +fn snapshot_git_project( + repository_root: &Path, + project_root: &Path, +) -> Result { + let project_prefix = project_root + .strip_prefix(repository_root) + .map_err(|_| "source checkout is outside its discovered Git repository".to_owned())?; + let project_prefix = if project_prefix.as_os_str().is_empty() { + ".".to_owned() + } else { + slash_path(project_prefix)? + }; + let mut command = git_command(repository_root); + command + .args([ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--", + ]) + .arg(&project_prefix); + let output = command + .output() + .map_err(|error| format!("enumerate Git source snapshot: {error}"))?; + if !output.status.success() { + return Err(format!( + "enumerate Git source snapshot failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let mut files = Vec::new(); + for raw in output + .stdout + .split(|byte| *byte == 0) + .filter(|raw| !raw.is_empty()) + { + let repository_relative = + std::str::from_utf8(raw).map_err(|_| "Git source path is not UTF-8".to_owned())?; + let absolute = repository_root.join(repository_relative); + let relative = absolute + .strip_prefix(project_root) + .map_err(|_| "Git enumerated source outside the selected project".to_owned())?; + files.push(snapshot_file(&absolute, &slash_path(relative)?)?); + } + files.sort_by(|left, right| left.path.cmp(&right.path)); + files.dedup_by(|left, right| left.path == right.path); + validate_snapshot_bounds(&files)?; + + let head = + git_text(repository_root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unborn".to_owned()); + let roots = git_text(repository_root, &["rev-list", "--max-parents=0", "HEAD"]) + .unwrap_or_else(|| "unborn".to_owned()); + let remote = git_text(repository_root, &["config", "--get", "remote.origin.url"]) + .unwrap_or_else(|| "no-origin".to_owned()); + let submodules = git_text( + repository_root, + &["submodule", "status", "--recursive", "--", &project_prefix], + ) + .unwrap_or_else(|| "no-submodules".to_owned()); + finish_snapshot( + "git", + [ + b"node-source-snapshot:git:v1".to_vec(), + head.into_bytes(), + roots.into_bytes(), + remote.into_bytes(), + submodules.into_bytes(), + project_prefix.into_bytes(), + ], + files, + ) +} + +fn snapshot_filesystem_project(project_root: &Path) -> Result { + let mut paths = Vec::new(); + collect_filesystem_paths(project_root, project_root, &mut paths)?; + paths.sort(); + if paths.len() > MAX_SNAPSHOT_FILES { + return Err(format!( + "source snapshot has {} files; limit is {MAX_SNAPSHOT_FILES}", + paths.len() + )); + } + let mut files = Vec::with_capacity(paths.len()); + for absolute in paths { + let relative = absolute + .strip_prefix(project_root) + .map_err(|_| "filesystem source escaped its project root".to_owned())?; + files.push(snapshot_file(&absolute, &slash_path(relative)?)?); + } + validate_snapshot_bounds(&files)?; + finish_snapshot( + "filesystem", + [b"node-source-snapshot:filesystem:v1".to_vec()], + files, + ) +} + +fn finish_snapshot( + provider: &'static str, + identity_parts: [Vec; N], + files: Vec, +) -> Result { + let mut parts = identity_parts.into_iter().collect::>(); + let mut total_bytes = 0_u64; + for file in &files { + total_bytes = total_bytes + .checked_add(file.size_bytes) + .ok_or_else(|| "source snapshot byte accounting overflowed".to_owned())?; + parts.push(file.path.as_bytes().to_vec()); + parts.push(file.kind.as_bytes().to_vec()); + parts.push(if file.executable { b"x" } else { b"-" }.to_vec()); + parts.push(file.digest.as_str().as_bytes().to_vec()); + parts.push(file.size_bytes.to_string().into_bytes()); + } + Ok(SourceSnapshotInventory { + digest: Digest::from_parts(parts), + provider, + file_count: files.len(), + total_bytes, + }) +} + +fn snapshot_file(absolute: &Path, relative: &str) -> Result { + validate_relative_source_path(relative)?; + let metadata = match fs::symlink_metadata(absolute) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(SnapshotFile { + path: relative.to_owned(), + kind: "deleted", + executable: false, + digest: Digest::sha256("deleted"), + size_bytes: 0, + }) + } + Err(error) => return Err(error.to_string()), + }; + if metadata.file_type().is_symlink() { + let target = fs::read_link(absolute).map_err(|error| error.to_string())?; + let target = target + .to_str() + .ok_or_else(|| "source symlink target is not UTF-8".to_owned())?; + return Ok(SnapshotFile { + path: relative.to_owned(), + kind: "symlink", + executable: false, + digest: Digest::from_parts([b"source-symlink:v1".as_slice(), target.as_bytes()]), + size_bytes: target.len() as u64, + }); + } + if !metadata.is_file() { + return Err(format!("source snapshot entry `{relative}` is not a file")); + } + if metadata.len() > MAX_SNAPSHOT_FILE_BYTES { + return Err(format!( + "source file `{relative}` is {} bytes; per-file limit is {MAX_SNAPSHOT_FILE_BYTES}", + metadata.len() + )); + } + let mut file = fs::File::open(absolute).map_err(|error| error.to_string())?; + let mut hasher = Sha256::new(); + let mut size_bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|error| error.to_string())?; + if read == 0 { + break; + } + size_bytes = size_bytes + .checked_add(read as u64) + .ok_or_else(|| "source file size overflowed".to_owned())?; + if size_bytes > MAX_SNAPSHOT_FILE_BYTES { + return Err(format!( + "source file `{relative}` grew beyond {MAX_SNAPSHOT_FILE_BYTES} bytes while hashing" + )); + } + hasher.update(&buffer[..read]); + } + let digest = Digest::from_sha256_hex(&format!("{:x}", hasher.finalize()))?; + #[cfg(unix)] + let executable = { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + }; + #[cfg(not(unix))] + let executable = false; + Ok(SnapshotFile { + path: relative.to_owned(), + kind: "file", + executable, + digest, + size_bytes, + }) +} + +fn validate_snapshot_bounds(files: &[SnapshotFile]) -> Result<(), String> { + if files.len() > MAX_SNAPSHOT_FILES { + return Err(format!( + "source snapshot has {} files; limit is {MAX_SNAPSHOT_FILES}", + files.len() + )); + } + let total = files + .iter() + .try_fold(0_u64, |total, file| total.checked_add(file.size_bytes)) + .ok_or_else(|| "source snapshot byte accounting overflowed".to_owned())?; + if total > MAX_SNAPSHOT_TOTAL_BYTES { + return Err(format!( + "source snapshot is {total} bytes; limit is {MAX_SNAPSHOT_TOTAL_BYTES}" + )); + } + Ok(()) +} + +fn collect_filesystem_paths( + root: &Path, + directory: &Path, + paths: &mut Vec, +) -> Result<(), String> { + for entry in fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let name = entry + .file_name() + .into_string() + .map_err(|_| "source path is not UTF-8".to_owned())?; + if directory == root && matches!(name.as_str(), ".git" | ".disasmer" | "target") { + continue; + } + let file_type = entry.file_type().map_err(|error| error.to_string())?; + if file_type.is_dir() { + collect_filesystem_paths(root, &entry.path(), paths)?; + } else if file_type.is_file() || file_type.is_symlink() { + paths.push(entry.path()); + if paths.len() > MAX_SNAPSHOT_FILES { + return Err(format!( + "source snapshot exceeds file-count limit of {MAX_SNAPSHOT_FILES}" + )); + } + } + } + Ok(()) +} + +fn git_repository_root(project_root: &Path) -> Result, String> { + let output = git_command(project_root) + .args(["rev-parse", "--show-toplevel"]) + .output(); + let Ok(output) = output else { + return Ok(None); + }; + if !output.status.success() { + return Ok(None); + } + let root = String::from_utf8(output.stdout) + .map_err(|_| "Git repository root is not UTF-8".to_owned())?; + let root = PathBuf::from(root.trim()) + .canonicalize() + .map_err(|error| error.to_string())?; + Ok(Some(root)) +} + +fn git_text(repository_root: &Path, args: &[&str]) -> Option { + let output = git_command(repository_root).args(args).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim().to_owned()) +} + +fn git_command(repository_root: &Path) -> Command { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(repository_root) + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0"); + command +} + +fn slash_path(path: &Path) -> Result { + let mut result = String::new(); + for component in path.components() { + let component = component + .as_os_str() + .to_str() + .ok_or_else(|| "source path is not UTF-8".to_owned())?; + if !result.is_empty() { + result.push('/'); + } + result.push_str(component); + } + Ok(result) +} + +fn validate_relative_source_path(path: &str) -> Result<(), String> { + if path.is_empty() + || path.starts_with('/') + || path.split('/').any(|component| { + component.is_empty() + || component == "." + || component == ".." + || component.contains('\0') + }) + { + return Err("source snapshot path must be a safe relative path".to_owned()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn git(root: &Path, args: &[&str]) { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .env("GIT_AUTHOR_NAME", "Disasmer Test") + .env("GIT_AUTHOR_EMAIL", "test@disasmer.invalid") + .env("GIT_COMMITTER_NAME", "Disasmer Test") + .env("GIT_COMMITTER_EMAIL", "test@disasmer.invalid") + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn initialize_repository(root: &Path, message: &str) { + git(root, &["init", "--quiet"]); + git(root, &["add", "."]); + git(root, &["commit", "--quiet", "-m", message]); + } + + #[test] + fn dirty_and_untracked_content_changes_snapshot_identity() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("src/lib.rs"), "pub fn value() -> u8 { 1 }").unwrap(); + initialize_repository(temp.path(), "initial source"); + let first = snapshot_project(temp.path()).unwrap(); + + fs::write(temp.path().join("src/lib.rs"), "pub fn value() -> u8 { 2 }").unwrap(); + let dirty = snapshot_project(temp.path()).unwrap(); + fs::write( + temp.path().join("fixture.c"), + "int main(void) { return 0; }", + ) + .unwrap(); + let untracked = snapshot_project(temp.path()).unwrap(); + + assert_ne!(first.digest, dirty.digest); + assert_ne!(dirty.digest, untracked.digest); + assert_eq!(untracked.provider, "git"); + assert_eq!(untracked.file_count, 2); + } + + #[test] + fn different_repositories_at_the_same_path_are_not_identified_by_path() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("source.txt"), "identical source content").unwrap(); + initialize_repository(temp.path(), "first repository identity"); + let first = snapshot_project(temp.path()).unwrap(); + + fs::remove_dir_all(temp.path().join(".git")).unwrap(); + initialize_repository(temp.path(), "second repository identity"); + let second = snapshot_project(temp.path()).unwrap(); + + assert_ne!(first.digest, second.digest); + assert_eq!(first.provider, "git"); + assert_eq!(second.provider, "git"); + } +} diff --git a/crates/disasmer-node/src/task_artifacts.rs b/crates/disasmer-node/src/task_artifacts.rs new file mode 100644 index 0000000..f604d16 --- /dev/null +++ b/crates/disasmer-node/src/task_artifacts.rs @@ -0,0 +1,984 @@ +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{ArtifactId, Digest, NodeId, TaskInstanceId, VfsManifest, VfsOverlay}; +use sha2::{Digest as _, Sha256}; + +const MAX_NODE_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024; +const MAX_NODE_ARTIFACT_CHUNK_BYTES: u64 = 256 * 1024; +const DEFAULT_MAX_NODE_ARTIFACT_STORE_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const DEFAULT_MAX_NODE_ARTIFACT_COUNT: usize = 1_024; +const DEFAULT_MAX_NODE_ARTIFACT_AGE_SECONDS: u64 = 7 * 24 * 60 * 60; +const DEFAULT_NODE_ARTIFACT_RESTART_PIN_SECONDS: u64 = 60 * 60; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct NodeArtifactRetentionLimits { + pub(crate) max_bytes: u64, + pub(crate) max_count: usize, + pub(crate) max_age_seconds: u64, + pub(crate) restart_pin_seconds: u64, +} + +impl Default for NodeArtifactRetentionLimits { + fn default() -> Self { + Self { + max_bytes: DEFAULT_MAX_NODE_ARTIFACT_STORE_BYTES, + max_count: DEFAULT_MAX_NODE_ARTIFACT_COUNT, + max_age_seconds: DEFAULT_MAX_NODE_ARTIFACT_AGE_SECONDS, + restart_pin_seconds: DEFAULT_NODE_ARTIFACT_RESTART_PIN_SECONDS, + } + } +} + +impl NodeArtifactRetentionLimits { + pub(crate) fn from_environment() -> Result { + Ok(Self { + max_bytes: environment_u64( + "DISASMER_NODE_ARTIFACT_MAX_BYTES", + DEFAULT_MAX_NODE_ARTIFACT_STORE_BYTES, + )?, + max_count: usize::try_from(environment_u64( + "DISASMER_NODE_ARTIFACT_MAX_COUNT", + DEFAULT_MAX_NODE_ARTIFACT_COUNT as u64, + )?) + .map_err(|_| "DISASMER_NODE_ARTIFACT_MAX_COUNT does not fit usize".to_owned())?, + max_age_seconds: environment_u64( + "DISASMER_NODE_ARTIFACT_MAX_AGE_SECONDS", + DEFAULT_MAX_NODE_ARTIFACT_AGE_SECONDS, + )?, + restart_pin_seconds: environment_u64( + "DISASMER_NODE_ARTIFACT_RESTART_PIN_SECONDS", + DEFAULT_NODE_ARTIFACT_RESTART_PIN_SECONDS, + )?, + }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct NodeArtifactGcReport { + pub(crate) retained_count: usize, + pub(crate) retained_bytes: u64, + pub(crate) evicted: Vec, + pub(crate) limits_satisfied: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RetainedArtifact { + pub(crate) id: ArtifactId, + pub(crate) digest: Digest, + pub(crate) size_bytes: u64, + pub(crate) path: PathBuf, +} + +#[derive(Clone, Debug)] +pub(crate) struct NodeArtifactStore { + root: PathBuf, +} + +impl NodeArtifactStore { + pub(crate) fn for_runtime(project_root: Option<&Path>, node: &str) -> Result { + let base = project_root + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let node = node + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || "._-".contains(character) { + character + } else { + '_' + } + }) + .collect::(); + let root = base.join(".disasmer").join("node-artifacts").join(node); + if fs::symlink_metadata(&root) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return Err("node artifact store root must not be a symbolic link".to_owned()); + } + fs::create_dir_all(&root).map_err(|error| error.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + } + let root = root.canonicalize().map_err(|error| error.to_string())?; + Ok(Self { root }) + } + + pub(crate) fn retain_output_file( + &self, + output_root: &Path, + relative_path: &str, + ) -> Result { + let source = confined_output_file(output_root, relative_path)?; + let name = relative_path.replace('/', "_"); + validate_artifact_component(&name, "artifact name", 240)?; + let temporary = self.root.join(format!( + ".flush.{}.{}.tmp", + std::process::id(), + NEXT_FILE_ID.fetch_add(1, Ordering::Relaxed) + )); + let retained = (|| { + let mut input = open_regular_readonly(&source, "task output")?; + let metadata = input.metadata().map_err(|error| error.to_string())?; + if metadata.len() > MAX_NODE_ARTIFACT_BYTES { + return Err(format!( + "task output is {} bytes; node artifact limit is {MAX_NODE_ARTIFACT_BYTES} bytes", + metadata.len() + )); + } + let mut output = create_new_private_file(&temporary)?; + let (digest, size) = copy_and_hash(&mut input, &mut output, MAX_NODE_ARTIFACT_BYTES)?; + output.sync_all().map_err(|error| error.to_string())?; + let digest_hex = digest + .as_str() + .strip_prefix("sha256:") + .ok_or_else(|| "task output digest is not SHA-256".to_owned())?; + let id = ArtifactId::new(format!("{name}-{digest_hex}")); + let path = self.root.join(id.as_str()); + match fs::hard_link(&temporary, &path) { + Ok(()) => { + fs::remove_file(&temporary).map_err(|error| error.to_string())?; + Ok(RetainedArtifact { + id, + digest, + size_bytes: size, + path, + }) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let existing = self + .metadata(&id)? + .ok_or_else(|| "retained artifact disappeared during flush".to_owned())?; + if existing.digest != digest || existing.size_bytes != size { + return Err("retained artifact path has conflicting bytes".to_owned()); + } + Ok(existing) + } + Err(error) => Err(error.to_string()), + } + })(); + let _ = fs::remove_file(&temporary); + retained + } + + pub(crate) fn materialize_into_output( + &self, + artifact: &disasmer_core::ArtifactHandle, + output_root: &Path, + relative_path: &str, + ) -> Result { + let retained = self + .metadata(&artifact.id)? + .ok_or_else(|| format!("retained artifact `{}` is unavailable", artifact.id))?; + if retained.digest != artifact.digest || retained.size_bytes != artifact.size_bytes { + return Err( + "artifact handle digest/size does not match retained node metadata".to_owned(), + ); + } + let target = confined_output_target(output_root, relative_path)?; + let copied = (|| { + let mut output = create_new_private_file(&target)?; + let mut input = open_regular_readonly(&retained.path, "retained artifact")?; + let (digest, size_bytes) = + copy_and_hash(&mut input, &mut output, MAX_NODE_ARTIFACT_BYTES)?; + output.sync_all().map_err(|error| error.to_string())?; + Ok::<_, String>((digest, size_bytes)) + })(); + let (digest, size_bytes) = match copied { + Ok(copied) => copied, + Err(error) => { + let _ = fs::remove_file(&target); + return Err(error); + } + }; + if size_bytes != artifact.size_bytes || digest != artifact.digest { + let _ = fs::remove_file(&target); + return Err("materialized artifact digest/size changed during local copy".to_owned()); + } + Ok(target) + } + + pub(crate) fn metadata(&self, id: &ArtifactId) -> Result, String> { + validate_artifact_component(id.as_str(), "artifact id", 256)?; + let path = self.root.join(id.as_str()); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("retained artifact path is not a regular non-symlink file".to_owned()); + } + if metadata.len() > MAX_NODE_ARTIFACT_BYTES { + return Err(format!( + "retained artifact exceeds node artifact limit of {MAX_NODE_ARTIFACT_BYTES} bytes" + )); + } + let mut file = open_regular_readonly(&path, "retained artifact")?; + let (digest, size_bytes) = hash_reader(&mut file, MAX_NODE_ARTIFACT_BYTES)?; + Ok(Some(RetainedArtifact { + id: id.clone(), + digest, + size_bytes, + path, + })) + } + + #[cfg(test)] + pub(crate) fn read_verified( + &self, + id: &ArtifactId, + expected_digest: &Digest, + expected_size_bytes: u64, + ) -> Result, String> { + let retained = self + .metadata(id)? + .ok_or_else(|| format!("retained artifact `{id}` no longer exists on this node"))?; + if &retained.digest != expected_digest || retained.size_bytes != expected_size_bytes { + return Err(format!( + "retained artifact `{id}` does not match coordinator digest/size metadata" + )); + } + fs::read(retained.path).map_err(|error| error.to_string()) + } + + pub(crate) fn read_verified_chunk( + &self, + id: &ArtifactId, + expected_digest: &Digest, + expected_size_bytes: u64, + offset: u64, + max_chunk_bytes: u64, + ) -> Result, String> { + validate_artifact_component(id.as_str(), "artifact id", 256)?; + let digest_hex = expected_digest + .as_str() + .strip_prefix("sha256:") + .ok_or_else(|| "artifact transfer expected digest is not SHA-256".to_owned())?; + if !id.as_str().ends_with(digest_hex) { + return Err(format!( + "retained artifact `{id}` is not the content-addressed object requested by the coordinator" + )); + } + let path = self.root.join(id.as_str()); + let mut file = open_regular_readonly(&path, "retained artifact") + .map_err(|_| format!("retained artifact `{id}` no longer exists on this node"))?; + let actual_size = file.metadata().map_err(|error| error.to_string())?.len(); + if actual_size != expected_size_bytes { + return Err(format!( + "retained artifact `{id}` does not match coordinator size metadata" + )); + } + if offset > actual_size { + return Err(format!( + "artifact transfer offset {offset} exceeds retained artifact size {actual_size}" + )); + } + file.seek(SeekFrom::Start(offset)) + .map_err(|error| error.to_string())?; + let length = max_chunk_bytes + .min(MAX_NODE_ARTIFACT_CHUNK_BYTES) + .min(actual_size.saturating_sub(offset)); + let mut bytes = vec![0; usize::try_from(length).map_err(|error| error.to_string())?]; + file.read_exact(&mut bytes) + .map_err(|error| error.to_string())?; + Ok(bytes) + } + + pub(crate) fn garbage_collect( + &self, + limits: NodeArtifactRetentionLimits, + pinned: &BTreeSet, + now_epoch_seconds: u64, + ) -> Result { + let mut entries = Vec::new(); + for entry in fs::read_dir(&self.root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let name = entry + .file_name() + .into_string() + .map_err(|_| "retained artifact filename is not UTF-8".to_owned())?; + if name.starts_with('.') { + continue; + } + validate_artifact_component(&name, "artifact id", 256)?; + let metadata = entry.metadata().map_err(|error| error.to_string())?; + if !entry + .file_type() + .map_err(|error| error.to_string())? + .is_file() + || !metadata.is_file() + { + continue; + } + entries.push(StoredArtifactEntry { + id: ArtifactId::new(name), + path: entry.path(), + size_bytes: metadata.len(), + modified_epoch_seconds: metadata + .modified() + .ok() + .and_then(system_time_epoch_seconds) + .unwrap_or_default(), + }); + } + entries.sort_by(|left, right| { + left.modified_epoch_seconds + .cmp(&right.modified_epoch_seconds) + .then_with(|| left.id.cmp(&right.id)) + }); + + let age_cutoff = now_epoch_seconds.saturating_sub(limits.max_age_seconds); + let mut retained_count = entries.len(); + let mut retained_bytes = entries + .iter() + .try_fold(0_u64, |total, entry| total.checked_add(entry.size_bytes)) + .ok_or_else(|| "node artifact byte accounting overflowed".to_owned())?; + let mut evicted = Vec::new(); + for entry in &entries { + let expired = entry.modified_epoch_seconds <= age_cutoff; + let over_count = retained_count > limits.max_count; + let over_bytes = retained_bytes > limits.max_bytes; + if !(expired || over_count || over_bytes) || pinned.contains(&entry.id) { + continue; + } + match fs::remove_file(&entry.path) { + Ok(()) => { + retained_count = retained_count.saturating_sub(1); + retained_bytes = retained_bytes.saturating_sub(entry.size_bytes); + evicted.push(entry.id.clone()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + retained_count = retained_count.saturating_sub(1); + retained_bytes = retained_bytes.saturating_sub(entry.size_bytes); + evicted.push(entry.id.clone()); + } + Err(error) => return Err(error.to_string()), + } + } + let retained_ids = self.artifact_ids()?.into_iter().collect::>(); + let retained_has_expired = entries.iter().any(|entry| { + retained_ids.contains(&entry.id) && entry.modified_epoch_seconds <= age_cutoff + }); + Ok(NodeArtifactGcReport { + retained_count, + retained_bytes, + evicted, + limits_satisfied: retained_count <= limits.max_count + && retained_bytes <= limits.max_bytes + && !retained_has_expired, + }) + } + + pub(crate) fn artifact_ids(&self) -> Result, String> { + let mut ids = Vec::new(); + for entry in fs::read_dir(&self.root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + if !entry + .file_type() + .map_err(|error| error.to_string())? + .is_file() + { + continue; + } + let name = entry + .file_name() + .into_string() + .map_err(|_| "retained artifact filename is not UTF-8".to_owned())?; + if name.starts_with('.') { + continue; + } + validate_artifact_component(&name, "artifact id", 256)?; + ids.push(ArtifactId::new(name)); + } + ids.sort(); + Ok(ids) + } +} + +#[derive(Clone, Debug)] +struct StoredArtifactEntry { + id: ArtifactId, + path: PathBuf, + size_bytes: u64, + modified_epoch_seconds: u64, +} + +pub(crate) fn retained_result_artifact( + project_root: Option<&Path>, + node: &str, + result: Option<&disasmer_core::TaskBoundaryValue>, +) -> Result, String> { + let Some(disasmer_core::TaskBoundaryValue::Artifact(handle)) = result else { + return Ok(None); + }; + NodeArtifactStore::for_runtime(project_root, node)? + .metadata(&handle.id)? + .ok_or_else(|| { + format!( + "task returned artifact `{}` without flushing a file on this node", + handle.id + ) + }) + .and_then(|retained| { + if retained.digest != handle.digest || retained.size_bytes != handle.size_bytes { + return Err("task artifact handle does not match retained file metadata".to_owned()); + } + Ok(Some(retained)) + }) +} + +static NEXT_FILE_ID: AtomicU64 = AtomicU64::new(1); +static NEXT_OUTPUT_ROOT_ID: AtomicU64 = AtomicU64::new(1); + +pub(crate) fn task_output_root( + project_root: Option<&Path>, + node: &str, + task: &TaskInstanceId, +) -> Result { + let base = project_root + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let safe = |value: &str| { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || "._-".contains(character) { + character + } else { + '_' + } + }) + .collect::() + }; + let parent = base.join(".disasmer").join("task-outputs").join(safe(node)); + fs::create_dir_all(&parent).map_err(|error| error.to_string())?; + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let root = parent.join(format!( + "{}.{}.{}.{}", + safe(task.as_str()), + std::process::id(), + now_nanos, + NEXT_OUTPUT_ROOT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).map_err(|error| error.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + } + root.canonicalize().map_err(|error| error.to_string()) +} + +pub(crate) fn clean_stale_task_output_roots( + project_root: Option<&Path>, + node: &str, +) -> Result { + let base = project_root + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let node = node + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || "._-".contains(character) { + character + } else { + '_' + } + }) + .collect::(); + let parent = base.join(".disasmer").join("task-outputs").join(node); + let parent_metadata = match fs::symlink_metadata(&parent) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.to_string()), + }; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + return Err("task-output store root must be a non-symlink directory".to_owned()); + } + let mut removed = 0; + for entry in fs::read_dir(&parent).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let file_type = entry.file_type().map_err(|error| error.to_string())?; + if file_type.is_dir() { + fs::remove_dir_all(entry.path()).map_err(|error| error.to_string())?; + } else { + fs::remove_file(entry.path()).map_err(|error| error.to_string())?; + } + removed += 1; + } + Ok(removed) +} + +fn confined_output_file(output_root: &Path, relative_path: &str) -> Result { + validate_relative_path(relative_path)?; + let root = output_root + .canonicalize() + .map_err(|error| error.to_string())?; + reject_symlink_components(&root, relative_path, true)?; + let path = root.join(relative_path); + let metadata = fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() { + return Err("task output publication rejects symbolic links".to_owned()); + } + let canonical = path.canonicalize().map_err(|error| error.to_string())?; + if !canonical.starts_with(&root) { + return Err("task output path escapes its bounded output root".to_owned()); + } + Ok(canonical) +} + +fn confined_output_target(output_root: &Path, relative_path: &str) -> Result { + validate_relative_path(relative_path)?; + let root = output_root + .canonicalize() + .map_err(|error| error.to_string())?; + let path = root.join(relative_path); + let parent = path + .parent() + .ok_or_else(|| "task output target has no parent".to_owned())?; + create_confined_directories(&root, relative_path)?; + reject_symlink_components(&root, relative_path, false)?; + let parent = parent.canonicalize().map_err(|error| error.to_string())?; + if !parent.starts_with(&root) { + return Err("task output target escapes its bounded output root".to_owned()); + } + Ok(path) +} + +fn create_confined_directories(root: &Path, relative_path: &str) -> Result<(), String> { + let components = relative_path.split('/').collect::>(); + let mut current = root.to_path_buf(); + for component in components.iter().take(components.len().saturating_sub(1)) { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("task output target rejects symbolic-link directories".to_owned()) + } + Ok(metadata) if !metadata.is_dir() => { + return Err("task output target parent is not a directory".to_owned()) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(¤t).map_err(|error| error.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(¤t, fs::Permissions::from_mode(0o700)) + .map_err(|error| error.to_string())?; + } + } + Err(error) => return Err(error.to_string()), + } + } + Ok(()) +} + +fn reject_symlink_components( + root: &Path, + relative_path: &str, + include_leaf: bool, +) -> Result<(), String> { + let components = relative_path.split('/').collect::>(); + let count = if include_leaf { + components.len() + } else { + components.len().saturating_sub(1) + }; + let mut current = root.to_path_buf(); + for component in components.iter().take(count) { + current.push(component); + let metadata = fs::symlink_metadata(¤t).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() { + return Err("task output path rejects symbolic-link components".to_owned()); + } + } + Ok(()) +} + +fn open_regular_readonly(path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("{label} must be a regular non-symlink file")); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options.open(path).map_err(|error| error.to_string())?; + if !file + .metadata() + .map_err(|error| error.to_string())? + .is_file() + { + return Err(format!("{label} must remain a regular file while open")); + } + Ok(file) +} + +fn create_new_private_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + options.open(path).map_err(|error| error.to_string()) +} + +fn hash_reader(reader: &mut impl Read, limit: u64) -> Result<(Digest, u64), String> { + let mut sink = std::io::sink(); + copy_and_hash(reader, &mut sink, limit) +} + +fn copy_and_hash( + reader: &mut impl Read, + writer: &mut impl Write, + limit: u64, +) -> Result<(Digest, u64), String> { + let mut hasher = Sha256::new(); + let mut size_bytes = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| error.to_string())?; + if read == 0 { + break; + } + size_bytes = size_bytes + .checked_add(read as u64) + .ok_or_else(|| "artifact size accounting overflowed".to_owned())?; + if size_bytes > limit { + return Err(format!( + "artifact exceeds bounded size limit of {limit} bytes" + )); + } + hasher.update(&buffer[..read]); + writer + .write_all(&buffer[..read]) + .map_err(|error| error.to_string())?; + } + let digest_hex = format!("{:x}", hasher.finalize()); + Ok((Digest::from_sha256_hex(&digest_hex)?, size_bytes)) +} + +fn environment_u64(name: &str, default: u64) -> Result { + match std::env::var(name) { + Ok(value) => value + .parse::() + .map_err(|_| format!("{name} must be an unsigned integer")), + Err(std::env::VarError::NotPresent) => Ok(default), + Err(error) => Err(format!("read {name}: {error}")), + } +} + +fn system_time_epoch_seconds(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()) +} + +pub(crate) fn current_epoch_seconds() -> u64 { + system_time_epoch_seconds(SystemTime::now()).unwrap_or_default() +} + +fn validate_relative_path(path: &str) -> Result<(), String> { + if path.is_empty() + || path.len() > 240 + || path.starts_with('/') + || path.starts_with('\\') + || path.split('/').any(|component| { + component.is_empty() + || component == "." + || component == ".." + || !component + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) + }) + { + return Err("task output path must be a safe relative path".to_owned()); + } + Ok(()) +} + +fn validate_artifact_component(value: &str, label: &str, max_len: usize) -> Result<(), String> { + if value.trim().is_empty() + || value.len() > max_len + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) + { + return Err(format!("{label} is invalid")); + } + Ok(()) +} + +pub(crate) struct TaskArtifactStore { + overlay: VfsOverlay, +} + +impl TaskArtifactStore { + pub(crate) fn new(task: TaskInstanceId, node: NodeId) -> Self { + Self { + overlay: VfsOverlay::new(task, node), + } + } + + pub(crate) fn overlay_mut(&mut self) -> &mut VfsOverlay { + &mut self.overlay + } + + pub(crate) fn flush(mut self) -> VfsManifest { + self.overlay.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn retain_fixture( + store: &NodeArtifactStore, + workspace: &Path, + name: &str, + bytes: &[u8], + ) -> RetainedArtifact { + let output = workspace.join("output"); + fs::create_dir_all(&output).unwrap(); + let path = output.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, bytes).unwrap(); + store.retain_output_file(&output, name).unwrap() + } + + #[test] + fn node_artifact_store_retains_real_content_addressed_bytes() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node/a").unwrap(); + let retained = retain_fixture(&store, temp.path(), "app.tar.zst", b"real artifact bytes"); + + assert_eq!(retained.digest, Digest::sha256(b"real artifact bytes")); + assert_eq!(retained.size_bytes, 19); + assert_eq!(fs::read(&retained.path).unwrap(), b"real artifact bytes"); + assert_eq!(store.metadata(&retained.id).unwrap().unwrap(), retained); + } + + #[test] + fn node_artifact_store_rejects_path_traversal() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let output = temp.path().join("output"); + fs::create_dir_all(&output).unwrap(); + + assert!(store.retain_output_file(&output, "../escape").is_err()); + assert!(store.metadata(&ArtifactId::from("../escape")).is_err()); + } + + #[test] + fn node_artifact_store_reads_only_digest_verified_bytes() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let retained = retain_fixture(&store, temp.path(), "result.bin", b"verified bytes"); + + assert_eq!( + store + .read_verified(&retained.id, &retained.digest, retained.size_bytes) + .unwrap(), + b"verified bytes" + ); + assert!(store + .read_verified(&retained.id, &Digest::sha256("wrong"), retained.size_bytes) + .is_err()); + assert_eq!(store.artifact_ids().unwrap(), vec![retained.id]); + } + + #[test] + fn node_artifact_store_reads_bounded_transfer_chunks() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let retained = retain_fixture(&store, temp.path(), "large.bin", b"0123456789"); + + assert_eq!( + store + .read_verified_chunk(&retained.id, &retained.digest, retained.size_bytes, 3, 4) + .unwrap(), + b"3456" + ); + assert!(store + .read_verified_chunk(&retained.id, &retained.digest, retained.size_bytes, 11, 4) + .is_err()); + } + + #[test] + fn downstream_materialization_copies_exact_digest_verified_bytes() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let retained = retain_fixture(&store, temp.path(), "build/app", b"real compiler output"); + let consumer = temp.path().join("consumer"); + fs::create_dir_all(&consumer).unwrap(); + let handle = disasmer_core::ArtifactHandle { + id: retained.id, + digest: retained.digest, + size_bytes: retained.size_bytes, + }; + + let path = store + .materialize_into_output(&handle, &consumer, "package/app") + .unwrap(); + + assert_eq!(path, consumer.join("package/app")); + assert_eq!(fs::read(path).unwrap(), b"real compiler output"); + } + + #[test] + fn materialization_rejects_a_corrupt_handle_and_removes_partial_output() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let retained = retain_fixture(&store, temp.path(), "app", b"artifact"); + let consumer = temp.path().join("consumer"); + fs::create_dir_all(&consumer).unwrap(); + let handle = disasmer_core::ArtifactHandle { + id: retained.id, + digest: Digest::sha256("forged"), + size_bytes: retained.size_bytes, + }; + + assert!(store + .materialize_into_output(&handle, &consumer, "app") + .is_err()); + assert!(!consumer.join("app").exists()); + } + + #[cfg(unix)] + #[test] + fn publication_and_materialization_reject_symlink_components() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let output = temp.path().join("output"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&output).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("secret"), b"outside").unwrap(); + symlink(outside.join("secret"), output.join("linked-file")).unwrap(); + symlink(&outside, output.join("linked-dir")).unwrap(); + + assert!(store.retain_output_file(&output, "linked-file").is_err()); + assert!(store + .retain_output_file(&output, "linked-dir/secret") + .is_err()); + + let retained = retain_fixture(&store, temp.path(), "safe", b"safe"); + let handle = disasmer_core::ArtifactHandle { + id: retained.id, + digest: retained.digest, + size_bytes: retained.size_bytes, + }; + assert!(store + .materialize_into_output(&handle, &output, "linked-dir/copied") + .is_err()); + assert!(!outside.join("copied").exists()); + } + + #[test] + fn transfer_chunks_are_hard_bounded_by_the_node() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let bytes = vec![7_u8; (MAX_NODE_ARTIFACT_CHUNK_BYTES + 10) as usize]; + let retained = retain_fixture(&store, temp.path(), "large", &bytes); + + let chunk = store + .read_verified_chunk( + &retained.id, + &retained.digest, + retained.size_bytes, + 0, + u64::MAX, + ) + .unwrap(); + + assert_eq!(chunk.len() as u64, MAX_NODE_ARTIFACT_CHUNK_BYTES); + } + + #[test] + fn garbage_collection_obeys_count_bytes_age_and_pins() { + let temp = tempfile::tempdir().unwrap(); + let store = NodeArtifactStore::for_runtime(Some(temp.path()), "node-a").unwrap(); + let pinned = retain_fixture(&store, temp.path(), "pinned", b"12345"); + let disposable = retain_fixture(&store, temp.path(), "disposable", b"67890"); + let pins = BTreeSet::from([pinned.id.clone()]); + + let report = store + .garbage_collect( + NodeArtifactRetentionLimits { + max_bytes: 5, + max_count: 1, + max_age_seconds: u64::MAX, + restart_pin_seconds: 60, + }, + &pins, + current_epoch_seconds(), + ) + .unwrap(); + + assert_eq!(report.evicted, vec![disposable.id]); + assert!(report.limits_satisfied); + assert_eq!(store.artifact_ids().unwrap(), vec![pinned.id.clone()]); + + let pinned_over_limit = store + .garbage_collect( + NodeArtifactRetentionLimits { + max_bytes: 0, + max_count: 0, + max_age_seconds: 0, + restart_pin_seconds: 60, + }, + &pins, + current_epoch_seconds().saturating_add(1), + ) + .unwrap(); + assert!(pinned_over_limit.evicted.is_empty()); + assert!(!pinned_over_limit.limits_satisfied); + assert!(pinned.path.exists()); + } + + #[test] + fn task_output_roots_are_unique_and_stale_roots_are_cleaned_on_node_start() { + let temp = tempfile::tempdir().unwrap(); + let task = TaskInstanceId::from("task:1"); + let first = task_output_root(Some(temp.path()), "node-a", &task).unwrap(); + let second = task_output_root(Some(temp.path()), "node-a", &task).unwrap(); + fs::write(first.join("stale"), b"one").unwrap(); + fs::write(second.join("stale"), b"two").unwrap(); + + assert_ne!(first, second); + assert_eq!( + clean_stale_task_output_roots(Some(temp.path()), "node-a").unwrap(), + 2 + ); + assert!(!first.exists()); + assert!(!second.exists()); + } +} diff --git a/crates/disasmer-node/src/task_reports.rs b/crates/disasmer-node/src/task_reports.rs new file mode 100644 index 0000000..c347e5e --- /dev/null +++ b/crates/disasmer-node/src/task_reports.rs @@ -0,0 +1,379 @@ +use disasmer_core::{TaskBoundaryValue, VfsManifest}; +use disasmer_node::CommandOutput; +use serde_json::{json, Value}; + +use crate::daemon::RuntimeTask; +use crate::{ + coordinator_session::CoordinatorSession, daemon::Args, node_identity::signed_node_request_json, + task_artifacts::RetainedArtifact, +}; + +#[allow(clippy::too_many_arguments)] +pub(crate) fn record_completed_task( + args: &Args, + session: &mut CoordinatorSession, + task: RuntimeTask, + output: CommandOutput, + manifest: VfsManifest, + result: Option, + retained: Option, + registration: Value, + heartbeat: Value, + capability_report: Value, + debug_command: Value, + node_private_key: &str, +) -> Result> { + let staged = output.staged_artifact.as_ref(); + let artifact_digest = retained + .as_ref() + .map(|artifact| artifact.digest.clone()) + .or_else(|| staged.map(|artifact| artifact.digest.clone())); + let artifact_path = retained + .as_ref() + .map(|artifact| format!("/vfs/artifacts/{}", artifact.id)) + .or_else(|| staged.map(|artifact| artifact.path.as_str().to_owned())); + let artifact_size_bytes = retained + .as_ref() + .map(|artifact| artifact.size_bytes) + .or_else(|| staged.map(|artifact| artifact.size)); + let log_event = session.request(signed_node_request_json( + args, + node_private_key, + "report_task_log", + json!({ + "type": "report_task_log", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "stdout_bytes": output.stdout.len(), + "stderr_bytes": output.stderr.len(), + "stdout_tail": &output.stdout, + "stderr_tail": &output.stderr, + "stdout_truncated": output.stdout_truncated, + "stderr_truncated": output.stderr_truncated, + "backpressured": output.log_backpressured, + }), + )?)?; + let vfs_metadata = session.request(signed_node_request_json( + args, + node_private_key, + "report_vfs_metadata", + json!({ + "type": "report_vfs_metadata", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "artifact_path": artifact_path, + "artifact_digest": artifact_digest, + "artifact_size_bytes": artifact_size_bytes, + "large_bytes_uploaded": manifest.large_bytes_uploaded, + }), + )?)?; + let recorded = session.request(signed_node_request_json( + args, + node_private_key, + "task_completed", + json!({ + "type": "task_completed", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "status_code": output.status_code, + "stdout_bytes": output.stdout.len(), + "stderr_bytes": output.stderr.len(), + "stdout_tail": &output.stdout, + "stderr_tail": &output.stderr, + "stdout_truncated": output.stdout_truncated, + "stderr_truncated": output.stderr_truncated, + "artifact_path": artifact_path, + "artifact_digest": artifact_digest, + "artifact_size_bytes": artifact_size_bytes, + "result": result, + }), + )?)?; + Ok(completed_node_report( + output, + manifest.large_bytes_uploaded, + registration, + heartbeat, + capability_report, + task.task_assignment_response, + debug_command, + log_event, + vfs_metadata, + recorded, + session.requests(), + )) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn record_failed_task( + args: &Args, + session: &mut CoordinatorSession, + task: &RuntimeTask, + registration: Value, + heartbeat: Value, + capability_report: Value, + debug_command: Value, + node_private_key: &str, + error: &str, +) -> Result> { + let error = bounded_runtime_error(error); + let log_event = session.request(signed_node_request_json( + args, + node_private_key, + "report_task_log", + json!({ + "type": "report_task_log", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "stdout_bytes": 0, + "stderr_bytes": error.len(), + "stdout_tail": "", + "stderr_tail": &error, + "stdout_truncated": false, + "stderr_truncated": false, + "backpressured": false, + }), + )?)?; + let vfs_metadata = session.request(signed_node_request_json( + args, + node_private_key, + "report_vfs_metadata", + json!({ + "type": "report_vfs_metadata", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "artifact_path": null, + "artifact_digest": null, + "artifact_size_bytes": null, + "large_bytes_uploaded": false, + }), + )?)?; + let recorded = session.request(signed_node_request_json( + args, + node_private_key, + "task_completed", + json!({ + "type": "task_completed", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "terminal_state": "failed", + "status_code": -1, + "stdout_bytes": 0, + "stderr_bytes": error.len(), + "stdout_tail": "", + "stderr_tail": &error, + "stdout_truncated": false, + "stderr_truncated": false, + "artifact_path": null, + "artifact_digest": null, + "artifact_size_bytes": null, + }), + )?)?; + Ok(failed_node_report( + task, + &error, + registration, + heartbeat, + capability_report, + debug_command, + log_event, + vfs_metadata, + recorded, + session.requests(), + )) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn record_cancelled_task( + args: &Args, + session: &mut CoordinatorSession, + task: &RuntimeTask, + registration: Value, + heartbeat: Value, + capability_report: Value, + debug_command: Value, + node_private_key: &str, +) -> Result> { + let recorded = session.request(signed_node_request_json( + args, + node_private_key, + "task_completed", + json!({ + "type": "task_completed", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "terminal_state": "cancelled", + "status_code": null, + "stdout_bytes": 0, + "stderr_bytes": 0, + "stdout_tail": "", + "stderr_tail": "", + "stdout_truncated": false, + "stderr_truncated": false, + "artifact_path": null, + "artifact_digest": null, + "artifact_size_bytes": null, + "result": null, + }), + )?)?; + Ok(cancelled_node_report( + task, + registration, + heartbeat, + capability_report, + task.task_assignment_response.clone(), + debug_command, + recorded, + session.requests(), + )) +} + +fn bounded_runtime_error(error: &str) -> String { + const MAX_BYTES: usize = 16 * 1024; + if error.len() <= MAX_BYTES { + return error.to_owned(); + } + let boundary = error + .char_indices() + .take_while(|(index, _)| *index <= MAX_BYTES) + .map(|(index, _)| index) + .last() + .unwrap_or(0); + format!("{}\n", &error[..boundary]) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn completed_node_report( + output: CommandOutput, + large_bytes_uploaded: bool, + registration_response: Value, + heartbeat_response: Value, + capability_response: Value, + task_assignment_response: Value, + debug_command_response: Value, + log_event_response: Value, + vfs_metadata_response: Value, + coordinator_response: Value, + session_requests: usize, +) -> Value { + json!({ + "node_status": "completed", + "virtual_thread": output.virtual_thread, + "terminal_state": if output.status_code == Some(0) { "completed" } else { "failed" }, + "status_code": output.status_code, + "stdout_bytes": output.stdout.len(), + "stderr_bytes": output.stderr.len(), + "stdout_tail": &output.stdout, + "stderr_tail": &output.stderr, + "stdout_truncated": output.stdout_truncated, + "stderr_truncated": output.stderr_truncated, + "log_backpressured": output.log_backpressured, + "staged_artifact": output.staged_artifact, + "large_bytes_uploaded": large_bytes_uploaded, + "registration_response": registration_response, + "heartbeat_response": heartbeat_response, + "capability_response": capability_response, + "task_assignment_response": task_assignment_response, + "debug_command_response": debug_command_response, + "log_event_response": log_event_response, + "vfs_metadata_response": vfs_metadata_response, + "session_requests": session_requests, + "coordinator_response": coordinator_response, + }) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn cancelled_node_report( + task: &RuntimeTask, + registration_response: Value, + heartbeat_response: Value, + capability_response: Value, + task_assignment_response: Value, + debug_command_response: Value, + coordinator_response: Value, + session_requests: usize, +) -> Value { + json!({ + "node_status": "cancelled", + "virtual_thread": &task.task, + "terminal_state": "cancelled", + "status_code": null, + "stdout_bytes": 0, + "stderr_bytes": 0, + "stdout_tail": "", + "stderr_tail": "", + "stdout_truncated": false, + "stderr_truncated": false, + "log_backpressured": false, + "staged_artifact": null, + "large_bytes_uploaded": false, + "registration_response": registration_response, + "heartbeat_response": heartbeat_response, + "capability_response": capability_response, + "task_assignment_response": task_assignment_response, + "debug_command_response": debug_command_response, + "log_event_response": null, + "vfs_metadata_response": null, + "session_requests": session_requests, + "coordinator_response": coordinator_response, + }) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn failed_node_report( + task: &RuntimeTask, + error: &str, + registration_response: Value, + heartbeat_response: Value, + capability_response: Value, + debug_command_response: Value, + log_event_response: Value, + vfs_metadata_response: Value, + coordinator_response: Value, + session_requests: usize, +) -> Value { + json!({ + "node_status": "failed", + "virtual_thread": &task.task, + "terminal_state": "failed", + "status_code": -1, + "stdout_bytes": 0, + "stderr_bytes": error.len(), + "stdout_tail": "", + "stderr_tail": error, + "stdout_truncated": false, + "stderr_truncated": false, + "log_backpressured": false, + "staged_artifact": null, + "large_bytes_uploaded": false, + "registration_response": registration_response, + "heartbeat_response": heartbeat_response, + "capability_response": capability_response, + "task_assignment_response": &task.task_assignment_response, + "debug_command_response": debug_command_response, + "log_event_response": log_event_response, + "vfs_metadata_response": vfs_metadata_response, + "session_requests": session_requests, + "coordinator_response": coordinator_response, + }) +} diff --git a/crates/disasmer-node/src/windows_dev.rs b/crates/disasmer-node/src/windows_dev.rs new file mode 100644 index 0000000..f90c1ba --- /dev/null +++ b/crates/disasmer-node/src/windows_dev.rs @@ -0,0 +1,39 @@ +use disasmer_core::{ + Capability, CommandBackendKind, CommandInvocation, CommandPlan, GuestRuntimeKind, +}; + +use crate::{BackendError, CommandBackend}; + +#[derive(Clone, Debug, Default)] +pub struct WindowsCommandDevBackend; + +impl CommandBackend for WindowsCommandDevBackend { + fn kind(&self) -> CommandBackendKind { + CommandBackendKind::WindowsCommandDev + } + + fn plan(&self, _invocation: &CommandInvocation) -> Result { + Ok(CommandPlan { + guest_runtime: GuestRuntimeKind::Wasmtime, + backend: CommandBackendKind::WindowsCommandDev, + required_capability: Capability::WindowsCommandDev, + user_attached_development_execution: true, + }) + } +} + +#[derive(Clone, Debug, Default)] +pub struct WindowsSandboxStubBackend; + +impl CommandBackend for WindowsSandboxStubBackend { + fn kind(&self) -> CommandBackendKind { + CommandBackendKind::StubbedWindowsSandbox + } + + fn plan(&self, _invocation: &CommandInvocation) -> Result { + Err(BackendError::Denied( + "Windows sandbox backend is an explicit stub for MVP; use windows-command-dev only for user-attached development execution" + .to_owned(), + )) + } +} diff --git a/crates/disasmer-sdk/Cargo.toml b/crates/disasmer-sdk/Cargo.toml new file mode 100644 index 0000000..cabccc9 --- /dev/null +++ b/crates/disasmer-sdk/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "disasmer-sdk" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "disasmer" + +[dependencies] +base64.workspace = true +disasmer-core = { path = "../disasmer-core" } +disasmer-control = { path = "../disasmer-control" } +disasmer-macros = { path = "../disasmer-macros" } +futures-executor.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/disasmer-sdk/src/__private.rs b/crates/disasmer-sdk/src/__private.rs new file mode 100644 index 0000000..74a555a --- /dev/null +++ b/crates/disasmer-sdk/src/__private.rs @@ -0,0 +1,500 @@ +#[cfg(target_arch = "wasm32")] +use serde::de::DeserializeOwned; + +#[cfg(target_arch = "wasm32")] +use crate::TaskArg; + +#[cfg(target_arch = "wasm32")] +#[unsafe(no_mangle)] +pub extern "C" fn disasmer_alloc_v1(length: u32) -> u32 { + if length as usize > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { + return 0; + } + let mut bytes = Vec::::with_capacity(length as usize); + let pointer = bytes.as_mut_ptr() as usize; + std::mem::forget(bytes); + u32::try_from(pointer).unwrap_or(0) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task0_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + R: TaskArg, + F: FnOnce() -> R, +{ + invoke_task0_output( + expected_task, + input_pointer, + input_length, + || Ok(function()), + ) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task0_result_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + R: TaskArg, + E: std::fmt::Display, + F: FnOnce() -> Result, +{ + invoke_task0_output(expected_task, input_pointer, input_length, || { + function().map_err(|error| error.to_string()) + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task0_async_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + R: TaskArg, + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + invoke_task0_output(expected_task, input_pointer, input_length, || { + Ok(futures_executor::block_on(function())) + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task0_async_result_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + R: TaskArg, + E: std::fmt::Display, + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + invoke_task0_output(expected_task, input_pointer, input_length, || { + futures_executor::block_on(function()).map_err(|error| error.to_string()) + }) +} + +#[cfg(target_arch = "wasm32")] +fn invoke_task0_output( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + R: TaskArg, + F: FnOnce() -> Result, +{ + let invocation = match decode_invocation(expected_task, input_pointer, input_length) { + Ok(invocation) => invocation, + Err(error) => return encode_result(failed_result(expected_task, error)), + }; + if !invocation.args.is_empty() { + return encode_result(failed_result( + invocation.task_instance.clone(), + format!( + "task expected zero arguments but received {}", + invocation.args.len() + ), + )); + } + let result = match function() { + Ok(output) => match output.task_boundary_value() { + Ok(result) => { + disasmer_core::WasmTaskResult::completed(invocation.task_instance, result) + } + Err(error) => disasmer_core::WasmTaskResult::failed( + invocation.task_instance, + bounded_task_error(error.to_string()), + ), + }, + Err(error) => disasmer_core::WasmTaskResult::failed( + invocation.task_instance, + bounded_task_error(error), + ), + }; + encode_result(result) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task1_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + A: TaskArg + DeserializeOwned, + R: TaskArg, + F: FnOnce(A) -> R, +{ + invoke_task1_output(expected_task, input_pointer, input_length, |argument| { + Ok(function(argument)) + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task1_result_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + A: TaskArg + DeserializeOwned, + R: TaskArg, + E: std::fmt::Display, + F: FnOnce(A) -> Result, +{ + invoke_task1_output(expected_task, input_pointer, input_length, |argument| { + function(argument).map_err(|error| error.to_string()) + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task1_async_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + A: TaskArg + DeserializeOwned, + R: TaskArg, + F: FnOnce(A) -> Fut, + Fut: std::future::Future, +{ + invoke_task1_output(expected_task, input_pointer, input_length, |argument| { + Ok(futures_executor::block_on(function(argument))) + }) +} + +#[cfg(target_arch = "wasm32")] +pub fn invoke_task1_async_result_v1( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + A: TaskArg + DeserializeOwned, + R: TaskArg, + E: std::fmt::Display, + F: FnOnce(A) -> Fut, + Fut: std::future::Future>, +{ + invoke_task1_output(expected_task, input_pointer, input_length, |argument| { + futures_executor::block_on(function(argument)).map_err(|error| error.to_string()) + }) +} + +#[cfg(target_arch = "wasm32")] +fn invoke_task1_output( + expected_task: &str, + input_pointer: u32, + input_length: u32, + function: F, +) -> u64 +where + A: TaskArg + DeserializeOwned, + R: TaskArg, + F: FnOnce(A) -> Result, +{ + let invocation = match decode_invocation(expected_task, input_pointer, input_length) { + Ok(invocation) => invocation, + Err(error) => return encode_result(failed_result(expected_task, error)), + }; + let [argument] = invocation.args.as_slice() else { + return encode_result(failed_result( + invocation.task_instance.clone(), + format!( + "task expected one argument but received {}", + invocation.args.len() + ), + )); + }; + let argument = match decode_boundary_value(argument.clone()) { + Ok(argument) => argument, + Err(error) => { + return encode_result(disasmer_core::WasmTaskResult::failed( + invocation.task_instance, + error, + )) + } + }; + let result = match function(argument) { + Ok(output) => match output.task_boundary_value() { + Ok(result) => { + disasmer_core::WasmTaskResult::completed(invocation.task_instance, result) + } + Err(error) => disasmer_core::WasmTaskResult::failed( + invocation.task_instance, + bounded_task_error(error.to_string()), + ), + }, + Err(error) => disasmer_core::WasmTaskResult::failed( + invocation.task_instance, + bounded_task_error(error), + ), + }; + encode_result(result) +} + +#[cfg(target_arch = "wasm32")] +fn bounded_task_error(mut error: String) -> String { + const LIMIT: usize = 4 * 1024; + const SUFFIX: &str = "… [truncated]"; + if error.len() <= LIMIT { + return error; + } + let mut end = LIMIT.saturating_sub(SUFFIX.len()); + while !error.is_char_boundary(end) { + end = end.saturating_sub(1); + } + error.truncate(end); + error.push_str(SUFFIX); + error +} + +#[cfg(target_arch = "wasm32")] +fn decode_invocation( + expected_task: &str, + pointer: u32, + length: u32, +) -> Result { + if length as usize > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { + return Err(format!( + "task invocation exceeds {} byte ABI limit", + disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES + )); + } + if pointer == 0 && length != 0 { + return Err("task invocation pointer is null".to_owned()); + } + let bytes = unsafe { std::slice::from_raw_parts(pointer as *const u8, length as usize) }; + let invocation: disasmer_core::WasmTaskInvocation = + serde_json::from_slice(bytes).map_err(|error| error.to_string())?; + invocation.validate()?; + if invocation.task_definition.as_str() != expected_task { + return Err(format!( + "task export `{expected_task}` received invocation for `{}`", + invocation.task_definition + )); + } + Ok(invocation) +} + +#[cfg(target_arch = "wasm32")] +fn decode_boundary_value(value: disasmer_core::TaskBoundaryValue) -> Result +where + T: DeserializeOwned, +{ + let value = match value { + disasmer_core::TaskBoundaryValue::SmallJson(value) => value, + disasmer_core::TaskBoundaryValue::Structured(structured) => structured.value, + disasmer_core::TaskBoundaryValue::SourceSnapshot(digest) + | disasmer_core::TaskBoundaryValue::Blob(digest) + | disasmer_core::TaskBoundaryValue::VfsManifest(digest) => { + serde_json::json!({ "digest": digest }) + } + disasmer_core::TaskBoundaryValue::Artifact(artifact) => { + serde_json::json!({ + "id": artifact.id, + "digest": artifact.digest, + "size_bytes": artifact.size_bytes, + }) + } + }; + serde_json::from_value(value).map_err(|error| error.to_string()) +} + +#[cfg(target_arch = "wasm32")] +fn failed_result( + task_instance: impl Into, + error: impl Into, +) -> disasmer_core::WasmTaskResult { + disasmer_core::WasmTaskResult::failed(task_instance.into().0, error) +} + +#[cfg(target_arch = "wasm32")] +struct FailedTaskInstance(disasmer_core::TaskInstanceId); + +#[cfg(target_arch = "wasm32")] +impl From<&str> for FailedTaskInstance { + fn from(task_definition: &str) -> Self { + Self(disasmer_core::TaskInstanceId::new(format!( + "invalid-invocation:{task_definition}" + ))) + } +} + +#[cfg(target_arch = "wasm32")] +impl From for FailedTaskInstance { + fn from(task_instance: disasmer_core::TaskInstanceId) -> Self { + Self(task_instance) + } +} + +#[cfg(target_arch = "wasm32")] +fn encode_result(mut result: disasmer_core::WasmTaskResult) -> u64 { + let mut bytes = serde_json::to_vec(&result).unwrap_or_default(); + if bytes.len() > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { + result = disasmer_core::WasmTaskResult::failed( + result.task_instance, + format!( + "task result exceeds {} byte ABI limit", + disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES + ), + ); + bytes = serde_json::to_vec(&result).unwrap_or_default(); + } + let length = bytes.len() as u32; + let pointer = bytes.as_mut_ptr() as usize; + let pointer = u32::try_from(pointer).unwrap_or(0); + std::mem::forget(bytes); + ((length as u64) << 32) | pointer as u64 +} +#[doc(hidden)] +pub trait CollectTaskHandles { + fn collect_task_handles( + &self, + handles: &mut Vec, + ) -> Result<(), crate::TaskArgError>; +} + +#[doc(hidden)] +pub fn structured_task_boundary( + value: &T, +) -> Result +where + T: serde::Serialize + CollectTaskHandles + ?Sized, +{ + let serialized = serde_json::to_value(value) + .map_err(|error| crate::TaskArgError::Serialization(error.to_string()))?; + let mut handles = Vec::new(); + CollectTaskHandles::collect_task_handles(value, &mut handles)?; + Ok(disasmer_core::TaskBoundaryValue::Structured( + disasmer_core::StructuredTaskBoundary { + value: serialized, + handles, + }, + )) +} + +macro_rules! no_task_handles { + ($($ty:ty),+ $(,)?) => { + $( + impl CollectTaskHandles for $ty { + fn collect_task_handles( + &self, + _handles: &mut Vec, + ) -> Result<(), crate::TaskArgError> { + Ok(()) + } + } + )+ + }; +} + +no_task_handles!( + (), + bool, + char, + String, + i8, + i16, + i32, + i64, + isize, + u8, + u16, + u32, + u64, + usize, + f32, + f64, +); + +impl CollectTaskHandles for crate::SourceSnapshot { + fn collect_task_handles( + &self, + handles: &mut Vec, + ) -> Result<(), crate::TaskArgError> { + handles.push(disasmer_core::TaskBoundaryHandle::SourceSnapshot( + crate::task_args::parse_handle_digest(&self.digest)?, + )); + Ok(()) + } +} + +impl CollectTaskHandles for crate::Blob { + fn collect_task_handles( + &self, + handles: &mut Vec, + ) -> Result<(), crate::TaskArgError> { + handles.push(disasmer_core::TaskBoundaryHandle::Blob( + crate::task_args::parse_handle_digest(&self.digest)?, + )); + Ok(()) + } +} + +impl CollectTaskHandles for crate::Artifact { + fn collect_task_handles( + &self, + handles: &mut Vec, + ) -> Result<(), crate::TaskArgError> { + if self.id.trim().is_empty() || self.id.len() > 256 { + return Err(crate::TaskArgError::Serialization( + "artifact handle ID must be non-empty and at most 256 bytes".to_owned(), + )); + } + handles.push(disasmer_core::TaskBoundaryHandle::Artifact( + disasmer_core::ArtifactId::from(self.id.as_str()), + )); + Ok(()) + } +} + +impl CollectTaskHandles for Option +where + T: CollectTaskHandles, +{ + fn collect_task_handles( + &self, + handles: &mut Vec, + ) -> Result<(), crate::TaskArgError> { + if let Some(value) = self { + value.collect_task_handles(handles)?; + } + Ok(()) + } +} + +impl CollectTaskHandles for Vec +where + T: CollectTaskHandles, +{ + fn collect_task_handles( + &self, + handles: &mut Vec, + ) -> Result<(), crate::TaskArgError> { + for value in self { + value.collect_task_handles(handles)?; + } + Ok(()) + } +} diff --git a/crates/disasmer-sdk/src/lib.rs b/crates/disasmer-sdk/src/lib.rs new file mode 100644 index 0000000..72f540c --- /dev/null +++ b/crates/disasmer-sdk/src/lib.rs @@ -0,0 +1,1001 @@ +use serde::Serialize; + +pub use disasmer_core as core; +pub use disasmer_macros::{main, task, TaskArg}; + +mod sdk_runtime; +mod task_args; +pub use task_args::{ + reject_host_only_task_arg, validate_task_arg, Artifact, Blob, EnvRef, SourceSnapshot, TaskArg, + TaskArgBudget, TaskArgError, TaskArgKind, TaskArgValidation, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct EntrypointDescriptor { + pub name: &'static str, + pub function: &'static str, + pub export: &'static str, + pub stable_id: &'static str, + pub argument_schema: &'static str, + pub result_schema: &'static str, + pub abi_version: u32, + pub bundle_manifest_entry: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct TaskDescriptor { + pub name: &'static str, + pub function: &'static str, + pub export: &'static str, + pub stable_id: &'static str, + pub argument_schema: &'static str, + pub result_schema: &'static str, + pub required_capabilities: &'static [&'static str], + pub restart_compatibility_hash: &'static str, + pub abi_version: u32, + pub source_file: &'static str, + pub source_line: u32, + pub probe_symbol: &'static str, + pub bundle_manifest_entry: bool, + pub remotely_startable: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RegisteredProgram { + pub entrypoints: &'static [EntrypointDescriptor], + pub tasks: &'static [TaskDescriptor], +} + +impl RegisteredProgram { + pub fn select_entrypoint(&self, name: &str) -> Option { + self.entrypoints + .iter() + .copied() + .find(|entrypoint| entrypoint.name == name) + } + + pub fn task(&self, name: &str) -> Option { + self.tasks.iter().copied().find(|task| task.name == name) + } +} + +#[doc(hidden)] +pub mod __private; + +pub mod spawn { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Mutex, OnceLock}; + + use serde::de::DeserializeOwned; + + #[cfg(target_arch = "wasm32")] + use crate::sdk_runtime::start_guest_host_task; + #[cfg(not(target_arch = "wasm32"))] + use crate::sdk_runtime::start_remote_task; + use crate::sdk_runtime::{join_remote_task, RemoteTaskHandle}; + pub use crate::sdk_runtime::{ProductRuntimeConfig, TaskRuntimeError}; + use crate::{validate_task_arg, EnvRef, TaskArg, TaskArgBudget}; + + static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1); + static RUNTIME_THREADS: OnceLock>> = OnceLock::new(); + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct RuntimeSpawnEvent { + pub virtual_thread_id: u64, + pub name: &'static str, + pub env: Option, + pub debugger_visible: bool, + } + + fn runtime_threads() -> &'static Mutex> { + RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new())) + } + + fn register_runtime_thread( + virtual_thread_id: u64, + name: &'static str, + env: Option, + ) -> RuntimeSpawnEvent { + let event = RuntimeSpawnEvent { + virtual_thread_id, + name, + env, + debugger_visible: true, + }; + runtime_threads().lock().unwrap().push(event.clone()); + event + } + + pub fn drain_runtime_spawn_events() -> Vec { + runtime_threads().lock().unwrap().drain(..).collect() + } + + pub fn runtime_spawn_events() -> Vec { + runtime_threads().lock().unwrap().clone() + } + + pub fn task(entry: F) -> TaskBuilder + where + F: FnOnce() -> R, + R: TaskArg, + { + TaskBuilder { + entry: Some(entry), + env: None, + name: "task", + task_id: None, + } + } + + pub fn task_with_arg(arg: A, entry: F) -> TaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> R, + R: TaskArg, + { + TaskWithArgBuilder { + arg: Some(arg), + entry: Some(entry), + env: None, + name: "task", + task_id: None, + arg_budget: TaskArgBudget::default(), + } + } + + pub fn async_task(entry: F) -> AsyncTaskBuilder + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + R: TaskArg, + { + AsyncTaskBuilder { + entry: Some(entry), + env: None, + name: "task", + task_id: None, + marker: std::marker::PhantomData, + } + } + + pub fn async_task_with_arg( + arg: A, + entry: F, + ) -> AsyncTaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> Fut, + Fut: std::future::Future, + R: TaskArg, + { + AsyncTaskWithArgBuilder { + arg: Some(arg), + entry: Some(entry), + env: None, + name: "task", + task_id: None, + arg_budget: TaskArgBudget::default(), + marker: std::marker::PhantomData, + } + } + + pub struct AsyncTaskBuilder + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + R: TaskArg, + { + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + entry: Option, + env: Option, + name: &'static str, + task_id: Option, + marker: std::marker::PhantomData (Fut, R)>, + } + + impl AsyncTaskBuilder + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + R: TaskArg + DeserializeOwned, + { + pub fn env(mut self, env: EnvRef) -> Self { + self.env = Some(env); + self + } + + pub fn name(mut self, name: &'static str) -> Self { + self.name = name; + self + } + + pub fn task_id(mut self, task_id: impl Into) -> Self { + self.task_id = Some(task_id.into()); + self + } + + pub async fn start(self) -> Result, TaskRuntimeError> { + let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); + let runtime_event = register_runtime_thread(id, self.name, self.env); + #[cfg(target_arch = "wasm32")] + { + let task_id = product_task_id::(self.task_id)?; + let remote = start_guest_host_task( + disasmer_core::TaskDefinitionId::from(task_id.as_str()), + self.env, + Vec::new(), + )?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(config) = ProductRuntimeConfig::from_env()? { + let task_id = product_task_id::(self.task_id)?; + let remote = start_remote_task(config, task_id, self.env, Vec::new())?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + let entry = self.entry.expect("task entry used once"); + Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: Some(entry().await), + remote: None, + }) + } + } + } + + pub struct AsyncTaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> Fut, + Fut: std::future::Future, + R: TaskArg, + { + arg: Option, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + entry: Option, + env: Option, + name: &'static str, + task_id: Option, + arg_budget: TaskArgBudget, + marker: std::marker::PhantomData (Fut, R)>, + } + + impl AsyncTaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> Fut, + Fut: std::future::Future, + R: TaskArg + DeserializeOwned, + { + pub fn env(mut self, env: EnvRef) -> Self { + self.env = Some(env); + self + } + + pub fn name(mut self, name: &'static str) -> Self { + self.name = name; + self + } + + pub fn task_id(mut self, task_id: impl Into) -> Self { + self.task_id = Some(task_id.into()); + self + } + + pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self { + self.arg_budget = arg_budget; + self + } + + pub async fn start(self) -> Result, TaskRuntimeError> { + let arg = self.arg.expect("task argument used once"); + validate_task_arg(&arg, self.arg_budget) + .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; + let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); + let runtime_event = register_runtime_thread(id, self.name, self.env); + #[cfg(target_arch = "wasm32")] + { + let task_id = product_task_id::(self.task_id)?; + let boundary = arg + .task_boundary_value() + .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; + let remote = start_guest_host_task( + disasmer_core::TaskDefinitionId::from(task_id.as_str()), + self.env, + vec![boundary], + )?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(config) = ProductRuntimeConfig::from_env()? { + let task_id = product_task_id::(self.task_id)?; + let boundary = arg + .task_boundary_value() + .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; + let remote = start_remote_task(config, task_id, self.env, vec![boundary])?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + let entry = self.entry.expect("task entry used once"); + Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: Some(entry(arg).await), + remote: None, + }) + } + } + } + + pub struct TaskBuilder + where + F: FnOnce() -> R, + R: TaskArg, + { + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + entry: Option, + env: Option, + name: &'static str, + task_id: Option, + } + + impl TaskBuilder + where + F: FnOnce() -> R, + R: TaskArg + DeserializeOwned, + { + pub fn env(mut self, env: EnvRef) -> Self { + self.env = Some(env); + self + } + + pub fn name(mut self, name: &'static str) -> Self { + self.name = name; + self + } + + pub fn task_id(mut self, task_id: impl Into) -> Self { + self.task_id = Some(task_id.into()); + self + } + + pub async fn start(self) -> Result, TaskRuntimeError> { + let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); + let runtime_event = register_runtime_thread(id, self.name, self.env); + #[cfg(target_arch = "wasm32")] + { + let task_id = product_task_id::(self.task_id)?; + let remote = start_guest_host_task( + disasmer_core::TaskDefinitionId::from(task_id.as_str()), + self.env, + Vec::new(), + )?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(config) = ProductRuntimeConfig::from_env()? { + let task_id = product_task_id::(self.task_id)?; + let remote = start_remote_task(config, task_id, self.env, Vec::new())?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + let entry = self.entry.expect("task entry used once"); + Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: Some(entry()), + remote: None, + }) + } + } + } + + pub struct TaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> R, + R: TaskArg, + { + arg: Option, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + entry: Option, + env: Option, + name: &'static str, + task_id: Option, + arg_budget: TaskArgBudget, + } + + impl TaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> R, + R: TaskArg + DeserializeOwned, + { + pub fn env(mut self, env: EnvRef) -> Self { + self.env = Some(env); + self + } + + pub fn name(mut self, name: &'static str) -> Self { + self.name = name; + self + } + + pub fn task_id(mut self, task_id: impl Into) -> Self { + self.task_id = Some(task_id.into()); + self + } + + pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self { + self.arg_budget = arg_budget; + self + } + + pub async fn start(self) -> Result, TaskRuntimeError> { + let arg = self.arg.expect("task argument used once"); + validate_task_arg(&arg, self.arg_budget) + .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; + let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); + let runtime_event = register_runtime_thread(id, self.name, self.env); + #[cfg(target_arch = "wasm32")] + { + let task_id = product_task_id::(self.task_id)?; + let boundary = arg + .task_boundary_value() + .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; + let remote = start_guest_host_task( + disasmer_core::TaskDefinitionId::from(task_id.as_str()), + self.env, + vec![boundary], + )?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(config) = ProductRuntimeConfig::from_env()? { + let task_id = product_task_id::(self.task_id)?; + let boundary = arg + .task_boundary_value() + .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; + let remote = start_remote_task(config, task_id, self.env, vec![boundary])?; + return Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: None, + remote: Some(remote), + }); + } + let entry = self.entry.expect("task entry used once"); + Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: Some(entry(arg)), + remote: None, + }) + } + } + } + + pub struct TaskHandle { + virtual_thread_id: u64, + name: &'static str, + env: Option, + debugger_visible: bool, + result: Option, + remote: Option, + } + + impl TaskHandle + where + R: TaskArg + DeserializeOwned, + { + pub fn virtual_thread_id(&self) -> u64 { + self.virtual_thread_id + } + + pub fn name(&self) -> &'static str { + self.name + } + + pub fn env(&self) -> Option { + self.env + } + + pub fn debugger_visible(&self) -> bool { + self.debugger_visible + } + + pub fn task_spec(&self) -> Option<&disasmer_core::TaskSpec> { + self.remote.as_ref().map(|remote| &remote.spec) + } + + pub async fn join(mut self) -> Result { + if let Some(remote) = self.remote.take() { + return join_remote_task(remote); + } + self.result.take().ok_or_else(|| { + TaskRuntimeError::Protocol("task handle was already joined".to_owned()) + }) + } + } + + fn product_task_id(explicit: Option) -> Result { + if let Some(explicit) = explicit { + return Ok(explicit); + } + let inferred = std::any::type_name::() + .rsplit("::") + .next() + .unwrap_or_default(); + if inferred.is_empty() || inferred.contains("closure") { + return Err(TaskRuntimeError::Configuration( + "product-mode spawn from a closure requires .task_id(\"registered-export\")" + .to_owned(), + )); + } + Ok(inferred.to_owned()) + } +} + +pub mod command { + use std::collections::BTreeMap; + use std::time::Duration; + + pub use crate::sdk_runtime::TaskRuntimeError; + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Output { + pub status_code: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct Command { + program: String, + args: Vec, + working_directory: String, + environment_variables: BTreeMap, + timeout: Duration, + network: disasmer_core::CommandNetworkPolicy, + } + + impl Command { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + working_directory: "/workspace".to_owned(), + environment_variables: BTreeMap::new(), + timeout: Duration::from_secs(15 * 60), + network: disasmer_core::CommandNetworkPolicy::Disabled, + } + } + + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + pub fn current_dir(mut self, directory: impl Into) -> Self { + self.working_directory = directory.into(); + self + } + + pub fn env(mut self, name: impl Into, value: impl Into) -> Self { + self.environment_variables.insert(name.into(), value.into()); + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn network_disabled(mut self) -> Self { + self.network = disasmer_core::CommandNetworkPolicy::Disabled; + self + } + + pub fn network_enabled(mut self) -> Self { + self.network = disasmer_core::CommandNetworkPolicy::Enabled; + self + } + + pub async fn output(self) -> Result { + #[cfg(target_arch = "wasm32")] + { + let timeout_ms = u64::try_from(self.timeout.as_millis()).map_err(|_| { + TaskRuntimeError::Argument( + "command timeout does not fit the task ABI".to_owned(), + ) + })?; + let output = crate::sdk_runtime::run_guest_host_command( + self.program, + self.args, + self.working_directory, + self.environment_variables, + timeout_ms, + self.network, + )?; + return Ok(Output { + status_code: output.status_code, + stdout: output.stdout, + stderr: output.stderr, + stdout_truncated: output.stdout_truncated, + stderr_truncated: output.stderr_truncated, + }); + } + #[cfg(not(target_arch = "wasm32"))] + Err(TaskRuntimeError::Configuration( + "Disasmer commands execute only as a granted host capability of a running Wasm task" + .to_owned(), + )) + } + } +} + +pub mod source { + use crate::{spawn::TaskRuntimeError, SourceSnapshot}; + + /// Snapshots the node-local project checkout without sending its source bytes + /// through the coordinator. + pub async fn snapshot() -> Result { + #[cfg(target_arch = "wasm32")] + { + let result = crate::sdk_runtime::guest_source_snapshot()?; + return Ok(SourceSnapshot { + digest: result.snapshot.as_str().to_owned(), + }); + } + #[cfg(not(target_arch = "wasm32"))] + Err(TaskRuntimeError::Configuration( + "source snapshots are produced only by a source-capable Disasmer node".to_owned(), + )) + } +} + +pub mod fs { + use crate::{spawn::TaskRuntimeError, Artifact}; + + pub const OUTPUT_ROOT: &str = "/disasmer/output"; + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct OutputPath { + relative: String, + container_path: String, + } + + impl OutputPath { + pub fn relative(&self) -> &str { + &self.relative + } + + pub fn as_str(&self) -> &str { + &self.container_path + } + } + + pub fn output_path(relative: impl Into) -> Result { + let relative = relative.into(); + validate_relative_path(&relative)?; + Ok(OutputPath { + container_path: format!("{OUTPUT_ROOT}/{relative}"), + relative, + }) + } + + pub async fn flush(path: &OutputPath) -> Result { + #[cfg(target_arch = "wasm32")] + { + let retained = crate::sdk_runtime::guest_vfs_operation( + disasmer_core::WasmHostVfsOperation::FlushOutput { + relative_path: path.relative.clone(), + }, + )?; + return Ok(Artifact { + id: retained.artifact.id.as_str().to_owned(), + digest: retained.artifact.digest.as_str().to_owned(), + size_bytes: retained.artifact.size_bytes, + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = path; + Err(TaskRuntimeError::Configuration( + "Disasmer output files are flushed only by a running Wasm task on a retaining node" + .to_owned(), + )) + } + } + + pub async fn materialize( + artifact: &Artifact, + relative: impl Into, + ) -> Result { + let output = output_path(relative)?; + #[cfg(target_arch = "wasm32")] + { + let digest = crate::task_args::parse_handle_digest(&artifact.digest) + .map_err(|error| TaskRuntimeError::Argument(error.to_string()))?; + crate::sdk_runtime::guest_vfs_operation( + disasmer_core::WasmHostVfsOperation::MaterializeArtifact { + artifact: disasmer_core::ArtifactHandle { + id: disasmer_core::ArtifactId::from(artifact.id.as_str()), + digest, + size_bytes: artifact.size_bytes, + }, + relative_path: output.relative.clone(), + }, + )?; + return Ok(output); + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (&output, artifact); + Err(TaskRuntimeError::Configuration( + "Disasmer artifacts are materialized only by a running Wasm task on a retaining node" + .to_owned(), + )) + } + } + + fn validate_relative_path(path: &str) -> Result<(), TaskRuntimeError> { + if path.is_empty() + || path.len() > 240 + || path.starts_with('/') + || path.starts_with('\\') + || path.split('/').any(|component| { + component.is_empty() + || component == "." + || component == ".." + || !component.chars().all(|character| { + character.is_ascii_alphanumeric() || "._-".contains(character) + }) + }) + { + return Err(TaskRuntimeError::Argument( + "output path must be a safe task-output-relative path".to_owned(), + )); + } + Ok(()) + } +} + +pub mod process { + pub use crate::sdk_runtime::TaskRuntimeError; + + pub fn cancellation_requested() -> Result { + #[cfg(target_arch = "wasm32")] + { + return crate::sdk_runtime::guest_cancellation_requested(); + } + #[cfg(not(target_arch = "wasm32"))] + Ok(false) + } +} + +#[doc(hidden)] +pub mod debug { + pub use crate::sdk_runtime::TaskRuntimeError; + + pub fn probe(symbol: impl Into) -> Result<(), TaskRuntimeError> { + #[cfg(target_arch = "wasm32")] + { + let _ = crate::sdk_runtime::guest_debug_probe(symbol.into())?; + return Ok(()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = symbol.into(); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use futures_executor::block_on; + + #[test] + fn env_macro_creates_logical_environment_reference() { + let env = crate::env!("linux"); + + assert_eq!(env.name, "linux"); + } + + #[test] + fn spawn_task_start_join_returns_small_result() { + let result = block_on(async { + let handle = crate::spawn::task(|| 42) + .name("compile linux") + .env(crate::env!("linux")) + .start() + .await + .unwrap(); + assert_eq!(handle.name(), "compile linux"); + assert_eq!(handle.env().unwrap().name, "linux"); + assert!(handle.debugger_visible()); + handle.join().await.unwrap() + }); + + assert_eq!(result, 42); + } + + #[test] + fn spawn_task_start_registers_debugger_visible_runtime_thread() { + let handle = block_on(async { + crate::spawn::task(|| 7_u32) + .name("sdk-runtime-thread-test") + .env(crate::env!("linux")) + .start() + .await + .unwrap() + }); + let events = crate::spawn::runtime_spawn_events(); + let event = events + .iter() + .find(|event| event.virtual_thread_id == handle.virtual_thread_id()) + .expect("spawn runtime event should exist for task handle"); + + assert!(handle.debugger_visible()); + assert!(event.debugger_visible); + assert_eq!(event.name, "sdk-runtime-thread-test"); + assert_eq!(event.env.unwrap().name, "linux"); + assert_eq!(block_on(handle.join()).unwrap(), 7); + } + + #[test] + fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() { + let artifact = crate::Artifact { + id: "artifact://build/app".to_owned(), + digest: disasmer_core::Digest::sha256("app").as_str().to_owned(), + size_bytes: 3, + }; + let artifact_validation = crate::validate_task_arg( + &artifact, + crate::TaskArgBudget { + max_inline_bytes: 4, + }, + ) + .unwrap(); + assert_eq!(artifact_validation.kind, crate::TaskArgKind::Handle); + + let bytes = vec![1_u8, 2, 3, 4, 5]; + let error = crate::validate_task_arg( + &bytes, + crate::TaskArgBudget { + max_inline_bytes: 4, + }, + ) + .unwrap_err(); + assert!(matches!(error, crate::TaskArgError::TooLarge { .. })); + } + + #[test] + fn spawn_task_with_arg_rejects_large_inline_argument_before_dispatch() { + let dispatched = std::cell::Cell::new(false); + let result = block_on(async { + crate::spawn::task_with_arg(vec![1_u8, 2, 3, 4, 5], |_| { + dispatched.set(true); + 42_u32 + }) + .arg_budget(crate::TaskArgBudget { + max_inline_bytes: 4, + }) + .start() + .await + }); + + assert!(matches!( + result, + Err(crate::spawn::TaskRuntimeError::Argument(_)) + )); + assert!(!dispatched.get()); + } + + #[test] + fn spawn_task_with_arg_allows_runtime_handles_under_inline_budget() { + let artifact = crate::Artifact { + id: "artifact://build/app".to_owned(), + digest: disasmer_core::Digest::sha256("app").as_str().to_owned(), + size_bytes: 3, + }; + let result = block_on(async { + let handle = crate::spawn::task_with_arg(artifact, |artifact| artifact.id) + .name("publish artifact") + .env(crate::env!("linux")) + .arg_budget(crate::TaskArgBudget { + max_inline_bytes: 4, + }) + .start() + .await + .unwrap(); + + assert_eq!(handle.name(), "publish artifact"); + handle.join().await.unwrap() + }); + + assert_eq!(result, "artifact://build/app"); + } + + #[test] + fn host_only_task_arg_error_names_rejected_type() { + let error = crate::reject_host_only_task_arg::<*const u8>(); + + assert!(error.to_string().contains("*const u8")); + assert!(error.to_string().contains("host-only")); + } +} diff --git a/crates/disasmer-sdk/src/sdk_runtime.rs b/crates/disasmer-sdk/src/sdk_runtime.rs new file mode 100644 index 0000000..f9a281d --- /dev/null +++ b/crates/disasmer-sdk/src/sdk_runtime.rs @@ -0,0 +1,621 @@ +#[cfg(not(target_arch = "wasm32"))] +use std::collections::BTreeSet; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use disasmer_control::ControlSession; +use disasmer_core::{ + coordinator_wire_request, Digest, TaskBoundaryValue, TaskDefinitionId, TaskJoinResult, + TaskJoinState, TaskSpec, +}; +#[cfg(not(target_arch = "wasm32"))] +use disasmer_core::{ + EnvironmentRequirements, ProcessId, ProjectId, TaskDispatch, TenantId, WasmExportAbi, +}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; + +use crate::EnvRef; + +const COORDINATOR_ENV: &str = "DISASMER_SDK_COORDINATOR"; +const TENANT_ENV: &str = "DISASMER_SDK_TENANT"; +const PROJECT_ENV: &str = "DISASMER_SDK_PROJECT"; +const PROCESS_ENV: &str = "DISASMER_SDK_PROCESS"; +const USER_ENV: &str = "DISASMER_SDK_USER"; +const SESSION_SECRET_ENV: &str = "DISASMER_SDK_SESSION_SECRET"; +const BUNDLE_DIGEST_ENV: &str = "DISASMER_SDK_BUNDLE_DIGEST"; +const WASM_MODULE_ENV: &str = "DISASMER_SDK_WASM_MODULE_BASE64"; +const WASM_MODULE_PATH_ENV: &str = "DISASMER_SDK_WASM_MODULE_PATH"; +const VFS_EPOCH_ENV: &str = "DISASMER_SDK_VFS_EPOCH"; +#[cfg(not(target_arch = "wasm32"))] +static NEXT_PRODUCT_TASK_INSTANCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProductRuntimeConfig { + pub coordinator: String, + pub tenant: String, + pub project: String, + pub process: String, + pub actor_user: String, + pub session_secret: Option, + pub bundle_digest: Digest, + pub wasm_module_base64: String, + pub vfs_epoch: u64, + pub join_poll_interval: Duration, + pub join_timeout: Duration, +} + +impl ProductRuntimeConfig { + pub fn from_env() -> Result, TaskRuntimeError> { + let Some(coordinator) = nonempty_env(COORDINATOR_ENV) else { + return Ok(None); + }; + let tenant = required_env(TENANT_ENV)?; + let project = required_env(PROJECT_ENV)?; + let process = required_env(PROCESS_ENV)?; + let actor_user = required_env(USER_ENV)?; + let bundle_digest = parse_digest(&required_env(BUNDLE_DIGEST_ENV)?)?; + let wasm_module_base64 = if let Some(module) = nonempty_env(WASM_MODULE_ENV) { + module + } else { + let module_path = required_env(WASM_MODULE_PATH_ENV)?; + let module = std::fs::read(&module_path).map_err(|error| { + TaskRuntimeError::Configuration(format!( + "read {WASM_MODULE_PATH_ENV} `{module_path}`: {error}" + )) + })?; + BASE64_STANDARD.encode(module) + }; + let vfs_epoch = required_env(VFS_EPOCH_ENV)?.parse::().map_err(|_| { + TaskRuntimeError::Configuration(format!("{VFS_EPOCH_ENV} must be an unsigned integer")) + })?; + Ok(Some(Self { + coordinator, + tenant, + project, + process, + actor_user, + session_secret: nonempty_env(SESSION_SECRET_ENV), + bundle_digest, + wasm_module_base64, + vfs_epoch, + join_poll_interval: Duration::from_millis(25), + join_timeout: Duration::from_secs(120), + })) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct RemoteTaskHandle { + pub config: Option, + pub spec: TaskSpec, + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + pub host_handle_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TaskRuntimeError { + Argument(String), + Configuration(String), + Transport(String), + Protocol(String), + RemoteTask(String), + ResultDecode(String), +} + +impl std::fmt::Display for TaskRuntimeError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (kind, message) = match self { + Self::Argument(message) => ("task argument", message), + Self::Configuration(message) => ("product runtime configuration", message), + Self::Transport(message) => ("coordinator transport", message), + Self::Protocol(message) => ("coordinator protocol", message), + Self::RemoteTask(message) => ("remote task", message), + Self::ResultDecode(message) => ("remote result", message), + }; + write!(formatter, "{kind} error: {message}") + } +} + +impl std::error::Error for TaskRuntimeError {} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn start_remote_task( + config: ProductRuntimeConfig, + task_definition: String, + environment: Option, + args: Vec, +) -> Result { + if task_definition.trim().is_empty() || task_definition.len() > 128 { + return Err(TaskRuntimeError::Configuration( + "product task id must be non-empty and at most 128 bytes".to_owned(), + )); + } + let requirements = EnvironmentRequirements { + os: None, + arch: None, + capabilities: BTreeSet::new(), + }; + let required_artifacts = args + .iter() + .flat_map(TaskBoundaryValue::required_artifacts) + .collect::>() + .into_iter() + .collect::>(); + let source_snapshots = args + .iter() + .flat_map(TaskBoundaryValue::source_snapshots) + .collect::>(); + if source_snapshots.len() > 1 { + return Err(TaskRuntimeError::Argument( + "one task invocation cannot require multiple distinct source snapshots".to_owned(), + )); + } + let source_snapshot = source_snapshots.into_iter().next(); + let task_instance = disasmer_core::TaskInstanceId::new(format!( + "ti:{}:{}", + config.process, + NEXT_PRODUCT_TASK_INSTANCE.fetch_add(1, Ordering::SeqCst) + )); + let spec = TaskSpec { + tenant: TenantId::from(config.tenant.as_str()), + project: ProjectId::from(config.project.as_str()), + process: ProcessId::from(config.process.as_str()), + task_definition: TaskDefinitionId::from(task_definition.as_str()), + task_instance: task_instance.clone(), + dispatch: TaskDispatch::CoordinatorNodeWasm { + export: None, + abi: WasmExportAbi::TaskV1, + }, + environment_id: environment.map(|environment| environment.name.to_owned()), + environment: Some(requirements.clone()), + environment_digest: None, + required_capabilities: BTreeSet::new(), + dependency_cache: None, + source_snapshot, + required_artifacts, + args, + vfs_epoch: config.vfs_epoch, + bundle_digest: Some(config.bundle_digest.clone()), + }; + let payload = json!({ + "type": "launch_task", + "tenant": config.tenant, + "project": config.project, + "actor_user": config.actor_user, + "task_spec": spec, + "wait_for_node": true, + "artifact_path": format!("/vfs/artifacts/{}-result.json", spec.task_instance), + "wasm_module_base64": config.wasm_module_base64, + }); + let response = coordinator_request(&config, payload)?; + match response.get("type").and_then(Value::as_str) { + Some("task_launched") => { + let accepted_spec: TaskSpec = serde_json::from_value( + response + .pointer("/assignment/task_spec") + .cloned() + .ok_or_else(|| { + TaskRuntimeError::Protocol( + "task launch response omitted assignment TaskSpec".to_owned(), + ) + })?, + ) + .map_err(|error| TaskRuntimeError::Protocol(error.to_string()))?; + if accepted_spec != spec { + return Err(TaskRuntimeError::Protocol( + "coordinator assignment TaskSpec differs from SDK submission".to_owned(), + )); + } + } + Some("task_queued") => {} + Some("error") => return Err(remote_error(&response)), + other => { + return Err(TaskRuntimeError::Protocol(format!( + "unexpected launch response type {other:?}" + ))) + } + } + Ok(RemoteTaskHandle { + config: Some(config), + spec, + host_handle_id: None, + }) +} + +pub(crate) fn join_remote_task(handle: RemoteTaskHandle) -> Result +where + R: DeserializeOwned, +{ + #[cfg(target_arch = "wasm32")] + if let Some(handle_id) = handle.host_handle_id { + let response: Result = guest_host_call( + &disasmer_core::WasmHostTaskJoinRequest { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + handle_id, + }, + GuestHostCall::Join, + )?; + let response = response.map_err(TaskRuntimeError::RemoteTask)?; + return decode_boundary_value(response.result); + } + let config = handle.config.as_ref().ok_or_else(|| { + TaskRuntimeError::Protocol("remote task handle has no coordinator authority".to_owned()) + })?; + let started = Instant::now(); + loop { + let response = coordinator_request( + config, + json!({ + "type": "join_task", + "tenant": config.tenant, + "project": config.project, + "actor_user": config.actor_user, + "process": config.process, + "task": handle.spec.task_instance, + }), + )?; + if response.get("type").and_then(Value::as_str) == Some("error") { + return Err(remote_error(&response)); + } + if response.get("type").and_then(Value::as_str) != Some("task_joined") { + return Err(TaskRuntimeError::Protocol( + "join response was not task_joined".to_owned(), + )); + } + let join: TaskJoinResult = serde_json::from_value( + response + .get("join") + .cloned() + .ok_or_else(|| TaskRuntimeError::Protocol("join result missing".to_owned()))?, + ) + .map_err(|error| TaskRuntimeError::Protocol(error.to_string()))?; + match join.state { + TaskJoinState::Pending => { + if started.elapsed() >= config.join_timeout { + return Err(TaskRuntimeError::RemoteTask(format!( + "timed out waiting for signed node completion of {}", + handle.spec.task_instance + ))); + } + thread::sleep(config.join_poll_interval); + } + TaskJoinState::Completed => { + if !join.remote_completion_observed { + return Err(TaskRuntimeError::Protocol( + "completed join did not prove a signed node completion".to_owned(), + )); + } + let boundary = join.result.ok_or_else(|| { + TaskRuntimeError::ResultDecode( + "completed remote task did not return a boundary value".to_owned(), + ) + })?; + return decode_boundary_value(boundary); + } + TaskJoinState::Failed | TaskJoinState::Cancelled => { + return Err(TaskRuntimeError::RemoteTask(join.message)); + } + } + } +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn start_guest_host_task( + task_definition: TaskDefinitionId, + environment: Option, + args: Vec, +) -> Result { + let request = disasmer_core::WasmHostTaskStartRequest { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + task_definition, + environment_id: environment.map(|environment| environment.name.to_owned()), + args, + }; + request.validate().map_err(TaskRuntimeError::Argument)?; + let response: Result = + guest_host_call(&request, GuestHostCall::Start)?; + let response = response.map_err(TaskRuntimeError::RemoteTask)?; + Ok(RemoteTaskHandle { + config: None, + spec: response.task_spec, + host_handle_id: Some(response.handle_id), + }) +} + +#[cfg(target_arch = "wasm32")] +enum GuestHostCall { + Start, + Join, + Command, + TaskControl, + DebugProbe, + SourceSnapshot, + Vfs, +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn run_guest_host_command( + program: String, + args: Vec, + working_directory: String, + environment_variables: std::collections::BTreeMap, + timeout_ms: u64, + network: disasmer_core::CommandNetworkPolicy, +) -> Result { + let request = disasmer_core::WasmHostCommandRequest { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + program, + args, + working_directory, + environment_variables, + timeout_ms, + network, + }; + request.validate().map_err(TaskRuntimeError::Argument)?; + let response: Result = + guest_host_call(&request, GuestHostCall::Command)?; + response.map_err(TaskRuntimeError::RemoteTask) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn guest_cancellation_requested() -> Result { + let request = disasmer_core::WasmHostTaskControlRequest { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + }; + request.validate().map_err(TaskRuntimeError::Argument)?; + let response: Result = + guest_host_call(&request, GuestHostCall::TaskControl)?; + response + .map(|result| result.cancellation_requested) + .map_err(TaskRuntimeError::RemoteTask) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn guest_debug_probe( + symbol: String, +) -> Result { + let request = disasmer_core::WasmHostDebugProbeRequest { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + symbol, + }; + request.validate().map_err(TaskRuntimeError::Argument)?; + let response: Result = + guest_host_call(&request, GuestHostCall::DebugProbe)?; + response.map_err(TaskRuntimeError::RemoteTask) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn guest_vfs_operation( + operation: disasmer_core::WasmHostVfsOperation, +) -> Result { + let request = disasmer_core::WasmHostVfsRequest { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + operation, + }; + request.validate().map_err(TaskRuntimeError::Argument)?; + let response: Result = + guest_host_call(&request, GuestHostCall::Vfs)?; + response.map_err(TaskRuntimeError::RemoteTask) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn guest_source_snapshot( +) -> Result { + let request = disasmer_core::WasmHostSourceSnapshotRequest { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + }; + request.validate().map_err(TaskRuntimeError::Argument)?; + let response: Result = + guest_host_call(&request, GuestHostCall::SourceSnapshot)?; + response.map_err(TaskRuntimeError::RemoteTask) +} + +#[cfg(target_arch = "wasm32")] +fn guest_host_call(input: &I, call: GuestHostCall) -> Result +where + I: serde::Serialize, + O: DeserializeOwned, +{ + #[link(wasm_import_module = "disasmer")] + unsafe extern "C" { + #[link_name = "task_start_v1"] + fn task_start_v1( + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + ) -> i32; + #[link_name = "task_join_v1"] + fn task_join_v1( + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + ) -> i32; + #[link_name = "command_run_v1"] + fn command_run_v1( + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + ) -> i32; + #[link_name = "vfs_operation_v1"] + fn vfs_operation_v1( + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + ) -> i32; + #[link_name = "source_snapshot_v1"] + fn source_snapshot_v1( + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + ) -> i32; + #[link_name = "task_control_v1"] + fn task_control_v1( + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + ) -> i32; + #[link_name = "debug_probe_v1"] + fn debug_probe_v1( + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + ) -> i32; + } + + let input = + serde_json::to_vec(input).map_err(|error| TaskRuntimeError::Protocol(error.to_string()))?; + if input.len() > disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES { + return Err(TaskRuntimeError::Argument( + "Wasm host-call request exceeds the task ABI limit".to_owned(), + )); + } + let mut output = vec![0_u8; disasmer_core::MAX_WASM_TASK_ENVELOPE_BYTES]; + let written = unsafe { + match call { + GuestHostCall::Start => task_start_v1( + input.as_ptr() as u32, + input.len() as u32, + output.as_mut_ptr() as u32, + output.len() as u32, + ), + GuestHostCall::Join => task_join_v1( + input.as_ptr() as u32, + input.len() as u32, + output.as_mut_ptr() as u32, + output.len() as u32, + ), + GuestHostCall::Command => command_run_v1( + input.as_ptr() as u32, + input.len() as u32, + output.as_mut_ptr() as u32, + output.len() as u32, + ), + GuestHostCall::TaskControl => task_control_v1( + input.as_ptr() as u32, + input.len() as u32, + output.as_mut_ptr() as u32, + output.len() as u32, + ), + GuestHostCall::DebugProbe => debug_probe_v1( + input.as_ptr() as u32, + input.len() as u32, + output.as_mut_ptr() as u32, + output.len() as u32, + ), + GuestHostCall::Vfs => vfs_operation_v1( + input.as_ptr() as u32, + input.len() as u32, + output.as_mut_ptr() as u32, + output.len() as u32, + ), + GuestHostCall::SourceSnapshot => source_snapshot_v1( + input.as_ptr() as u32, + input.len() as u32, + output.as_mut_ptr() as u32, + output.len() as u32, + ), + } + }; + if written <= 0 || written as usize > output.len() { + return Err(TaskRuntimeError::Protocol(format!( + "Wasm host-call returned invalid response length {written}" + ))); + } + serde_json::from_slice(&output[..written as usize]) + .map_err(|error| TaskRuntimeError::Protocol(error.to_string())) +} + +fn coordinator_request( + config: &ProductRuntimeConfig, + payload: Value, +) -> Result { + let payload = if let Some(session_secret) = &config.session_secret { + let request = payload + .as_object() + .cloned() + .ok_or_else(|| TaskRuntimeError::Protocol("request must be an object".to_owned()))?; + let request = request + .into_iter() + .filter(|(key, _)| !matches!(key.as_str(), "tenant" | "project" | "actor_user")) + .collect::>(); + json!({ + "type": "authenticated", + "session_secret": session_secret, + "request": request, + }) + } else { + payload + }; + let wire = coordinator_wire_request("sdk-product-runtime", payload); + let mut session = ControlSession::connect(&config.coordinator) + .map_err(|error| TaskRuntimeError::Transport(error.to_string()))?; + session + .request(&wire) + .map_err(|error| TaskRuntimeError::Transport(error.to_string())) +} + +fn decode_boundary_value(value: TaskBoundaryValue) -> Result +where + R: DeserializeOwned, +{ + let value = match value { + TaskBoundaryValue::SmallJson(value) => value, + TaskBoundaryValue::Structured(structured) => structured.value, + TaskBoundaryValue::SourceSnapshot(digest) => json!({ "digest": digest }), + TaskBoundaryValue::Blob(digest) => json!({ "digest": digest }), + TaskBoundaryValue::Artifact(artifact) => json!({ + "id": artifact.id, + "digest": artifact.digest, + "size_bytes": artifact.size_bytes, + }), + TaskBoundaryValue::VfsManifest(digest) => json!({ "digest": digest }), + }; + serde_json::from_value(value).map_err(|error| TaskRuntimeError::ResultDecode(error.to_string())) +} + +fn remote_error(response: &Value) -> TaskRuntimeError { + TaskRuntimeError::RemoteTask( + response + .get("message") + .and_then(Value::as_str) + .unwrap_or("coordinator rejected task request") + .to_owned(), + ) +} + +fn parse_digest(value: &str) -> Result { + let digest: Digest = serde_json::from_value(Value::String(value.to_owned())) + .map_err(|error| TaskRuntimeError::Configuration(error.to_string()))?; + if !digest.is_valid_sha256() { + return Err(TaskRuntimeError::Configuration(format!( + "{BUNDLE_DIGEST_ENV} must be a lowercase sha256 digest" + ))); + } + Ok(digest) +} + +fn required_env(name: &str) -> Result { + nonempty_env(name).ok_or_else(|| { + TaskRuntimeError::Configuration(format!( + "{name} is required when {COORDINATOR_ENV} enables product mode" + )) + }) +} + +fn nonempty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} diff --git a/crates/disasmer-sdk/src/task_args.rs b/crates/disasmer-sdk/src/task_args.rs new file mode 100644 index 0000000..1f0f29a --- /dev/null +++ b/crates/disasmer-sdk/src/task_args.rs @@ -0,0 +1,250 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceSnapshot { + pub digest: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Blob { + pub digest: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Artifact { + pub id: String, + pub digest: String, + pub size_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvRef { + pub name: &'static str, +} + +impl EnvRef { + pub const fn new_static(name: &'static str) -> Self { + Self { name } + } +} + +#[macro_export] +macro_rules! env { + ($name:literal) => { + $crate::EnvRef::new_static($name) + }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskArgKind { + SmallSerialized, + Structured, + Handle, +} + +/// A value allowed to cross a Disasmer task boundary. +/// +/// Implementations are deliberately limited to small owned values and explicit +/// Disasmer handles. Host-only values fail at compile time because they do not +/// implement `TaskArg`: +/// +/// ```compile_fail +/// let borrowed = String::from("host-owned"); +/// let _ = disasmer::spawn::task_with_arg(borrowed.as_str(), |_| 1_u32); +/// ``` +/// +/// ```compile_fail +/// let pointer = std::ptr::null::(); +/// let _ = disasmer::spawn::task_with_arg(pointer, |_| 1_u32); +/// ``` +/// +/// ```compile_fail +/// let file = std::fs::File::open("Cargo.toml").unwrap(); +/// let _ = disasmer::spawn::task_with_arg(file, |_| 1_u32); +/// ``` +/// +/// ```compile_fail +/// let lock = std::sync::Mutex::new(1_u32); +/// let guard = lock.lock().unwrap(); +/// let _ = disasmer::spawn::task_with_arg(guard, |_| 1_u32); +/// ``` +pub trait TaskArg: Serialize { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::SmallSerialized + } + + fn task_boundary_value(&self) -> Result { + serde_json::to_value(self) + .map(disasmer_core::TaskBoundaryValue::SmallJson) + .map_err(|error| TaskArgError::Serialization(error.to_string())) + } +} + +macro_rules! small_task_arg { + ($($ty:ty),+ $(,)?) => { + $( + impl TaskArg for $ty {} + )+ + }; +} + +small_task_arg!( + (), + bool, + char, + String, + i8, + i16, + i32, + i64, + isize, + u8, + u16, + u32, + u64, + usize, + f32, + f64, +); + +impl TaskArg for SourceSnapshot { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } + + fn task_boundary_value(&self) -> Result { + parse_handle_digest(&self.digest).map(disasmer_core::TaskBoundaryValue::SourceSnapshot) + } +} + +impl TaskArg for Blob { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } + + fn task_boundary_value(&self) -> Result { + parse_handle_digest(&self.digest).map(disasmer_core::TaskBoundaryValue::Blob) + } +} + +impl TaskArg for Artifact { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } + + fn task_boundary_value(&self) -> Result { + Ok(disasmer_core::TaskBoundaryValue::Artifact( + disasmer_core::ArtifactHandle { + id: disasmer_core::ArtifactId::from(self.id.as_str()), + digest: parse_handle_digest(&self.digest)?, + size_bytes: self.size_bytes, + }, + )) + } +} + +impl TaskArg for Option +where + T: TaskArg + crate::__private::CollectTaskHandles, +{ + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Structured + } + + fn task_boundary_value(&self) -> Result { + crate::__private::structured_task_boundary(self) + } +} + +impl TaskArg for Vec +where + T: TaskArg + crate::__private::CollectTaskHandles, +{ + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Structured + } + + fn task_boundary_value(&self) -> Result { + crate::__private::structured_task_boundary(self) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TaskArgBudget { + pub max_inline_bytes: usize, +} + +impl Default for TaskArgBudget { + fn default() -> Self { + Self { + max_inline_bytes: 64 * 1024, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaskArgValidation { + pub inline_bytes: usize, + pub kind: TaskArgKind, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TaskArgError { + Serialization(String), + TooLarge { size: usize, limit: usize }, + HostOnly { type_name: &'static str }, +} + +impl std::fmt::Display for TaskArgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Serialization(error) => write!(f, "task argument could not serialize: {error}"), + Self::TooLarge { size, limit } => write!( + f, + "task argument is {size} bytes; inline task arguments are limited to {limit} bytes, use SourceSnapshot, Blob, Artifact, or VFS handles" + ), + Self::HostOnly { type_name } => write!( + f, + "task boundary value `{type_name}` is host-only; use small serialized data or handles" + ), + } + } +} + +impl std::error::Error for TaskArgError {} + +pub fn validate_task_arg( + value: &T, + budget: TaskArgBudget, +) -> Result +where + T: TaskArg + ?Sized, +{ + let bytes = serde_json::to_vec(value) + .map_err(|error| TaskArgError::Serialization(error.to_string()))?; + let kind = value.task_arg_kind(); + if kind != TaskArgKind::Handle && bytes.len() > budget.max_inline_bytes { + return Err(TaskArgError::TooLarge { + size: bytes.len(), + limit: budget.max_inline_bytes, + }); + } + Ok(TaskArgValidation { + inline_bytes: bytes.len(), + kind, + }) +} + +pub fn reject_host_only_task_arg() -> TaskArgError { + TaskArgError::HostOnly { + type_name: std::any::type_name::(), + } +} + +pub(crate) fn parse_handle_digest(value: &str) -> Result { + serde_json::from_value(serde_json::Value::String(value.to_owned())).map_err(|_| { + TaskArgError::Serialization( + "handle digest must be a serialized sha256 digest issued by Disasmer".to_owned(), + ) + }) +} diff --git a/crates/disasmer-sdk/tests/macros.rs b/crates/disasmer-sdk/tests/macros.rs new file mode 100644 index 0000000..1a5edc5 --- /dev/null +++ b/crates/disasmer-sdk/tests/macros.rs @@ -0,0 +1,180 @@ +use futures_executor::block_on; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, disasmer::TaskArg)] +struct CompoundBoundary { + label: String, + source: disasmer::SourceSnapshot, + optional_artifact: Option, + artifacts: Vec, +} + +#[disasmer::main] +fn build_main() -> u32 { + 1 +} + +#[disasmer::main(name = "release")] +fn release_main() -> u32 { + 2 +} + +#[disasmer::task(name = "compile-linux", capabilities = "Command")] +fn compile_linux() -> u32 { + 41 +} + +#[disasmer::task] +fn package_release() -> disasmer::Artifact { + disasmer::Artifact { + id: "artifact://package/release.tar.zst".to_owned(), + digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_owned(), + size_bytes: 0, + } +} + +#[disasmer::main(name = "async-build")] +async fn async_build_main() -> Result<(), &'static str> { + Ok(()) +} + +#[disasmer::task(name = "async-compile")] +async fn async_compile(input: u32) -> Result { + Ok(input + 1) +} + +#[disasmer::task(name = "roundtrip-compound")] +async fn roundtrip_compound(input: CompoundBoundary) -> CompoundBoundary { + input +} + +#[test] +fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() { + let program = disasmer::RegisteredProgram { + entrypoints: &[ + __DISASMER_ENTRYPOINT_BUILD_MAIN, + __DISASMER_ENTRYPOINT_RELEASE_MAIN, + ], + tasks: &[ + __DISASMER_TASK_COMPILE_LINUX, + __DISASMER_TASK_PACKAGE_RELEASE, + ], + }; + assert_eq!( + program.select_entrypoint("build").unwrap().function, + "build_main" + ); + assert_eq!( + program.select_entrypoint("release").unwrap().function, + "release_main" + ); + assert_eq!( + program.task("compile-linux").unwrap().function, + "compile_linux" + ); + let task = program.task("compile-linux").unwrap(); + assert!(task.stable_id.starts_with("sha256:")); + assert_eq!(task.argument_schema, ""); + assert_eq!(task.result_schema, "u32"); + assert_eq!(task.required_capabilities, ["Command"]); + assert!(task.restart_compatibility_hash.starts_with("sha256:")); + assert_eq!(task.abi_version, 1); + assert!(task.source_file.ends_with("macros.rs")); + assert!(task.source_line > 0); + assert_eq!(task.probe_symbol, "disasmer.probe.compile_linux"); + assert!(task.bundle_manifest_entry); + assert!(task.remotely_startable); + let entrypoint = program.select_entrypoint("build").unwrap(); + assert!(entrypoint.stable_id.starts_with("sha256:")); + assert_eq!(entrypoint.result_schema, "u32"); + assert_eq!(entrypoint.abi_version, 1); + assert!(entrypoint.bundle_manifest_entry); + + let result = block_on(async { + let handle = disasmer::spawn::task(|| compile_linux() + build_main()) + .name("compile linux") + .env(disasmer::env!("linux")) + .start() + .await + .unwrap(); + + assert!(handle.virtual_thread_id() > 0); + handle.join().await.unwrap() + }); + + assert_eq!(result, 42); + assert_eq!(release_main(), 2); +} + +#[test] +fn task_boundaries_accept_small_values_and_runtime_handles() { + let small = disasmer::validate_task_arg(&7_u32, disasmer::TaskArgBudget::default()).unwrap(); + assert_eq!(small.kind, disasmer::TaskArgKind::SmallSerialized); + + let artifact = package_release(); + let artifact = disasmer::validate_task_arg( + &artifact, + disasmer::TaskArgBudget { + max_inline_bytes: 4, + }, + ) + .unwrap(); + assert_eq!(artifact.kind, disasmer::TaskArgKind::Handle); +} + +#[test] +fn async_main_and_task_attributes_preserve_real_rust_futures_and_results() { + assert_eq!(block_on(async_build_main()), Ok(())); + assert_eq!(block_on(async_compile(41)), Ok(42)); + assert_eq!(__DISASMER_ENTRYPOINT_ASYNC_BUILD_MAIN.name, "async-build"); + assert_eq!(__DISASMER_TASK_ASYNC_COMPILE.name, "async-compile"); +} + +#[test] +fn derived_compound_boundary_keeps_nested_handles_out_of_opaque_json() { + let source = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let value = CompoundBoundary { + label: "release".to_owned(), + source: disasmer::SourceSnapshot { + digest: source.to_owned(), + }, + optional_artifact: Some(disasmer::Artifact { + id: "optional.bin".to_owned(), + digest: source.to_owned(), + size_bytes: 0, + }), + artifacts: vec![ + disasmer::Artifact { + id: "one.bin".to_owned(), + digest: source.to_owned(), + size_bytes: 0, + }, + disasmer::Artifact { + id: "two.bin".to_owned(), + digest: source.to_owned(), + size_bytes: 0, + }, + ], + }; + assert_eq!(block_on(roundtrip_compound(value.clone())), value); + let boundary = disasmer::TaskArg::task_boundary_value(&value).unwrap(); + let disasmer::core::TaskBoundaryValue::Structured(structured) = boundary else { + panic!("derived boundary must be structured"); + }; + assert_eq!(structured.handles.len(), 4); + assert_eq!( + disasmer::core::TaskBoundaryValue::Structured(structured.clone()).source_snapshots(), + vec![disasmer::core::Digest::sha256([])] + ); + assert_eq!( + disasmer::core::TaskBoundaryValue::Structured(structured.clone()).required_artifacts(), + vec![ + disasmer::core::ArtifactId::from("optional.bin"), + disasmer::core::ArtifactId::from("one.bin"), + disasmer::core::ArtifactId::from("two.bin"), + ] + ); + let decoded: CompoundBoundary = serde_json::from_value(structured.value).unwrap(); + assert_eq!(decoded, value); +} diff --git a/crates/disasmer-wasm-runtime/Cargo.toml b/crates/disasmer-wasm-runtime/Cargo.toml new file mode 100644 index 0000000..778c463 --- /dev/null +++ b/crates/disasmer-wasm-runtime/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "disasmer-wasm-runtime" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +disasmer-core = { path = "../disasmer-core" } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +wasmtime.workspace = true diff --git a/crates/disasmer-wasm-runtime/src/lib.rs b/crates/disasmer-wasm-runtime/src/lib.rs new file mode 100644 index 0000000..f15e582 --- /dev/null +++ b/crates/disasmer-wasm-runtime/src/lib.rs @@ -0,0 +1,862 @@ +use disasmer_core::{ + DebugEpoch, DebugParticipant, DebugParticipantKind, DebugRuntimeState, Digest, ProcessId, + TaskInstanceId, WasmHostCommandRequest, WasmHostCommandResult, WasmHostDebugProbeRequest, + WasmHostDebugProbeResult, WasmHostSourceSnapshotRequest, WasmHostSourceSnapshotResult, + WasmHostTaskControlRequest, WasmHostTaskControlResult, WasmHostTaskHandle, + WasmHostTaskJoinRequest, WasmHostTaskJoinResult, WasmHostTaskStartRequest, WasmHostVfsRequest, + WasmHostVfsResult, WasmTaskInvocation, WasmTaskResult, MAX_WASM_TASK_ENVELOPE_BYTES, +}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread; +use std::time::Duration; +use thiserror::Error; +use wasmtime::{ + Caller, Config, DebugEvent, DebugHandler, Engine, Linker, Module, OptLevel, Store, + StoreContextMut, StoreLimits, StoreLimitsBuilder, UpdateDeadline, +}; + +mod task_host_linker; +use task_host_linker::{task_host_linker, task_host_stub_linker}; + +const INACTIVE_EPOCH_DEADLINE_TICKS: u64 = u64::MAX / 2; +const MAX_WASM_MEMORY_BYTES: usize = 256 * 1024 * 1024; +const MAX_WASM_TABLE_ELEMENTS: usize = 100_000; +const MAX_WASM_FUEL_PER_INVOCATION: u64 = 1_000_000_000; + +fn task_store_limits() -> StoreLimits { + StoreLimitsBuilder::new() + .memory_size(MAX_WASM_MEMORY_BYTES) + .table_elements(MAX_WASM_TABLE_ELEMENTS) + .instances(8) + .tables(8) + .memories(8) + .trap_on_grow_failure(true) + .build() +} + +fn debug_control_trace(message: impl std::fmt::Display) { + if std::env::var_os("DISASMER_DEBUG_CONTROL_TRACE").is_some() { + eprintln!("disasmer debug control: {message}"); + } +} + +#[derive(Clone)] +pub struct WasmtimeTaskRuntime { + engine: Engine, +} + +pub trait WasmTaskHost { + fn abort_signal(&self) -> Option> { + None + } + + fn debug_control(&self) -> Option> { + None + } + + fn start_task( + &mut self, + request: WasmHostTaskStartRequest, + ) -> Result; + fn join_task( + &mut self, + request: WasmHostTaskJoinRequest, + ) -> Result; + fn run_command( + &mut self, + request: WasmHostCommandRequest, + ) -> Result; + fn poll_task_control( + &mut self, + request: WasmHostTaskControlRequest, + ) -> Result; + fn debug_probe( + &mut self, + request: WasmHostDebugProbeRequest, + ) -> Result { + request.validate()?; + Ok(WasmHostDebugProbeResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + breakpoint_matched: false, + debug_epoch: None, + }) + } + fn vfs_operation(&mut self, request: WasmHostVfsRequest) -> Result; + fn snapshot_source( + &mut self, + request: WasmHostSourceSnapshotRequest, + ) -> Result; +} + +#[derive(Debug, Default)] +struct WasmDebugControlState { + requested_epoch: Option, + frozen_epoch: Option, + resumed_through_epoch: u64, + quiescent_host_boundary_depth: usize, + frozen_at_host_boundary: bool, +} + +#[derive(Debug, Default)] +pub struct WasmDebugControl { + state: Mutex, + changed: Condvar, +} + +impl WasmDebugControl { + pub fn request_freeze(&self, epoch: u64) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + if state + .requested_epoch + .is_none_or(|requested| epoch >= requested) + { + state.requested_epoch = Some(epoch); + if state.quiescent_host_boundary_depth > 0 && state.resumed_through_epoch < epoch { + state.frozen_epoch = Some(epoch); + state.frozen_at_host_boundary = true; + } + self.changed.notify_all(); + } + } + + pub fn request_resume(&self, epoch: u64) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + state.resumed_through_epoch = state.resumed_through_epoch.max(epoch); + if state.frozen_epoch == Some(epoch) && state.frozen_at_host_boundary { + state.frozen_epoch = None; + state.frozen_at_host_boundary = false; + } + self.changed.notify_all(); + } + + pub fn requested_epoch(&self) -> Option { + self.state + .lock() + .expect("debug control lock poisoned") + .requested_epoch + } + + pub fn frozen_epoch(&self) -> Option { + self.state + .lock() + .expect("debug control lock poisoned") + .frozen_epoch + } + + pub fn resume_requested(&self, epoch: u64) -> bool { + self.state + .lock() + .expect("debug control lock poisoned") + .resumed_through_epoch + >= epoch + } + + pub fn mark_frozen(&self, epoch: u64) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + state.frozen_epoch = Some(epoch); + state.frozen_at_host_boundary = false; + self.changed.notify_all(); + } + + pub fn mark_running(&self, epoch: u64) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + if state.frozen_epoch == Some(epoch) { + state.frozen_epoch = None; + state.frozen_at_host_boundary = false; + } + self.changed.notify_all(); + } + + pub fn wait_until_frozen(&self, epoch: u64, timeout: Duration) -> bool { + let state = self.state.lock().expect("debug control lock poisoned"); + let (state, _) = self + .changed + .wait_timeout_while(state, timeout, |state| state.frozen_epoch != Some(epoch)) + .expect("debug control lock poisoned while waiting for freeze"); + state.frozen_epoch == Some(epoch) + } + + pub fn wait_until_running(&self, epoch: u64, timeout: Duration) -> bool { + let state = self.state.lock().expect("debug control lock poisoned"); + let (state, _) = self + .changed + .wait_timeout_while(state, timeout, |state| state.frozen_epoch == Some(epoch)) + .expect("debug control lock poisoned while waiting for resume"); + state.frozen_epoch != Some(epoch) + } + + fn pause_at_requested_epoch(&self, abort: &AtomicBool) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + let Some(epoch) = state.requested_epoch else { + return; + }; + if state.resumed_through_epoch >= epoch { + return; + } + debug_control_trace(format_args!("Wasm epoch callback freezing epoch {epoch}")); + state.frozen_epoch = Some(epoch); + state.frozen_at_host_boundary = false; + self.changed.notify_all(); + while state.resumed_through_epoch < epoch && !abort.load(Ordering::Acquire) { + state = self + .changed + .wait_timeout(state, Duration::from_millis(50)) + .expect("debug control lock poisoned while Wasm was frozen") + .0; + } + state.frozen_epoch = None; + debug_control_trace(format_args!("Wasm epoch callback resumed epoch {epoch}")); + self.changed.notify_all(); + } + + fn enter_quiescent_host_boundary(&self, abort: Option<&AtomicBool>) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + state.quiescent_host_boundary_depth += 1; + if let Some(epoch) = state.requested_epoch { + if state.resumed_through_epoch < epoch { + state.frozen_epoch = Some(epoch); + state.frozen_at_host_boundary = true; + self.changed.notify_all(); + } + } + while state.frozen_at_host_boundary + && state.frozen_epoch.is_some() + && !abort.is_some_and(|abort| abort.load(Ordering::Acquire)) + { + state = self + .changed + .wait_timeout(state, Duration::from_millis(50)) + .expect("debug control lock poisoned entering host-call safepoint") + .0; + } + } + + fn leave_quiescent_host_boundary(&self, abort: Option<&AtomicBool>) { + let mut state = self.state.lock().expect("debug control lock poisoned"); + while state.frozen_at_host_boundary + && state.frozen_epoch.is_some() + && !abort.is_some_and(|abort| abort.load(Ordering::Acquire)) + { + state = self + .changed + .wait_timeout(state, Duration::from_millis(50)) + .expect("debug control lock poisoned at host-call safepoint") + .0; + } + state.quiescent_host_boundary_depth = state.quiescent_host_boundary_depth.saturating_sub(1); + if state.quiescent_host_boundary_depth == 0 && state.frozen_at_host_boundary { + state.frozen_epoch = None; + state.frozen_at_host_boundary = false; + self.changed.notify_all(); + } + } +} + +struct WasmtimeTaskHostState { + host: Box, + fatal_host_error: Option, + limits: StoreLimits, +} + +struct BasicStoreState { + limits: StoreLimits, +} + +struct EpochControlGuard { + stop: Arc, + watcher: Option>, +} + +impl EpochControlGuard { + fn arm( + engine: &Engine, + store: &mut Store, + abort: Arc, + debug: Option>, + ) -> Self { + debug_control_trace(format_args!( + "arming epoch control (debug={:?})", + debug.as_ref().map(Arc::as_ptr) + )); + let callback_abort = Arc::clone(&abort); + let callback_debug = debug.clone(); + store.epoch_deadline_callback(move |_store| { + if callback_abort.load(Ordering::Acquire) { + return Ok(UpdateDeadline::Interrupt); + } + if let Some(debug) = &callback_debug { + debug.pause_at_requested_epoch(&callback_abort); + if callback_abort.load(Ordering::Acquire) { + return Ok(UpdateDeadline::Interrupt); + } + } + // Epochs are engine-wide. Another task may increment the shared + // engine's epoch to control its own store; keep this store running + // unless this store's own control signal requires action. + Ok(UpdateDeadline::Continue(1)) + }); + store.set_epoch_deadline(1); + let engine = engine.clone(); + let stop = Arc::new(AtomicBool::new(false)); + let watcher_stop = Arc::clone(&stop); + let watcher = thread::spawn(move || { + debug_control_trace("epoch control watcher started"); + let mut triggered_debug_epoch = None; + while !watcher_stop.load(Ordering::Acquire) { + if abort.load(Ordering::Acquire) { + engine.increment_epoch(); + return; + } + if let Some(epoch) = debug.as_ref().and_then(|debug| debug.requested_epoch()) { + if triggered_debug_epoch != Some(epoch) { + triggered_debug_epoch = Some(epoch); + debug_control_trace(format_args!( + "incrementing engine epoch for debug epoch {epoch}" + )); + engine.increment_epoch(); + } + } + thread::sleep(Duration::from_millis(10)); + } + }); + Self { + stop, + watcher: Some(watcher), + } + } +} + +fn defer_epoch_interruption(store: &mut Store) { + // Stores default to an already-expired epoch deadline. ABI setup and stores + // without an abort signal must explicitly opt out of interruption, especially + // after a previous task has incremented the shared engine epoch. + store.set_epoch_deadline(INACTIVE_EPOCH_DEADLINE_TICKS); +} + +impl Drop for EpochControlGuard { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(watcher) = self.watcher.take() { + let _ = watcher.join(); + } + } +} + +fn abort_error( + store: &Store, + abort: Option<&Arc>, + error: wasmtime::Error, +) -> WasmTaskError { + if abort.is_some_and(|signal| signal.load(Ordering::Acquire)) { + return WasmTaskError::HostControl( + "task execution cancelled: coordinator requested process abort".to_owned(), + ); + } + store + .data() + .fatal_host_error + .clone() + .map(WasmTaskError::HostControl) + .unwrap_or_else(|| wasmtime_error(error)) +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum WasmTaskError { + #[error("wasmtime task failed: {0}")] + Runtime(String), + #[error("Wasm task ABI failed: {0}")] + TaskAbi(String), + #[error("{0}")] + HostControl(String), + #[error( + "bundle digest mismatch before wasmtime execution: expected {expected}, actual {actual}" + )] + BundleDigestMismatch { expected: Digest, actual: Digest }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WasmtimeDebugProbe { + pub task: TaskInstanceId, + pub frozen_state: DebugRuntimeState, + pub resumed_state: DebugRuntimeState, + pub result: i32, + pub stack_frames: Vec, + pub local_values: Vec<(String, String)>, + pub wasm_function: Option, + pub wasm_pc: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct WasmtimeFrameSnapshot { + stack_frames: Vec, + local_values: Vec<(String, String)>, + wasm_function: Option, + wasm_pc: Option, +} + +#[derive(Debug)] +struct WasmtimeDebugState { + snapshot: Option, + limits: StoreLimits, +} + +impl Default for WasmtimeDebugState { + fn default() -> Self { + Self { + snapshot: None, + limits: task_store_limits(), + } + } +} + +#[derive(Clone, Debug)] +struct WasmtimeLocalSnapshotHandler { + export: String, +} + +impl DebugHandler for WasmtimeLocalSnapshotHandler { + type Data = WasmtimeDebugState; + + async fn handle(&self, mut store: StoreContextMut<'_, Self::Data>, _event: DebugEvent<'_>) { + if store.data().snapshot.is_some() { + if let Some(mut edit) = store.edit_breakpoints() { + let _ = edit.single_step(false); + } + return; + } + + let mut snapshot = WasmtimeFrameSnapshot { + stack_frames: vec![format!("{}::wasm_export", self.export)], + ..WasmtimeFrameSnapshot::default() + }; + // The debugger only presents the top Wasm frame here. Bounding the iterator is + // also essential for modules with linked host imports, whose exit-frame chain + // may include runtime trampolines that are not user-visible frames. + if let Some(frame) = store + .debug_exit_frames() + .take(1) + .collect::>() + .into_iter() + .next() + { + if let Ok(Some((function, pc))) = frame.wasm_function_index_and_pc(&mut store) { + snapshot.wasm_function = Some(format!("{function:?}")); + snapshot.wasm_pc = Some(pc); + } + + if let Ok(count) = frame.num_locals(&mut store) { + for index in 0..count.min(16) { + let value = match frame.local(&mut store, index) { + Ok(value) => format!("{value:?}"), + Err(err) => format!(""), + }; + snapshot + .local_values + .push((format!("wasm_local_{index}"), value)); + } + } + } + + if let Some(function) = &snapshot.wasm_function { + snapshot.stack_frames = vec![format!("{} / {function}", self.export)]; + } + store.data_mut().snapshot = Some(snapshot); + if let Some(mut edit) = store.edit_breakpoints() { + let _ = edit.single_step(false); + } + } +} + +impl WasmtimeTaskRuntime { + pub fn new() -> Result { + let mut config = Config::new(); + config.wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Enable); + config.epoch_interruption(true); + config.consume_fuel(true); + let engine = Engine::new(&config).map_err(wasmtime_error)?; + Ok(Self { engine }) + } + + fn debug_engine() -> Result { + let mut config = Config::new(); + config.debug_info(true); + config.guest_debug(true); + config.generate_address_map(true); + config.cranelift_opt_level(OptLevel::None); + Engine::new(&config).map_err(wasmtime_error) + } + + pub fn run_i32_export( + &self, + wasm_or_wat: impl AsRef<[u8]>, + export: &str, + arg: i32, + ) -> Result { + self.run_i32_export_bytes(wasm_or_wat.as_ref(), export, arg) + } + + pub fn run_i32_export_verified( + &self, + wasm_or_wat: impl AsRef<[u8]>, + expected_bundle_digest: &Digest, + export: &str, + arg: i32, + ) -> Result { + let wasm_or_wat = Self::verified_module_bytes(wasm_or_wat, expected_bundle_digest)?; + self.run_i32_export_bytes(&wasm_or_wat, export, arg) + } + + pub fn run_i32_export_verified_with_task_host( + &self, + wasm_or_wat: impl AsRef<[u8]>, + expected_bundle_digest: &Digest, + export: &str, + arg: i32, + host: Box, + ) -> Result { + let module_bytes = Self::verified_module_bytes(wasm_or_wat, expected_bundle_digest)?; + let module = Module::new(&self.engine, &module_bytes).map_err(wasmtime_error)?; + let linker = task_host_linker(&self.engine)?; + let abort_signal = host.abort_signal(); + let debug_control = host.debug_control(); + let mut store = Store::new( + &self.engine, + WasmtimeTaskHostState { + host, + fatal_host_error: None, + limits: task_store_limits(), + }, + ); + store.limiter(|state| &mut state.limits); + store + .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .map_err(wasmtime_error)?; + defer_epoch_interruption(&mut store); + let instance = linker + .instantiate(&mut store, &module) + .map_err(wasmtime_error)?; + let function = instance + .get_typed_func::(&mut store, export) + .map_err(wasmtime_error)?; + let _abort_guard = abort_signal.as_ref().map(|signal| { + EpochControlGuard::arm(&self.engine, &mut store, Arc::clone(signal), debug_control) + }); + match function.call(&mut store, arg) { + Ok(result) => Ok(result), + Err(error) => Err(abort_error(&store, abort_signal.as_ref(), error)), + } + } + + pub fn run_task_export_verified( + &self, + wasm_or_wat: impl AsRef<[u8]>, + expected_bundle_digest: &Digest, + export: &str, + invocation: &WasmTaskInvocation, + ) -> Result { + invocation.validate().map_err(WasmTaskError::TaskAbi)?; + let module_bytes = Self::verified_module_bytes(wasm_or_wat, expected_bundle_digest)?; + let encoded = serde_json::to_vec(invocation) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + let module = Module::new(&self.engine, &module_bytes).map_err(wasmtime_error)?; + let linker = task_host_stub_linker(&self.engine)?; + let mut store = Store::new( + &self.engine, + BasicStoreState { + limits: task_store_limits(), + }, + ); + store.limiter(|state| &mut state.limits); + store + .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .map_err(wasmtime_error)?; + defer_epoch_interruption(&mut store); + let instance = linker + .instantiate(&mut store, &module) + .map_err(wasmtime_error)?; + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| WasmTaskError::TaskAbi("guest module exports no memory".to_owned()))?; + let allocate = instance + .get_typed_func::(&mut store, "disasmer_alloc_v1") + .map_err(wasmtime_error)?; + let input_length = u32::try_from(encoded.len()) + .map_err(|_| WasmTaskError::TaskAbi("task invocation is too large".to_owned()))?; + let input_pointer = allocate + .call(&mut store, input_length) + .map_err(wasmtime_error)?; + debug_control_trace("task ABI input allocation completed"); + if input_pointer == 0 && input_length != 0 { + return Err(WasmTaskError::TaskAbi( + "guest refused task invocation allocation".to_owned(), + )); + } + memory + .write(&mut store, input_pointer as usize, &encoded) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + let task = instance + .get_typed_func::<(u32, u32), u64>(&mut store, export) + .map_err(wasmtime_error)?; + let packed = task + .call(&mut store, (input_pointer, input_length)) + .map_err(wasmtime_error)?; + let result_pointer = packed as u32; + let result_length = (packed >> 32) as u32; + if result_length as usize > MAX_WASM_TASK_ENVELOPE_BYTES { + return Err(WasmTaskError::TaskAbi(format!( + "guest task result is {result_length} bytes; maximum is {MAX_WASM_TASK_ENVELOPE_BYTES}" + ))); + } + if result_pointer == 0 && result_length != 0 { + return Err(WasmTaskError::TaskAbi( + "guest returned a null task result pointer".to_owned(), + )); + } + let mut result_bytes = vec![0_u8; result_length as usize]; + memory + .read(&store, result_pointer as usize, &mut result_bytes) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + let result: WasmTaskResult = serde_json::from_slice(&result_bytes) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + result + .validate_for(&invocation.task_instance) + .map_err(WasmTaskError::TaskAbi)?; + Ok(result) + } + + pub fn run_task_export_verified_with_task_host( + &self, + wasm_or_wat: impl AsRef<[u8]>, + expected_bundle_digest: &Digest, + export: &str, + invocation: &WasmTaskInvocation, + host: Box, + ) -> Result { + invocation.validate().map_err(WasmTaskError::TaskAbi)?; + let module_bytes = Self::verified_module_bytes(wasm_or_wat, expected_bundle_digest)?; + let encoded = serde_json::to_vec(invocation) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + let module = Module::new(&self.engine, &module_bytes).map_err(wasmtime_error)?; + let linker = task_host_linker(&self.engine)?; + let abort_signal = host.abort_signal(); + let debug_control = host.debug_control(); + let mut store = Store::new( + &self.engine, + WasmtimeTaskHostState { + host, + fatal_host_error: None, + limits: task_store_limits(), + }, + ); + store.limiter(|state| &mut state.limits); + store + .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .map_err(wasmtime_error)?; + defer_epoch_interruption(&mut store); + let instance = linker + .instantiate(&mut store, &module) + .map_err(wasmtime_error)?; + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| WasmTaskError::TaskAbi("guest module exports no memory".to_owned()))?; + let allocate = instance + .get_typed_func::(&mut store, "disasmer_alloc_v1") + .map_err(wasmtime_error)?; + let input_length = u32::try_from(encoded.len()) + .map_err(|_| WasmTaskError::TaskAbi("task invocation is too large".to_owned()))?; + let input_pointer = allocate + .call(&mut store, input_length) + .map_err(wasmtime_error)?; + if input_pointer == 0 && input_length != 0 { + return Err(WasmTaskError::TaskAbi( + "guest refused task invocation allocation".to_owned(), + )); + } + memory + .write(&mut store, input_pointer as usize, &encoded) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + let task = instance + .get_typed_func::<(u32, u32), u64>(&mut store, export) + .map_err(wasmtime_error)?; + let _abort_guard = abort_signal.as_ref().map(|signal| { + EpochControlGuard::arm(&self.engine, &mut store, Arc::clone(signal), debug_control) + }); + debug_control_trace("calling task ABI export"); + let packed = match task.call(&mut store, (input_pointer, input_length)) { + Ok(packed) => packed, + Err(error) => return Err(abort_error(&store, abort_signal.as_ref(), error)), + }; + decode_guest_task_result(&store, &memory, packed, &invocation.task_instance) + } + + fn run_i32_export_bytes( + &self, + wasm_or_wat: &[u8], + export: &str, + arg: i32, + ) -> Result { + let module = Module::new(&self.engine, wasm_or_wat).map_err(wasmtime_error)?; + let linker = task_host_stub_linker(&self.engine)?; + let mut store = Store::new( + &self.engine, + BasicStoreState { + limits: task_store_limits(), + }, + ); + store.limiter(|state| &mut state.limits); + store + .set_fuel(MAX_WASM_FUEL_PER_INVOCATION) + .map_err(wasmtime_error)?; + defer_epoch_interruption(&mut store); + let instance = linker + .instantiate(&mut store, &module) + .map_err(wasmtime_error)?; + let func = instance + .get_typed_func::(&mut store, export) + .map_err(wasmtime_error)?; + func.call(&mut store, arg).map_err(wasmtime_error) + } + + pub fn freeze_resume_i32_export_probe( + &self, + wasm_or_wat: impl AsRef<[u8]>, + export: &str, + arg: i32, + ) -> Result { + let task = TaskInstanceId::from(export); + let snapshot = Self::debug_i32_export_snapshot(wasm_or_wat.as_ref(), export, arg)?; + let mut epoch = DebugEpoch::pause( + ProcessId::from("wasmtime-debug-probe"), + 1, + vec![DebugParticipant { + task: task.clone(), + name: export.to_owned(), + kind: DebugParticipantKind::WasmTask, + can_freeze: true, + state: DebugRuntimeState::Running, + stack_frames: snapshot.stack_frames.clone(), + local_values: snapshot.local_values.clone(), + task_args: vec![("arg".to_owned(), arg.to_string())], + handles: Vec::new(), + command_status: None, + recent_output: Vec::new(), + }], + ) + .map_err(|err| WasmTaskError::Runtime(err.to_string()))?; + let frozen_state = epoch + .participant_state(&task) + .cloned() + .ok_or_else(|| WasmTaskError::Runtime("Wasm debug participant missing".to_owned()))?; + let inspection = epoch + .inspection(&task) + .map_err(|err| WasmTaskError::Runtime(err.to_string()))?; + epoch.continue_all(); + let resumed_state = epoch + .participant_state(&task) + .cloned() + .ok_or_else(|| WasmTaskError::Runtime("Wasm debug participant missing".to_owned()))?; + let result = self.run_i32_export(wasm_or_wat, export, arg)?; + + Ok(WasmtimeDebugProbe { + task, + frozen_state, + resumed_state, + result, + stack_frames: inspection.stack_frames, + local_values: inspection.local_values, + wasm_function: snapshot.wasm_function, + wasm_pc: snapshot.wasm_pc, + }) + } + + fn debug_i32_export_snapshot( + wasm_or_wat: &[u8], + export: &str, + arg: i32, + ) -> Result { + let engine = Self::debug_engine()?; + let module = Module::new(&engine, wasm_or_wat).map_err(wasmtime_error)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| WasmTaskError::Runtime(format!("create debug runtime: {err:#}")))?; + let state = runtime.block_on(async { + let linker = task_host_stub_linker(&engine)?; + let mut store = Store::new(&engine, WasmtimeDebugState::default()); + store.limiter(|state| &mut state.limits); + store.set_debug_handler(WasmtimeLocalSnapshotHandler { + export: export.to_owned(), + }); + let instance = linker + .instantiate_async(&mut store, &module) + .await + .map_err(wasmtime_error)?; + if let Some(mut edit) = store.edit_breakpoints() { + edit.single_step(true).map_err(wasmtime_error)?; + } + let func = instance + .get_typed_func::(&mut store, export) + .map_err(wasmtime_error)?; + let _ = func + .call_async(&mut store, arg) + .await + .map_err(wasmtime_error)?; + Ok::<_, WasmTaskError>(store.into_data()) + })?; + + state.snapshot.ok_or_else(|| { + WasmTaskError::Runtime( + "Wasmtime guest debug did not produce a frame-local snapshot".to_owned(), + ) + }) + } + + fn verified_module_bytes( + wasm_or_wat: impl AsRef<[u8]>, + expected_bundle_digest: &Digest, + ) -> Result, WasmTaskError> { + let bytes = wasm_or_wat.as_ref(); + let actual = Digest::sha256(bytes); + if &actual != expected_bundle_digest { + return Err(WasmTaskError::BundleDigestMismatch { + expected: expected_bundle_digest.clone(), + actual, + }); + } + Ok(bytes.to_vec()) + } +} + +fn wasmtime_error(err: wasmtime::Error) -> WasmTaskError { + WasmTaskError::Runtime(format!("{err:?}")) +} + +fn decode_guest_task_result( + store: &Store, + memory: &wasmtime::Memory, + packed: u64, + expected_task: &TaskInstanceId, +) -> Result { + let result_pointer = packed as u32; + let result_length = (packed >> 32) as u32; + if result_length as usize > MAX_WASM_TASK_ENVELOPE_BYTES { + return Err(WasmTaskError::TaskAbi(format!( + "guest task result is {result_length} bytes; maximum is {MAX_WASM_TASK_ENVELOPE_BYTES}" + ))); + } + let mut result_bytes = vec![0_u8; result_length as usize]; + memory + .read(store, result_pointer as usize, &mut result_bytes) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + let result: WasmTaskResult = serde_json::from_slice(&result_bytes) + .map_err(|error| WasmTaskError::TaskAbi(error.to_string()))?; + result + .validate_for(expected_task) + .map_err(WasmTaskError::TaskAbi)?; + Ok(result) +} + +#[cfg(test)] +mod tests; diff --git a/crates/disasmer-wasm-runtime/src/task_host_linker.rs b/crates/disasmer-wasm-runtime/src/task_host_linker.rs new file mode 100644 index 0000000..464b557 --- /dev/null +++ b/crates/disasmer-wasm-runtime/src/task_host_linker.rs @@ -0,0 +1,406 @@ +use super::*; + +pub(super) fn task_host_linker( + engine: &Engine, +) -> Result, WasmTaskError> { + let mut linker = Linker::new(engine); + linker + .func_wrap( + "disasmer", + "task_start_v1", + |mut caller: Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + task_host_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + TaskHostOperation::Start, + ) + }, + ) + .map_err(wasmtime_error)?; + linker + .func_wrap( + "disasmer", + "source_snapshot_v1", + |mut caller: Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + task_host_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + TaskHostOperation::SourceSnapshot, + ) + }, + ) + .map_err(wasmtime_error)?; + linker + .func_wrap( + "disasmer", + "vfs_operation_v1", + |mut caller: Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + task_host_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + TaskHostOperation::Vfs, + ) + }, + ) + .map_err(wasmtime_error)?; + linker + .func_wrap( + "disasmer", + "command_run_v1", + |mut caller: Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + task_host_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + TaskHostOperation::Command, + ) + }, + ) + .map_err(wasmtime_error)?; + linker + .func_wrap( + "disasmer", + "task_control_v1", + |mut caller: Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + task_host_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + TaskHostOperation::TaskControl, + ) + }, + ) + .map_err(wasmtime_error)?; + linker + .func_wrap( + "disasmer", + "debug_probe_v1", + |mut caller: Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + task_host_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + TaskHostOperation::DebugProbe, + ) + }, + ) + .map_err(wasmtime_error)?; + linker + .func_wrap( + "disasmer", + "task_join_v1", + |mut caller: Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + task_host_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + TaskHostOperation::Join, + ) + }, + ) + .map_err(wasmtime_error)?; + Ok(linker) +} + +pub(super) fn task_host_stub_linker( + engine: &Engine, +) -> Result, WasmTaskError> { + let mut linker = Linker::new(engine); + for import in [ + "task_start_v1", + "task_join_v1", + "command_run_v1", + "task_control_v1", + "source_snapshot_v1", + "vfs_operation_v1", + ] { + linker + .func_wrap( + "disasmer", + import, + |_input_pointer: u32, + _input_length: u32, + _output_pointer: u32, + _output_capacity: u32| + -> i32 { -1 }, + ) + .map_err(wasmtime_error)?; + } + linker + .func_wrap( + "disasmer", + "debug_probe_v1", + |mut caller: Caller<'_, T>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32| + -> i32 { + debug_probe_stub_call( + &mut caller, + input_pointer, + input_length, + output_pointer, + output_capacity, + ) + }, + ) + .map_err(wasmtime_error)?; + Ok(linker) +} + +fn debug_probe_stub_call( + caller: &mut Caller<'_, T>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, +) -> i32 { + let Some(memory) = caller + .get_export("memory") + .and_then(|export| export.into_memory()) + else { + return -2; + }; + let mut input = vec![0_u8; input_length as usize]; + if memory + .read(&*caller, input_pointer as usize, &mut input) + .is_err() + { + return -3; + } + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) => request.validate().map(|()| WasmHostDebugProbeResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + breakpoint_matched: false, + debug_epoch: None, + }), + Err(error) => Err(error.to_string()), + }; + let Ok(encoded) = serde_json::to_vec(&response) else { + return -4; + }; + if encoded.len() > output_capacity as usize { + return -(encoded.len() as i32); + } + if memory + .write(caller, output_pointer as usize, &encoded) + .is_err() + { + return -5; + } + encoded.len() as i32 +} + +enum TaskHostOperation { + Start, + Join, + Command, + TaskControl, + DebugProbe, + SourceSnapshot, + Vfs, +} + +fn task_host_call( + caller: &mut Caller<'_, WasmtimeTaskHostState>, + input_pointer: u32, + input_length: u32, + output_pointer: u32, + output_capacity: u32, + operation: TaskHostOperation, +) -> i32 { + if input_length as usize > MAX_WASM_TASK_ENVELOPE_BYTES + || output_capacity as usize > MAX_WASM_TASK_ENVELOPE_BYTES + { + return -1; + } + let Some(memory) = caller + .get_export("memory") + .and_then(|export| export.into_memory()) + else { + return -2; + }; + let mut input = vec![0_u8; input_length as usize]; + if memory + .read(&*caller, input_pointer as usize, &mut input) + .is_err() + { + return -3; + } + let encoded = match operation { + TaskHostOperation::Start => { + let debug = caller.data().host.debug_control(); + let abort = caller.data().host.abort_signal(); + if let Some(debug) = &debug { + debug.enter_quiescent_host_boundary(abort.as_deref()); + } + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) => request + .validate() + .and_then(|()| caller.data_mut().host.start_task(request)), + Err(error) => Err(error.to_string()), + }; + if let Some(debug) = &debug { + debug.leave_quiescent_host_boundary(abort.as_deref()); + } + serde_json::to_vec(&response) + } + TaskHostOperation::Join => { + let debug = caller.data().host.debug_control(); + let abort = caller.data().host.abort_signal(); + if let Some(debug) = &debug { + debug.enter_quiescent_host_boundary(abort.as_deref()); + } + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) if request.abi_version == disasmer_core::WASM_TASK_ABI_VERSION => { + caller.data_mut().host.join_task(request) + } + Ok(request) => Err(format!( + "unsupported Wasm task ABI version {}", + request.abi_version + )), + Err(error) => Err(error.to_string()), + }; + if let Some(debug) = &debug { + debug.leave_quiescent_host_boundary(abort.as_deref()); + } + serde_json::to_vec(&response) + } + TaskHostOperation::Command => { + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) => request + .validate() + .and_then(|()| caller.data_mut().host.run_command(request)), + Err(error) => Err(error.to_string()), + }; + if let Err(error) = &response { + if error.contains("task execution cancelled:") { + caller.data_mut().fatal_host_error = Some(error.clone()); + } + } + serde_json::to_vec(&response) + } + TaskHostOperation::TaskControl => { + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) => request + .validate() + .and_then(|()| caller.data_mut().host.poll_task_control(request)), + Err(error) => Err(error.to_string()), + }; + serde_json::to_vec(&response) + } + TaskHostOperation::DebugProbe => { + // A matched probe requests a Debug Epoch from inside this host call. Keep + // the guest at this quiescent boundary until every participant has frozen + // and the debugger resumes the epoch; otherwise a short-lived task can run + // past the probe (or trap) before its control watcher acknowledges all-stop. + let debug = caller.data().host.debug_control(); + let abort = caller.data().host.abort_signal(); + if let Some(debug) = &debug { + debug.enter_quiescent_host_boundary(abort.as_deref()); + } + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) => request + .validate() + .and_then(|()| caller.data_mut().host.debug_probe(request)), + Err(error) => Err(error.to_string()), + }; + if let Some(debug) = &debug { + debug.leave_quiescent_host_boundary(abort.as_deref()); + } + serde_json::to_vec(&response) + } + TaskHostOperation::Vfs => { + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) => request + .validate() + .and_then(|()| caller.data_mut().host.vfs_operation(request)), + Err(error) => Err(error.to_string()), + }; + serde_json::to_vec(&response) + } + TaskHostOperation::SourceSnapshot => { + let response: Result = + match serde_json::from_slice::(&input) { + Ok(request) => request + .validate() + .and_then(|()| caller.data_mut().host.snapshot_source(request)), + Err(error) => Err(error.to_string()), + }; + serde_json::to_vec(&response) + } + }; + let Ok(encoded) = encoded else { + return -4; + }; + if encoded.is_empty() || encoded.len() > output_capacity as usize { + return -5; + } + if memory + .write(&mut *caller, output_pointer as usize, &encoded) + .is_err() + { + return -6; + } + i32::try_from(encoded.len()).unwrap_or(-7) +} diff --git a/crates/disasmer-wasm-runtime/src/tests.rs b/crates/disasmer-wasm-runtime/src/tests.rs new file mode 100644 index 0000000..44f5305 --- /dev/null +++ b/crates/disasmer-wasm-runtime/src/tests.rs @@ -0,0 +1,229 @@ +use super::*; + +struct AbortSignalHost { + abort: Arc, + debug: Option>, +} + +impl WasmTaskHost for AbortSignalHost { + fn abort_signal(&self) -> Option> { + Some(Arc::clone(&self.abort)) + } + + fn debug_control(&self) -> Option> { + self.debug.clone() + } + + fn start_task( + &mut self, + _request: WasmHostTaskStartRequest, + ) -> Result { + Err("not used".to_owned()) + } + + fn join_task( + &mut self, + _request: WasmHostTaskJoinRequest, + ) -> Result { + Err("not used".to_owned()) + } + + fn run_command( + &mut self, + _request: WasmHostCommandRequest, + ) -> Result { + Err("not used".to_owned()) + } + + fn poll_task_control( + &mut self, + request: WasmHostTaskControlRequest, + ) -> Result { + request.validate()?; + Ok(WasmHostTaskControlResult { + abi_version: disasmer_core::WASM_TASK_ABI_VERSION, + cancellation_requested: false, + }) + } + + fn vfs_operation(&mut self, _request: WasmHostVfsRequest) -> Result { + Err("not used".to_owned()) + } + + fn snapshot_source( + &mut self, + _request: WasmHostSourceSnapshotRequest, + ) -> Result { + Err("not used".to_owned()) + } +} + +#[test] +fn epoch_interruption_aborts_cpu_bound_wasm_without_a_host_call() { + let abort = Arc::new(AtomicBool::new(false)); + let trigger = Arc::clone(&abort); + let trigger_thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + trigger.store(true, Ordering::Release); + }); + let wasm = r#" + (module + (func (export "spin") (param i32) (result i32) + (loop $forever + br $forever) + i32.const 0)) + "#; + let started = std::time::Instant::now(); + let error = WasmtimeTaskRuntime::new() + .unwrap() + .run_i32_export_verified_with_task_host( + wasm, + &Digest::sha256(wasm), + "spin", + 1, + Box::new(AbortSignalHost { abort, debug: None }), + ) + .unwrap_err(); + trigger_thread.join().unwrap(); + + assert!(matches!(error, WasmTaskError::HostControl(_))); + assert!(error.to_string().contains("process abort")); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn aborting_one_store_does_not_poison_the_shared_engine() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let abort = Arc::new(AtomicBool::new(false)); + let trigger = Arc::clone(&abort); + let trigger_thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + trigger.store(true, Ordering::Release); + }); + let spinning_wasm = r#" + (module + (func (export "spin") (param i32) (result i32) + (loop $forever + br $forever) + i32.const 0)) + "#; + let error = runtime + .run_i32_export_verified_with_task_host( + spinning_wasm, + &Digest::sha256(spinning_wasm), + "spin", + 0, + Box::new(AbortSignalHost { abort, debug: None }), + ) + .unwrap_err(); + trigger_thread.join().unwrap(); + assert!(matches!(error, WasmTaskError::HostControl(_))); + + let succeeding_wasm = r#" + (module + (func (export "increment") (param i32) (result i32) + local.get 0 + i32.const 1 + i32.add)) + "#; + assert_eq!( + runtime + .run_i32_export_verified( + succeeding_wasm, + &Digest::sha256(succeeding_wasm), + "increment", + 41, + ) + .unwrap(), + 42 + ); +} + +#[test] +fn debug_control_freezes_and_resumes_executing_wasm_before_abort() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let abort = Arc::new(AtomicBool::new(false)); + let debug = Arc::new(WasmDebugControl::default()); + let execution_abort = Arc::clone(&abort); + let execution_debug = Arc::clone(&debug); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let execution = thread::spawn(move || { + let wasm = r#" + (module + (func (export "spin") (param i32) (result i32) + (loop $forever + br $forever) + i32.const 0)) + "#; + let result = runtime.run_i32_export_verified_with_task_host( + wasm, + &Digest::sha256(wasm), + "spin", + 0, + Box::new(AbortSignalHost { + abort: execution_abort, + debug: Some(execution_debug), + }), + ); + let _ = finished_tx.send(()); + result + }); + + debug.request_freeze(7); + assert!(debug.wait_until_frozen(7, Duration::from_secs(2))); + assert!(finished_rx.try_recv().is_err()); + debug.request_resume(7); + assert!(debug.wait_until_running(7, Duration::from_secs(2))); + abort.store(true, Ordering::Release); + + let error = execution.join().unwrap().unwrap_err(); + assert!(matches!(error, WasmTaskError::HostControl(_))); + assert!(error.to_string().contains("process abort")); +} + +#[test] +fn pending_freeze_blocks_a_quiescent_host_call_before_it_starts() { + let debug = Arc::new(WasmDebugControl::default()); + debug.request_freeze(9); + let boundary_debug = Arc::clone(&debug); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let boundary = thread::spawn(move || { + boundary_debug.enter_quiescent_host_boundary(None); + entered_tx.send(()).unwrap(); + boundary_debug.leave_quiescent_host_boundary(None); + }); + + assert!(debug.wait_until_frozen(9, Duration::from_secs(1))); + assert!(entered_rx.try_recv().is_err()); + debug.request_resume(9); + assert!(debug.wait_until_running(9, Duration::from_secs(1))); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + boundary.join().unwrap(); +} + +#[test] +fn wasm_linear_memory_growth_is_bounded_per_store() { + let wasm = r#" + (module + (memory 1) + (func (export "grow") (param i32) (result i32) + i32.const 5000 + memory.grow + drop + local.get 0)) + "#; + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let error = runtime.run_i32_export(wasm, "grow", 7).unwrap_err(); + + assert!(error.to_string().contains("memory") || error.to_string().contains("grow")); + assert_eq!( + runtime + .run_i32_export( + "(module (func (export \"healthy\") (param i32) (result i32) local.get 0))", + "healthy", + 11, + ) + .unwrap(), + 11 + ); +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..90eb40c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,46 @@ +# Architecture + +Disasmer presents a distributed workflow as one virtual process. The workflow +program and its tasks are WebAssembly modules; the coordinator, attached nodes, +CLI, and debugger are separate host processes. + +## Components + +- The SDK and macros turn Rust workflow functions into versioned Wasm exports. +- The CLI builds and validates a bounded bundle, creates a virtual process, and + launches its selected entrypoint as the coordinator main. +- The coordinator owns identity, authorization, scheduling, process state, and + debug epochs. It executes only the capless coordinator-main Wasm: that main + may spawn/join tasks, wait, log bounded state, and participate in debugging, + but cannot run commands, read source or host files, use sockets, or obtain + secrets. The coordinator does not retain artifact bytes. +- Attached nodes verify assignments and execute Wasm with Wasmtime. Host + operations are available only through imports backed by granted capabilities. +- The DAP adapter maps a virtual process and its tasks to ordinary debugger + processes and threads without pretending that source-level stepping exists. + +The standalone Core coordinator is the public self-hosted service. The Hosted +coordinator wraps the same Core service with private identity-provider, +community-policy, operator, and deployment code. Public/private is a source +distribution boundary, not an authority boundary. + +## State + +Durable Postgres state contains tenants, users, projects, credentials, +permissions, source-provider configuration, and service policy records. Active +virtual processes, assignments, debug epochs, transient VFS views, and artifact +locations are intentionally ephemeral for the MVP. Restarting the coordinator +therefore preserves identity and project configuration but ends active work. + +## Execution and placement + +Wasm is the execution substrate, not a capability. The coordinator main uses a +small fixed capless import set. A node task's `TaskSpec` declares the additional +host operations and environment it needs, and a node is eligible only when its +reported capabilities satisfy those requirements. For example, a Wasm task may +request command execution in a declared Linux environment; a user-attached node +with rootless Podman can provide that operation, while hosted community +infrastructure denies arbitrary native commands and hosted containers. + +The build-system workflow is the MVP wedge. General distributed application +runtime features are deliberately outside the initial release. diff --git a/docs/artifacts.md b/docs/artifacts.md new file mode 100644 index 0000000..825da0a --- /dev/null +++ b/docs/artifacts.md @@ -0,0 +1,37 @@ +# Artifacts + +Artifacts are named, content-addressed results retained by the node that +produced or received their bytes. The coordinator stores authorization and +location metadata, not an implicit copy of every output. + +## Publish, flush, and sync + +- Publishing from a Wasm task transfers bytes to its retaining node and records + the digest and size. +- `flush()` publishes metadata so downstream tasks can refer to the artifact. It + does not imply coordinator storage or durable replication. +- `sync()` is an explicit movement decision to another node or user-provided + storage integration. + +Large bytes should travel node-to-node or node-to-receiver. Task arguments carry +artifact handles rather than embedding output data. + +## Download and export + +`disasmer artifact download ` asks the coordinator for an authorized, +scoped stream and retrieves the real bytes from a retaining node. Authorization +is bound to tenant, project, process, artifact, actor, retention state, expiry, +and quota. Missing, expired, revoked, oversized, or unreachable artifacts fail +explicitly before success is reported. + +Final export is also explicit. A project can attach a receiver node or invoke a +user-provided storage tool from an allowed environment. The MVP intentionally +does not advertise a managed artifact-store product. + +Node retention is best effort unless project code synchronizes the artifact to +a durable destination. Coordinator restart does not manufacture or preserve +artifact bytes. Each node enforces configured byte, object-count, and age +bounds. Artifacts required by active tasks, live downloads, or a retained +restart boundary are pinned; garbage collection evicts eligible older objects +and reports unavailable bytes honestly. Coordinator metadata and download +session histories are independently count- and time-bounded. diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 0000000..c72bbce --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,30 @@ +# Debugging + +Disasmer exposes the distributed workflow as one virtual process with virtual +threads for tasks. The VS Code extension launches or attaches through the DAP +adapter and reads the same live process registry used by the CLI. + +## Supported controls + +- Launch and attach to a real coordinator process. +- Inspect processes, tasks, logs, artifacts, nodes, scopes, and variables. +- Set function/source breakpoints supported by the compiled Wasm mapping. +- Continue and pause through participant-acknowledged debug epochs. +- Rebuild and restart a terminal task from its captured clean VFS entry boundary + under a fresh task-instance ID when the edited task ABI and boundary remain + compatible. Incompatible edits require a whole-process restart without + altering the running process. +- Cooperatively cancel or explicitly abort the whole virtual process. + +Source-level stepping is not in the MVP. Step requests fail explicitly instead +of returning a synthetic success. All-stop succeeds only after every active +participant acknowledges the requested epoch; a missing participant is reported +as a freeze failure. + +When F5 finds an existing process in the same Coordinator Project, the extension +offers attach, restart, cancel, or abort. If the process came from another local +workspace, it does not silently attach with the wrong source mapping. Processes +remain visible independent of whether they currently contain active tasks. + +The DAP demo backend is an isolated adapter test fixture. Product launches use +the coordinator/node runtime path and do not fall back to the demo model. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..fee0a4b --- /dev/null +++ b/docs/security.md @@ -0,0 +1,54 @@ +# Security model + +Disasmer runs untrusted project code as WebAssembly on attached nodes. Wasm is +the isolation substrate; access to host operations is the capability boundary. +Running a Wasm module alone does not grant commands, containers, source access, +artifact access, networking, or coordinator authority. + +## Authority lanes + +- Client requests use an authenticated session scoped to one user, tenant, and + project. +- Nodes and non-interactive agents use Ed25519 request signatures, bounded + timestamps, unique nonces, and explicitly scoped enrollment grants. +- Identity requests create and poll browser-login transactions. The hosted + callback exchanges the authorization code with Authentik and obtains identity + from its authenticated user-info endpoint; the CLI cannot supply trusted + identity claims. +- Hosted Operator requests use a separate private credential and replay-safe + proof. They are not Client protocol variants authorized by convention. + +The coordinator uses its own clock for expiry, quota windows, and policy +decisions. Quota meters are keyed by tenant, project, resource kind, and window. +Client-provided actor or scope strings are not authority. Replay windows, +login/enrollment transactions, task and debug histories, downloads, and +artifact metadata all have explicit expiry or count bounds scoped to their +authority or project/process object. + +## Transport and secrets + +Remote control traffic must use HTTPS and follows normal certificate +validation. Plain JSON-lines TCP and HTTP are restricted to loopback and require +an explicit local-development mode on the coordinator. The hosted deployment +terminates TLS at its reverse proxy and forwards only to a loopback service. + +Node keys are generated from operating-system randomness. Local credential +directories and files use Unix modes `0700` and `0600`, reject symbolic-link +paths, and are written atomically without overwriting an existing credential. +Environment-provided keys remain the operator's storage responsibility. + +## Trust boundary + +Rootless Podman is a useful additional containment and environment mechanism on +Linux nodes, but it does not turn a denied host operation into an allowed one. +Native commands require an explicit environment, cwd, bounded environment map, +wall-clock timeout, output mount, and network policy. Linux container commands +run with resource and process-count limits, no-new-privileges, and dropped +capabilities; Wasmtime stores independently enforce memory/table/instance and +fuel bounds. A limit failure is a bounded task failure and does not terminate +the node daemon. +Node operators decide which projects they trust and which host capabilities a +node advertises. The community hosted service does not provide arbitrary hosted +native execution; users attach their own nodes for real work. + +Report vulnerabilities according to [SECURITY.md](../SECURITY.md). diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..9675d68 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,49 @@ +# Self-hosting + +The public workspace contains the standalone Core coordinator, node runtime, +CLI, SDK, DAP adapter, and VS Code extension. Self-hosting does not require the +private hosted website or Authentik integration. + +Build the workspace, then start a coordinator with a protected bootstrap +session: + +```bash +cargo build --workspace +DISASMER_SELF_HOSTED_SESSION_SECRET="$SELF_HOSTED_SESSION_SECRET" \ +DISASMER_SELF_HOSTED_TENANT=my-team \ +DISASMER_SELF_HOSTED_PROJECT=my-project \ +DISASMER_SELF_HOSTED_USER=me \ + target/debug/disasmer-coordinator --listen 127.0.0.1:7999 +``` + +The coordinator's native JSON-lines transport is deliberately loopback-only and +the server refuses a non-loopback listener. For administration from another +machine, use an authenticated SSH tunnel and connect to its local endpoint: + +```bash +ssh -N -L 7999:127.0.0.1:7999 coordinator.example +``` + +Do not expose plaintext port 7999 to a network. The hosted service is the +reference HTTPS deployment shape: TLS reverse proxy to a loopback HTTP +application endpoint. + +Connect a CLI without putting the secret in its process arguments: + +```bash +printf '%s\n' "$SELF_HOSTED_SESSION_SECRET" | \ + disasmer auth connect-self-hosted \ + --coordinator 127.0.0.1:7999 \ + --tenant my-team --project-id my-project --user me \ + --session-secret-stdin +``` + +Create a scoped node enrollment grant, then attach nodes that advertise only the +host capabilities you intend to provide. Linux command/container environments +require rootless Podman on those nodes. Wasmtime execution itself is always part +of the runtime and is not an optional capability. + +Configure `DATABASE_URL` for durable identity and project state. Active +processes and tasks are ephemeral in the MVP and end when the coordinator +restarts. Back up Postgres and any user-selected artifact destination; node-local +artifact retention is best effort. diff --git a/docs/task-abi.md b/docs/task-abi.md new file mode 100644 index 0000000..0bd23b7 --- /dev/null +++ b/docs/task-abi.md @@ -0,0 +1,45 @@ +# Task ABI + +The MVP task ABI is a small, versioned contract between a compiled workflow +module and the coordinator-main or node Wasmtime host. It is intentionally +narrower than WASI and should grow only when the flagship workflow needs +another host operation. + +## Assignment + +Each assignment carries a `TaskSpec` containing the virtual process and task +identity, bundle module, declared Wasm export and ABI version, serialized +arguments, required capabilities and artifacts, selected environment, and VFS +epoch. The node rejects a missing or incompatible spec rather than guessing an +entrypoint. + +SDK task macros generate exports that decode versioned boundary values, invoke +the Rust function, and encode the result. Values crossing a task boundary are +limited to serializable data and Disasmer handles; host-only objects fail at +compile time. + +## Host imports + +The current guest imports cover task spawn/join, command execution, +cancellation observation, source snapshots, VFS file operations, artifact +publication, and debug probes. Every import validates memory ranges and payload +sizes. The coordinator main receives only spawn/join, waiting, bounded state, +and debug controls; node hosts check the assignment's declared capabilities +before performing source, command, network, or artifact operations. + +`disasmer.command_run_v1` runs a command through the selected environment +backend. On Linux, a task assigned to an environment requiring rootless Podman +uses the normal Podman assignment path. The hosted community policy does not +grant this on managed infrastructure; a user node may grant it for its own +machine. + +## Lifecycle + +Cooperative cancellation is visible to guest code at a host boundary. Forced +abort arms Wasmtime epoch interruption and also terminates active environment +work. Debug freeze and resume synchronize at acknowledged host or Wasmtime epoch +boundaries. These controls do not claim instruction-accurate source stepping. +Each task assignment captures a restart-compatibility hash and clean VFS entry +boundary. DAP task restart rebuilds the bundle and accepts edited code only when +the task ABI, declared capabilities, environment, arguments, handles, and VFS +boundary remain compatible. diff --git a/examples/launch-build-demo/Cargo.toml b/examples/launch-build-demo/Cargo.toml new file mode 100644 index 0000000..8879be0 --- /dev/null +++ b/examples/launch-build-demo/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "launch-build-demo" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +path = "src/build.rs" +crate-type = ["rlib", "cdylib"] + +[dependencies] +disasmer = { package = "disasmer-sdk", path = "../../crates/disasmer-sdk" } +futures-executor.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] diff --git a/examples/launch-build-demo/README.md b/examples/launch-build-demo/README.md new file mode 100644 index 0000000..499e611 --- /dev/null +++ b/examples/launch-build-demo/README.md @@ -0,0 +1,11 @@ +# Launch Build Demo + +This demo is the MVP build workflow expressed as Rust source code. It uses `env!("linux")`, recognizes `env!("windows")` when a Windows development node is attached, spawns debugger-visible virtual tasks, and returns artifact/source handles instead of moving large bytes through task arguments. + +The Linux environment is defined by `envs/linux/Containerfile`. The Windows environment is a user-attached development contract and does not claim secure managed Windows sandboxing. + +Inspect the bundle metadata: + +```bash +disasmer bundle inspect --project examples/launch-build-demo +``` diff --git a/examples/launch-build-demo/envs/linux/Containerfile b/examples/launch-build-demo/envs/linux/Containerfile new file mode 100644 index 0000000..ba87ba3 --- /dev/null +++ b/examples/launch-build-demo/envs/linux/Containerfile @@ -0,0 +1,3 @@ +FROM docker.io/library/alpine:3.20 +RUN apk add --no-cache build-base tar zstd +WORKDIR /workspace diff --git a/examples/launch-build-demo/envs/windows/Dockerfile b/examples/launch-build-demo/envs/windows/Dockerfile new file mode 100644 index 0000000..e3cf468 --- /dev/null +++ b/examples/launch-build-demo/envs/windows/Dockerfile @@ -0,0 +1,2 @@ +# User-attached Windows development execution contract for the MVP. +# This is not a managed untrusted Windows sandbox. diff --git a/examples/launch-build-demo/fixture/hello-disasmer.c b/examples/launch-build-demo/fixture/hello-disasmer.c new file mode 100644 index 0000000..6a1c9c4 --- /dev/null +++ b/examples/launch-build-demo/fixture/hello-disasmer.c @@ -0,0 +1,6 @@ +#include + +int main(void) { + puts("hello from a real Disasmer build"); + return 0; +} diff --git a/examples/launch-build-demo/src/bin/sdk-product-runtime.rs b/examples/launch-build-demo/src/bin/sdk-product-runtime.rs new file mode 100644 index 0000000..f0b39be --- /dev/null +++ b/examples/launch-build-demo/src/bin/sdk-product-runtime.rs @@ -0,0 +1,33 @@ +use std::error::Error; + +use futures_executor::block_on; + +fn main() -> Result<(), Box> { + let handle = block_on( + disasmer::spawn::task_with_arg(41_i32, |_| -> i32 { + panic!("product-mode spawn invoked its local Rust closure") + }) + .task_id("task_add_one") + .name("SDK product Wasmtime task") + .env(disasmer::env!("linux")) + .start(), + )?; + let task_spec = handle + .task_spec() + .cloned() + .ok_or("DISASMER_SDK_COORDINATOR did not enable product runtime")?; + let virtual_thread_id = handle.virtual_thread_id(); + let result = block_on(handle.join())?; + + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "kind": "disasmer-sdk-product-runtime", + "virtual_thread_id": virtual_thread_id, + "task_spec": task_spec, + "remote_result": result, + "local_function_invoked_by_spawn": false, + }))? + ); + Ok(()) +} diff --git a/examples/launch-build-demo/src/build.rs b/examples/launch-build-demo/src/build.rs new file mode 100644 index 0000000..e18315b --- /dev/null +++ b/examples/launch-build-demo/src/build.rs @@ -0,0 +1,327 @@ +use disasmer::{Artifact, EnvRef, SourceSnapshot}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, disasmer::TaskArg)] +pub struct BuildReport { + pub linux_thread: u64, + pub package_thread: u64, + pub linux_artifact: Artifact, + pub package_artifact: Artifact, + pub source: SourceSnapshot, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, disasmer::TaskArg)] +pub struct PackageInput { + pub release_name: String, + pub source: SourceSnapshot, + pub executable: Option, + pub inputs: Vec, +} + +pub fn linux_env() -> EnvRef { + disasmer::env!("linux") +} + +pub fn windows_env() -> EnvRef { + disasmer::env!("windows") +} + +#[disasmer::task(capabilities = "source_filesystem")] +pub async fn prepare_source() -> SourceSnapshot { + #[cfg(target_arch = "wasm32")] + { + return disasmer::source::snapshot() + .await + .expect("the source task should snapshot the node checkout it was placed on"); + } + #[cfg(not(target_arch = "wasm32"))] + SourceSnapshot { + digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_owned(), + } +} + +#[disasmer::task(capabilities = "command")] +pub async fn compile_linux(source: SourceSnapshot) -> Artifact { + let _ = &source; + #[cfg(target_arch = "wasm32")] + { + let executable = disasmer::fs::output_path("hello-disasmer") + .expect("the executable output path should be task-local"); + let output = disasmer::command::Command::new("cc") + .args([ + "-Os", + "-static", + "-s", + "fixture/hello-disasmer.c", + "-o", + executable.as_str(), + ]) + .current_dir("/workspace") + .env("SOURCE_DATE_EPOCH", "0") + .timeout(std::time::Duration::from_secs(180)) + .network_disabled() + .output() + .await + .expect("the declared Linux environment should provide a real C compiler"); + assert_eq!(output.status_code, Some(0)); + return disasmer::fs::flush(&executable) + .await + .expect("the command-created executable should flush to node artifact storage"); + } + #[cfg(not(target_arch = "wasm32"))] + Artifact { + id: "hello-disasmer-native-test".to_owned(), + digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_owned(), + size_bytes: 0, + } +} + +#[disasmer::task] +#[unsafe(no_mangle)] +pub extern "C" fn task_add_one(input: i32) -> i32 { + input + 1 +} + +#[disasmer::task] +#[unsafe(no_mangle)] +pub extern "C" fn task_trap(_input: i32) -> i32 { + #[cfg(target_arch = "wasm32")] + core::arch::wasm32::unreachable(); + #[cfg(not(target_arch = "wasm32"))] + panic!("intentional task trap") +} + +#[disasmer::task(capabilities = "command")] +pub async fn package_release(input: PackageInput) -> Artifact { + #[cfg(target_arch = "wasm32")] + { + assert_eq!(input.release_name, "release.tar"); + let executable = input + .executable + .as_ref() + .expect("the package input should contain the compiler artifact"); + assert!(input.inputs.iter().any(|artifact| artifact == executable)); + let executable = disasmer::fs::materialize(&executable, "package/hello-disasmer") + .await + .expect("the retaining node should materialize the compiler artifact locally"); + let release = disasmer::fs::output_path("release.tar") + .expect("the release output path should be task-local"); + let output = disasmer::command::Command::new("tar") + .args([ + "--sort=name", + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "--mode=0755", + "-cf", + release.as_str(), + "-C", + "/disasmer/output/package", + "hello-disasmer", + ]) + .current_dir("/workspace") + .env("SOURCE_DATE_EPOCH", "0") + .timeout(std::time::Duration::from_secs(180)) + .network_disabled() + .output() + .await + .expect("the declared Linux environment should package the materialized executable"); + assert_eq!(output.status_code, Some(0)); + assert_eq!( + executable.as_str(), + "/disasmer/output/package/hello-disasmer" + ); + return disasmer::fs::flush(&release) + .await + .expect("the command-created release archive should flush as the final artifact"); + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = input; + Artifact { + id: "release-native-test".to_owned(), + digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_owned(), + size_bytes: 0, + } + } +} + +#[disasmer::task(capabilities = "command")] +pub async fn abort_probe() -> i32 { + #[cfg(target_arch = "wasm32")] + { + let output = disasmer::command::Command::new("sh") + .args(["-c", "sleep 30"]) + .output() + .await + .expect("abort probe command should either finish or be stopped by process abort"); + return output.status_code.unwrap_or(-1); + } + #[cfg(not(target_arch = "wasm32"))] + 0 +} + +#[disasmer::task] +pub fn cooperative_cancellation_probe() -> i32 { + #[cfg(target_arch = "wasm32")] + loop { + if disasmer::process::cancellation_requested() + .expect("task control should remain available while the Wasm task is running") + { + // Cancellation is a request observed and handled by task code. Returning + // normally is intentionally distinct from the runtime's forced abort path. + return 17; + } + } + #[cfg(not(target_arch = "wasm32"))] + 17 +} + +#[disasmer::task] +pub fn debug_child_probe() -> i32 { + #[cfg(target_arch = "wasm32")] + loop { + if disasmer::process::cancellation_requested() + .expect("child debug participant should retain task control") + { + return 23; + } + } + #[cfg(not(target_arch = "wasm32"))] + 23 +} + +#[disasmer::task] +pub async fn debug_parent_probe() -> i32 { + #[cfg(target_arch = "wasm32")] + { + let child = disasmer::spawn::task(debug_child_probe) + .name("debug child participant") + .start() + .await + .expect("parent debug participant should spawn its child"); + return child + .join() + .await + .expect("parent debug participant should join its child"); + } + #[cfg(not(target_arch = "wasm32"))] + 23 +} + +#[disasmer::main] +pub async fn build_main() -> BuildReport { + run_build_workflow().await +} + +#[disasmer::main(name = "fail")] +pub async fn fail_main() -> Result { + #[cfg(target_arch = "wasm32")] + { + let child = disasmer::spawn::task_with_arg(0_i32, |value| task_trap(value)) + .task_id("task_trap") + .name("intentional failing child") + .start() + .await + .map_err(|error| format!("failing entrypoint could not launch its child: {error}"))?; + return child + .join() + .await + .map_err(|error| format!("intentional child failure: {error}")); + } + #[cfg(not(target_arch = "wasm32"))] + Err("intentional child failure".to_owned()) +} + +#[disasmer::main(name = "restart")] +pub async fn restart_main() -> i32 { + #[cfg(target_arch = "wasm32")] + { + return disasmer::spawn::task_with_arg(41_i32, |value| task_add_one(value)) + .task_id("task_add_one") + .name("edited restart probe") + .start() + .await + .expect("restart probe should launch its child") + .join() + .await + .expect("restart probe child should complete"); + } + #[cfg(not(target_arch = "wasm32"))] + task_add_one(41) +} + +pub async fn run_build_workflow() -> BuildReport { + let source = disasmer::spawn::async_task(prepare_source) + .name("prepare source snapshot") + .start() + .await + .unwrap() + .join() + .await + .unwrap(); + let linux = disasmer::spawn::async_task_with_arg(source.clone(), compile_linux) + .name("compile linux") + .env(linux_env()) + .start() + .await + .unwrap(); + let linux_thread = linux.virtual_thread_id(); + let linux_artifact = linux.join().await.unwrap(); + let package = disasmer::spawn::async_task_with_arg( + PackageInput { + release_name: "release.tar".to_owned(), + source: source.clone(), + executable: Some(linux_artifact.clone()), + inputs: vec![linux_artifact.clone()], + }, + package_release, + ) + .name("package artifacts") + .env(linux_env()) + .start() + .await + .unwrap(); + + let package_thread = package.virtual_thread_id(); + let package_artifact = package.join().await.unwrap(); + + BuildReport { + linux_thread, + package_thread, + linux_artifact, + package_artifact, + source, + } +} + +#[cfg(test)] +mod tests { + use futures_executor::block_on; + + use super::*; + + #[test] + fn flagship_workflow_is_rust_source_with_spawned_virtual_tasks() { + let main_report = block_on(build_main()); + assert_eq!(main_report.linux_artifact.id, "hello-disasmer-native-test"); + assert_eq!(task_add_one(41), 42); + assert_eq!(block_on(restart_main()), 42); + assert_eq!(linux_env().name, "linux"); + assert_eq!(windows_env().name, "windows"); + + let report = block_on(run_build_workflow()); + + assert_ne!(report.linux_thread, report.package_thread); + assert_eq!(report.linux_artifact.id, "hello-disasmer-native-test"); + assert_eq!(report.package_artifact.id, "release-native-test"); + assert_eq!( + report.source.digest, + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..12f6b4d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1782498288, + "narHash": "sha256-8/X3yyTXiE82b38n32ItbOqfWOVBl+gKa8fILyZfR4Q=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3cac626ec5e3703e835f227687e88aa9e2f25701", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c70ccbc --- /dev/null +++ b/flake.nix @@ -0,0 +1,35 @@ +{ + description = "Disasmer development and verification environment"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + devShells = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + cargo + clippy + git + jq + nodejs_22 + podman + rustc + rustfmt + zip + ]; + shellHook = '' + echo "Disasmer shell: $(rustc --version), $(node --version), $(podman --version)" + ''; + }; + }); + }; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..0c664f6 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.91.1" +components = ["clippy", "rustfmt"] +targets = ["wasm32-unknown-unknown"] +profile = "minimal" diff --git a/scripts/acceptance-cli-first.sh b/scripts/acceptance-cli-first.sh new file mode 100755 index 0000000..f49e007 --- /dev/null +++ b/scripts/acceptance-cli-first.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo" + +if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:-}" == "1" ]]; then + echo "CLI-first acceptance does not run final public-release e2e; unset DISASMER_PUBLIC_RELEASE_DRYRUN_E2E" >&2 + exit 1 +fi + +if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then + echo "CLI-first acceptance does not run final public-release evidence; unset DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL" >&2 + exit 1 +fi + +node scripts/acceptance-report.js cli-first +node scripts/cli-first-contract-smoke.js +node scripts/acceptance-report-smoke.js +node scripts/acceptance-doc-contract-smoke.js +node scripts/acceptance-environment-contract-smoke.js +node scripts/acceptance-evidence-contract-smoke.js +node scripts/code-size-guard.js +node scripts/public-private-boundary-smoke.js +node scripts/release-blocker-smoke.js +node scripts/resource-metering-contract-smoke.js +node scripts/hostile-input-contract-smoke.js +node scripts/tenant-isolation-contract-smoke.js +node scripts/public-story-contract-smoke.js + +if [[ "${DISASMER_CLI_FIRST_PREPARE_PUBLIC_RELEASE:-1}" == "1" ]]; then + node scripts/prepare-public-release-dryrun.js +fi + +node scripts/public-release-dryrun-contract-smoke.js +node scripts/public-browser-login-contract-smoke.js +node scripts/self-hosted-coordinator-smoke.js +if [[ "${DISASMER_CLI_HAPPY_PATH_LIVE:-}" == "1" ]]; then + node scripts/cli-happy-path-live-smoke.js +fi +node scripts/public-local-demo-matrix-smoke.js +scripts/release-source-scan.sh + +cargo fmt --all --check +cargo test --workspace +cargo build --workspace --bins + +node scripts/docs-smoke.js +node scripts/cli-output-mode-smoke.js +node scripts/cli-login-smoke.js +node scripts/cli-error-exit-smoke.js +node scripts/cli-browser-login-flow-smoke.js +node scripts/cli-install-smoke.js +node scripts/user-session-token-boundary-smoke.js +node scripts/sdk-spawn-runtime-smoke.js +node scripts/node-lifecycle-contract-smoke.js +node scripts/wasmtime-node-smoke.js +node scripts/wasmtime-assignment-smoke.js +if command -v podman >/dev/null 2>&1; then + node scripts/podman-backend-smoke.js +elif command -v nix >/dev/null 2>&1; then + nix shell nixpkgs#podman --command node scripts/podman-backend-smoke.js +else + node scripts/podman-backend-smoke.js +fi +node scripts/vscode-extension-smoke.js +node scripts/vscode-f5-smoke.js +node scripts/node-attach-smoke.js +node scripts/cli-local-run-smoke.js +node scripts/artifact-download-smoke.js +node scripts/artifact-export-smoke.js +node scripts/operator-panel-smoke.js +node scripts/source-preparation-smoke.js +node scripts/scheduler-placement-smoke.js +node scripts/windows-best-effort-smoke.js +node scripts/windows-validation-contract-smoke.js +node scripts/quic-smoke.js +node scripts/dap-smoke.js +node scripts/flagship-demo-smoke.js + +if [[ "${DISASMER_CLI_FIRST_INCLUDE_PRIVATE:-0}" == "1" ]]; then + scripts/acceptance-private.sh +fi diff --git a/scripts/acceptance-doc-contract-smoke.js b/scripts/acceptance-doc-contract-smoke.js new file mode 100755 index 0000000..ae9857e --- /dev/null +++ b/scripts/acceptance-doc-contract-smoke.js @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const { + assertPreFinalOrFinalLedger, +} = require("./phase3-ledger"); + +const repo = path.resolve(__dirname, ".."); +if ( + fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json")) && + !fs.existsSync(path.join(repo, "acceptance_criteria.md")) +) { + console.log( + "Acceptance doc contract smoke skipped: root acceptance markdown is filtered from this public tree" + ); + process.exit(0); +} + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function criterionLines(source) { + return source + .split(/\r?\n/) + .filter((line) => /^- \[[ x]\] \*\*/.test(line)); +} + +function assertEveryCriterionHasStatus(source, name) { + const lines = criterionLines(source); + assert(lines.length > 0, `${name} must contain acceptance criteria`); + for (const line of lines) { + assert.match( + line, + /^- \[[ x]\] \*\*(Passed|Partial|Open|Postponed)(?: \([^)]+\))?:\*\*/, + `${name} criterion lacks an explicit status prefix: ${line}` + ); + } +} + +function assertNoOpenCriteria(source, name) { + const open = criterionLines(source).filter((line) => /\*\*Open(?::| \()/.test(line)); + assert.deepStrictEqual(open, [], `${name} still has Open criteria`); +} + +function assertPhase3CriterionHeadingsHaveStatus(source, name) { + const headings = source + .split(/\r?\n/) + .filter((line) => /^## /.test(line) && /P3-[A-Z]+-\d{3}:/.test(line)); + assert(headings.length > 0, `${name} must contain Phase 3 criteria`); + for (const line of headings) { + assert.match( + line, + /^## \*\*(Passed|Partial|Open|Postponed):\*\* P3-[A-Z]+-\d{3}:/, + `${name} criterion heading lacks an explicit status prefix: ${line}` + ); + } + assertPreFinalOrFinalLedger(source); +} + +const phase2 = read("acceptance_criteria_phase2.md"); +const base = read("acceptance_criteria.md"); +const cliFirst = read("cli_acceptance_criteria.md"); +const website = read("website_mvp_inventory.md"); +const phase3 = read("phase_3_acceptance_criteria.md"); +const docsSmoke = read("scripts/docs-smoke.js"); +const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); +const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); + +assert.match( + phase2, + /phase 2 superset of `acceptance_criteria\.md`/, + "phase 2 criteria must declare that they are a superset of the base criteria" +); +assert.match( + phase2, + /Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts/, + "phase 2 criteria must keep base acceptance criteria required unless stricter phase 2 criteria conflict" +); +assert.match( + phase2, + /- \[x\] \*\*Passed:\*\* Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts\./, + "phase 2 cross-document requirement must be marked passed only when this guard is wired" +); + +assert.match( + phase3, + /Canonical Phase 3 gate:[\s\S]*active Phase 3 acceptance document/, + "phase 3 criteria must declare itself as the canonical current gate" +); +assert.match( + phase3, + /Important reading note:[\s\S]*not necessarily always mean adding code/, + "phase 3 criteria must keep the no-automatic-code-work disclaimer at the top" +); +assert.match( + phase3, + /Every criterion heading below is prefixed with \*\*Passed\*\*, \*\*Partial\*\*, or \*\*Open\*\*/, + "phase 3 criteria must explain the explicit status-prefix convention" +); +assertPhase3CriterionHeadingsHaveStatus(phase3, "phase_3_acceptance_criteria.md"); + +for (const [source, name] of [ + [base, "acceptance_criteria.md"], + [phase2, "acceptance_criteria_phase2.md"], + [cliFirst, "cli_acceptance_criteria.md"], +]) { + assertEveryCriterionHasStatus(source, name); + assertNoOpenCriteria(source, name); + assert.match( + source, + /Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP/, + `${name} must keep billing/paid-plan placeholders outside MVP implementation scope` + ); + assert.match( + source, + /not the canonical Phase 3 release gate; current Phase 3 status and release verification live in `phase_3_acceptance_criteria\.md`/, + `${name} must point current Phase 3 status to phase_3_acceptance_criteria.md` + ); +} + +assertEveryCriterionHasStatus(website, "website_mvp_inventory.md"); +assert.match( + website, + /private hosted website addendum to `acceptance_criteria\.md`, `acceptance_criteria_phase2\.md`, and `cli_acceptance_criteria\.md`/, + "website acceptance criteria must declare itself as the private hosted website addendum" +); +assert.match( + website, + /barebones functional HTML with no CSS/, + "website acceptance criteria must keep the MVP website barebones with no CSS" +); +assert.match( + website, + /Billing is not part of the MVP[\s\S]*Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP website routes/, + "website acceptance criteria must keep billing and paid-plan placeholders outside MVP website scope" +); +assert.match( + website, + /- \[ \] \*\*Open:\*\* The private hosted website acceptance gate exists and exercises the barebones website through `disasmer\.michelpaulissen\.com`/, + "website acceptance criteria must leave the private website deployment gate explicit and open" +); +assert.match( + website, + /not the canonical Phase 3 release gate; current Phase 3 status and release verification live in `phase_3_acceptance_criteria\.md`/, + "website acceptance criteria must point current Phase 3 status to phase_3_acceptance_criteria.md" +); + +for (const file of [ + "MVP.md", + "acceptance_criteria.md", + "acceptance_criteria_phase2.md", + "cli_acceptance_criteria.md", + "website_mvp_inventory.md", + "phase_3_acceptance_criteria.md", +]) { + assert( + docsSmoke.includes(`"${file}"`), + `docs smoke must include ${file} as user-facing acceptance context` + ); +} + +assert( + releaseBlockerSmoke.includes('const phase2 = read("acceptance_criteria_phase2.md")'), + "release-blocker smoke must read phase 2 acceptance criteria" +); +assert( + releaseBlockerSmoke.includes('const base = read("acceptance_criteria.md")'), + "release-blocker smoke must read base acceptance criteria" +); + +for (const [source, name] of [ + [base, "acceptance_criteria.md"], + [phase2, "acceptance_criteria_phase2.md"], +]) { + for (const [label, pattern] of [ + ["MVP selected locals", /selected (?:top-level )?locals|selected real source locals/], + ["MVP task args", /task arguments|task args/], + ["MVP handle inspection", /Artifact.*SourceSnapshot.*Blob|Disasmer handles/], + ["MVP stdout stderr", /stdout\/stderr/], + ["MVP unavailable locals", /cannot be inspected|unavailable-local/], + ["MVP required DAP surface", /initialize[\s\S]*launch.*attach[\s\S]*setBreakpoints[\s\S]*configurationDone[\s\S]*threads[\s\S]*stackTrace[\s\S]*scopes[\s\S]*variables[\s\S]*continue[\s\S]*pause/], + ]) { + assert.match(source, pattern, `${name} must include MVP debugging criterion: ${label}`); + } +} + +for (const [scriptName, script] of [ + ["public acceptance", publicAcceptance], + ["private acceptance", privateAcceptance], + ["CLI-first acceptance", cliFirstAcceptance], + ["public split", publicSplit], +]) { + assert( + script.includes("node scripts/acceptance-doc-contract-smoke.js"), + `${scriptName} must run acceptance-doc-contract-smoke.js` + ); +} + +console.log("Acceptance doc contract smoke passed"); diff --git a/scripts/acceptance-environment-contract-smoke.js b/scripts/acceptance-environment-contract-smoke.js new file mode 100755 index 0000000..b0de87b --- /dev/null +++ b/scripts/acceptance-environment-contract-smoke.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function maybeRead(relativePath) { + const absolute = path.join(repo, relativePath); + if (!fs.existsSync(absolute)) return null; + return fs.readFileSync(absolute, "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing acceptance environment evidence: ${name}`); +} + +function expectIncludes(source, name, text) { + assert(source.includes(text), `missing acceptance environment evidence: ${name}`); +} + +const publicAcceptance = read("scripts/acceptance-public.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const acceptanceReport = read("scripts/acceptance-report.js"); +const acceptanceReportSmoke = read("scripts/acceptance-report-smoke.js"); +const readme = read("README.md"); +const windowsWorkflow = read(".forgejo/workflows/windows-validation.yml"); +const publicDryrunServiceSmoke = maybeRead("private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"); +const publicDryrunDeployPrep = maybeRead("private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"); +const publicDryrunSystemd = maybeRead("private/hosted-policy/deploy/disasmer-public-release-dryrun.service"); +const publicDryrunRunbook = maybeRead("private/hosted-policy/deploy/README.md"); +const hostedClientCompatSmoke = maybeRead("private/hosted-policy/scripts/hosted-client-compat-smoke.js"); +const hostedService = maybeRead("private/hosted-policy/src/bin/disasmer-hosted-service.rs"); +const hostedStartup = maybeRead( + "private/hosted-policy/src/bin/disasmer-hosted-service/startup.rs" +); +const hostedOperatorAuth = maybeRead( + "private/hosted-policy/src/bin/disasmer-hosted-service/operator_auth.rs" +); +const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js"); +const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js"); + +for (const [name, script] of [ + ["public acceptance", publicAcceptance], + ["private acceptance", privateAcceptance], +]) { + expect(script, `${name} writes acceptance environment report first`, /node scripts\/acceptance-report\.js (public|private)[\s\S]*node scripts\/acceptance-report-smoke\.js/); + expectIncludes( + script, + `${name} runs acceptance environment contract`, + "node scripts/acceptance-environment-contract-smoke.js" + ); +} + +for (const [name, script] of [ + ["public acceptance", publicAcceptance], + ["public split", publicSplit], +]) { + for (const smoke of [ + "scripts/wasmtime-assignment-smoke.js", + "scripts/node-attach-smoke.js", + "scripts/cli-local-run-smoke.js", + "scripts/vscode-f5-smoke.js", + "scripts/dap-smoke.js", + "scripts/artifact-download-smoke.js", + "scripts/artifact-export-smoke.js", + "scripts/public-local-demo-matrix-smoke.js", + ]) { + assert(script.includes(`node ${smoke}`), `${name} must run ${smoke}`); + } +} + +expectIncludes(publicAcceptance, "public gate runs rootless Podman backend smoke", "node scripts/podman-backend-smoke.js"); +expectIncludes(publicAcceptance, "public gate runs Wasmtime node smoke", "node scripts/wasmtime-node-smoke.js"); +expectIncludes(privateAcceptance, "private gate runs hosted deployment smoke", "node private/hosted-policy/scripts/hosted-deployment-smoke.js"); +expectIncludes(privateAcceptance, "private gate prepares public dry-run deployment bundle", "node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"); +expectIncludes(privateAcceptance, "private gate runs hosted Client compatibility smoke", "node private/hosted-policy/scripts/hosted-client-compat-smoke.js"); +expectIncludes(privateAcceptance, "private gate runs standalone Core coordinator smoke", "node scripts/self-hosted-coordinator-smoke.js"); +expectIncludes(privateAcceptance, "private gate runs Postgres durable smoke", "node private/hosted-policy/scripts/postgres-durable-smoke.js"); +expectIncludes(privateAcceptance, "private gate can run public release dry-run service smoke", "node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"); +expectIncludes(privateAcceptance, "public release dry-run service smoke is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR"); +expectIncludes(privateAcceptance, "private gate runs hosted policy cargo tests", "cargo test --manifest-path private/hosted-policy/Cargo.toml"); +expectIncludes(publicAcceptance, "public gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js"); +expectIncludes(publicAcceptance, "public gate can run public release dry-run e2e", "node scripts/public-release-dryrun-e2e.js"); +expectIncludes(publicAcceptance, "public release dry-run e2e is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"); +expectIncludes(privateAcceptance, "private gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js"); +expectIncludes(publicAcceptance, "public final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"); +expectIncludes(privateAcceptance, "private final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"); + +expect(publicSplit, "public split excludes private modules", /--exclude='\.\/private'/); +expect(publicSplit, "public split excludes experiments", /--exclude='\.\/experiments'/); +expect(publicSplit, "public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/); +expect(publicSplit, "public split builds copied workspace binaries", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/); +expectIncludes( + publicSplit, + "public split runs acceptance environment contract from copied tree", + '(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js)' +); + +for (const [name, pattern] of [ + ["commit SHA fallback", /process\.env\.DISASMER_ACCEPTANCE_COMMIT \|\| commandOutput\("git", \["rev-parse", "HEAD"\]\)/], + ["tree status", /tree_status: \(commandOutput\("git", \["status", "--short"\]\) \|\| ""\)[\s\S]*\.filter\(Boolean\)/], + ["OS report", /platform: os\.platform\(\)[\s\S]*kernel: os\.release\(\)/], + ["Rust report", /rustc: commandOutput\("rustc", \["--version"\]\)/], + ["Node report", /version: process\.version/], + ["Podman report", /function podmanReport\(\)/], + ["Postgres report", /postgres: \{[\s\S]*commandOutput\("postgres", \["--version"\]\) \|\|[\s\S]*commandOutput\("psql", \["--version"\]\)/], + ["browser harness report", /browser_harness:/], + ["VS Code harness report", /vscode_harness:/], + ["Windows validation report", /windows_validation: process\.env\.DISASMER_WINDOWS_VALIDATION \|\| "not-run"/], +]) { + expect(acceptanceReport, name, pattern); +} + +for (const [name, pattern] of [ + ["acceptance report validates Podman incomplete state", /assertPodmanReport/], + ["acceptance report validates Windows not-run", /assertReport\(runReport\(mode\), mode, "not-run"\)/], + ["acceptance report validates Windows runner mode", /DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner"/], +]) { + expect(acceptanceReportSmoke, name, pattern); +} + +expect(readme, "README documents public acceptance script", /scripts\/acceptance-public\.sh/); +expect(readme, "README documents private acceptance script", /scripts\/acceptance-private\.sh/); +expect(readme, "README documents rootless Podman incomplete handling", /Podman backend behavior is marked `incomplete`/); +expect(readme, "README documents Postgres discovery in environment report", /Podman\/Postgres discovery/); +expect(readme, "README documents manual Windows validation", /manual `Windows validation`\s+workflow/); + +expect(windowsWorkflow, "Windows workflow is manual", /workflow_dispatch/); +expect(windowsWorkflow, "Windows workflow uses intermittent Windows runner", /runs-on:\s*windows/); +expect(windowsWorkflow, "Windows workflow writes acceptance report", /node scripts\/acceptance-report\.js windows/); + +if (publicDryrunServiceSmoke && publicDryrunDeployPrep && publicDryrunSystemd && publicDryrunRunbook) { + for (const [name, pattern] of [ + ["service smoke requires external service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR is required/], + ["service smoke requires browser test driver", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/], + ["service smoke loads release manifest", /public-release-manifest\.json/], + ["service smoke rejects stale release manifest", /manifest\.source_commit[\s\S]*expectedCommit/], + ["service smoke records source commit", /source_commit: release\.sourceCommit/], + ["service smoke records release name", /release_name: release\.releaseName/], + ["service smoke connects through public domain", /addr\.host[\s\S]*serviceHost/], + ["service smoke verifies DNS state", /\["not-published", "published"\]\.includes\(dnsPublicationState\)/], + ["service smoke begins a server-owned OIDC login", /type: "begin_oidc_browser_login"/], + ["service smoke polls with opaque credentials", /type: "poll_oidc_browser_login"[\s\S]*transaction_id[\s\S]*polling_secret/], + ["service smoke rejects missing hosted callback completion", /timed out waiting for the hosted OIDC callback/], + ["service smoke reads server-created project", /type: "list_projects"/], + ["service smoke enrolls signed node", /type: "create_node_enrollment_grant"[\s\S]*exchange_node_enrollment_grant/], + ["service smoke starts session-authorized process", /type: "start_process"/], + ["service smoke reports signed node capabilities", /signedNodeRequest[\s\S]*report_node_capabilities/], + ["service smoke reads authorized debug state", /type: "debug_attach"/], + ["service smoke aborts its probe process", /type: "abort_process"/], + ["service smoke records private hosted coordinator", /coordinator_implementation:[\s\S]*"hosted-policy-coordinator"/], + ["service smoke writes evidence report", /public-release-dryrun-service\.json/], + ]) { + expect(publicDryrunServiceSmoke, name, pattern); + } + + for (const [name, source, pattern] of [ + ["deployment prep builds hosted service release", publicDryrunDeployPrep, /cargo"[\s\S]*"build"[\s\S]*"--release"[\s\S]*"private\/hosted-policy\/Cargo\.toml"[\s\S]*"disasmer-hosted-service"/], + ["deployment prep stages systemd unit", publicDryrunDeployPrep, /disasmer-public-release-dryrun\.service/], + ["deployment prep writes manifest", publicDryrunDeployPrep, /deployment-manifest\.json/], + ["deployment prep records private hosted coordinator", publicDryrunDeployPrep, /coordinator_implementation:[\s\S]*"hosted-policy-coordinator"/], + ["deployment prep records service address", publicDryrunDeployPrep, /service_addr:[\s\S]*`\$\{serviceHost\}:\$\{servicePort\}`/], + ["deployment prep records DNS state", publicDryrunDeployPrep, /dns_publication_state: dnsPublicationState/], + ["deployment prep records service smoke command", publicDryrunDeployPrep, /public-release-dryrun-service-smoke\.js/], + ["systemd keeps hosted HTTP upstream on loopback", publicDryrunSystemd, /--listen 127\.0\.0\.1:9080/], + ["systemd loads root-owned operator credentials", publicDryrunSystemd, /EnvironmentFile=\/etc\/disasmer-public-release-dryrun\/operator\.env/], + ["systemd avoids privileged port capability", publicDryrunSystemd, /NoNewPrivileges=true/], + ["systemd uses dedicated user", publicDryrunSystemd, /User=disasmer[\s\S]*Group=disasmer/], + ["runbook says externally reachable", publicDryrunRunbook, /externally reachable host/], + ["runbook documents DNS pending fallback", publicDryrunRunbook, /Until the `disasmer\.michelpaulissen\.com` DNS record is deployed/], + ["runbook gives hosts entry", publicDryrunRunbook, / disasmer\.michelpaulissen\.com/], + ["runbook separates authority vocabulary", publicDryrunRunbook, /Client and signed Node protocols[\s\S]*Identity boundary[\s\S]*Operator boundary/], + ["runbook documents operator credential", publicDryrunRunbook, /DISASMER_HOSTED_OPERATOR_TOKEN/], + ]) { + expect(source, name, pattern); + } +} + +if (hostedClientCompatSmoke && hostedService && hostedStartup && hostedOperatorAuth) { + const hostedServiceSecurity = `${hostedService}\n${hostedStartup}\n${hostedOperatorAuth}`; + for (const [name, pattern] of [ + ["hosted service embeds core coordinator runtime", /core_coordinator: CoordinatorService/], + ["hosted service parses the unified identity, operator, and client protocol", /decode_incoming_request/], + ["hosted service delegates client requests", /handle_client_request/], + ["hosted service routes decoded client requests to Core", /IncomingRequest::Client\(request\)/], + ["hosted service requires replay-resistant operator envelope proofs", /hosted_operator_request[\s\S]*HostedOperatorAuth[\s\S]*replay_nonces[\s\S]*verify_request[\s\S]*hosted_operator_request_proof_from_token_digest/], + ["hosted service issues the authenticated Core CLI session", /core_coordinator[\s\S]*\.issue_cli_session/], + ["hosted service creates the authenticated default project through Core", /AuthenticatedCoordinatorRequest::CreateProject/], + ]) { + expect(hostedServiceSecurity, name, pattern); + } + + for (const [name, pattern] of [ + ["compat smoke starts hosted service", /disasmer-hosted-service/], + ["compat smoke creates hosted project", /type: "create_project"/], + ["compat smoke creates session-authorized enrollment grant", /type: "create_node_enrollment_grant"/], + ["compat smoke runs public CLI attach", /"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/], + ["compat smoke verifies public enrollment exchange", /node_enrollment_exchanged/], + ["compat smoke runs public CLI browser login", /"login"[\s\S]*"--browser"[\s\S]*DISASMER_BROWSER_OPEN_COMMAND/], + ["compat smoke derives hosted scope", /const session = login\.coordinator_response\.session/], + ["compat smoke rejects forged identity", /forged_unsigned_project/], + ["compat smoke rejects an actually expired hosted session", /DISASMER_HOSTED_CLI_SESSION_TTL_SECONDS[\s\S]*expired; run disasmer login --browser again/], + ["compat smoke denies cross-tenant task-event reads", /crossTenantTaskEventsDenied[\s\S]*type: "list_task_events"[\s\S]*vp-victim/], + ["compat smoke writes evidence report", /hosted-client-compat\.json/], + ]) { + expect(hostedClientCompatSmoke, name, pattern); + } +} + +for (const [name, pattern] of [ + ["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/], + ["e2e runner requires the honestly reset pre-final ledger", /assertPreFinalLedger\(readPhase3Ledger\(repo\)\)/], + ["e2e runner requires public domain service address", /serviceAddr[\s\S]*serviceHost/], + ["e2e runner downloads release assets", /downloadReleaseAssets/], + ["e2e runner verifies release checksums", /verifyChecksums/], + ["e2e runner clones public repo", /git"[\s\S]*"clone"[\s\S]*publicRepositoryUrl/], + ["e2e runner uses default hosted coordinator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/], + ["e2e runner uses server-owned browser login", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND[\s\S]*DISASMER_BROWSER_OPEN_COMMAND/], + ["e2e runner attaches user node", /node"[\s\S]*"attach"/], + ["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/], + ["e2e runner launches released product through CLI", /"run"[\s\S]*"build"[\s\S]*runReport\.status[\s\S]*main_launched/], + ["standalone Core proof launches a real Wasm TaskSpec", /type: "launch_task"[\s\S]*task_spec:[\s\S]*kind: "coordinator_node_wasm"[\s\S]*bundle_digest: manifest\.bundle_digest/], + ["e2e runner verifies public assignment polling", /worker_assignment_poll_protocol/], + ["e2e runner validates standalone Core coordinator", /validateStandaloneCoreCoordinator/], + ["e2e runner records standalone Core coordinator", /core_coordinator_implementation/], + ["e2e runner verifies task events", /list_task_events/], + ["e2e runner creates artifact download link", /create_artifact_download_link/], + ["e2e runner verifies VS Code debugger", /vscode-f5-smoke\.js/], + ["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/], +]) { + expect(publicDryrunE2e, name, pattern); +} + +for (const [name, pattern] of [ + ["final verifier requires public release manifest", /public-release-manifest\.json/], + ["final verifier requires Forgejo release evidence", /public-release-dryrun-forgejo-release\.json/], + ["final verifier requires deployment manifest", /deployment-manifest\.json/], + ["final verifier requires service smoke evidence", /public-release-dryrun-service\.json/], + ["final verifier requires all 191 Phase 3 criteria passed", /assertFinalLedger\(readPhase3Ledger\(repo\)\)/], + ["final verifier requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/], + ["final verifier requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/], + ["final verifier requires hosted Client compatibility evidence", /hosted-client-compat\.json/], + ["final verifier requires current Client compatibility source", /compat\.source_commit[\s\S]*manifest\.source_commit/], + ["final verifier requires current Client compatibility release", /compat\.release_name[\s\S]*manifest\.release_name/], + ["final verifier requires Core coordinator compatibility evidence", /core-coordinator-compat\.json/], + ["final verifier requires current public coordinator source", /coreCoordinator\.source_commit[\s\S]*manifest\.source_commit/], + ["final verifier requires current public coordinator release", /coreCoordinator\.release_name[\s\S]*manifest\.release_name/], + ["final verifier requires public e2e evidence", /public-release-dryrun-e2e\.json/], + ["final verifier records both coordinator validations", /coordinator_validation/], + ["final verifier writes final evidence", /public-release-dryrun-final\.json/], +]) { + expect(finalDryrunEvidence, name, pattern); +} + +console.log("Acceptance environment contract smoke passed"); diff --git a/scripts/acceptance-evidence-contract-smoke.js b/scripts/acceptance-evidence-contract-smoke.js new file mode 100755 index 0000000..c9008c5 --- /dev/null +++ b/scripts/acceptance-evidence-contract-smoke.js @@ -0,0 +1,1080 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function maybeRead(relativePath) { + const fullPath = path.join(repo, relativePath); + return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, "utf8") : null; +} + +function expect(source, name, pattern) { + if (process.env.DISASMER_ACCEPTANCE_CONTRACT_TRACE) { + process.stderr.write(`checking acceptance evidence: ${name}\n`); + } + assert.match(source, pattern, `missing acceptance evidence guard: ${name}`); +} + +function expectGate(script, gateName) { + assert( + script.includes("node scripts/acceptance-evidence-contract-smoke.js"), + `${gateName} must run acceptance-evidence-contract-smoke.js` + ); +} + +const phase2 = read("acceptance_criteria_phase2.md"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); +const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const codeSizeGuard = read("scripts/code-size-guard.js"); +const phase3Ledger = read("scripts/phase3-ledger.js"); +const coreAuth = read("crates/disasmer-core/src/auth.rs"); +const coreArtifact = read("crates/disasmer-core/src/artifact.rs"); +const coreBundle = read("crates/disasmer-core/src/bundle.rs"); +const coreCheckpoint = read("crates/disasmer-core/src/checkpoint.rs"); +const coreExecution = read("crates/disasmer-core/src/execution.rs"); +const coreProject = read("crates/disasmer-core/src/project.rs"); +const coreWire = read("crates/disasmer-core/src/wire.rs"); +const sdkLib = read("crates/disasmer-sdk/src/lib.rs"); +const sdkTaskArgs = read("crates/disasmer-sdk/src/task_args.rs"); +const coordinatorDurable = read("crates/disasmer-coordinator/src/durable.rs"); +const coordinatorPostgres = read("crates/disasmer-coordinator/src/postgres_store.rs"); +const coordinatorRuntimeDurable = read("crates/disasmer-coordinator/src/service/durable_runtime.rs"); +const coordinatorSessions = read("crates/disasmer-coordinator/src/sessions.rs"); +const coordinatorAgents = read("crates/disasmer-coordinator/src/agents.rs"); +const coordinatorProtocol = `${read("crates/disasmer-coordinator/src/service/protocol.rs")}\n${read("crates/disasmer-coordinator/src/service/protocol/responses.rs")}\n${read("crates/disasmer-coordinator/src/service/wire_protocol.rs")}`; +const coordinatorService = `${read("crates/disasmer-coordinator/src/service.rs")}\n${read("crates/disasmer-coordinator/src/service/routing.rs")}`; +const coordinatorAdmin = read("crates/disasmer-coordinator/src/service/admin.rs"); +const coordinatorArtifacts = read("crates/disasmer-coordinator/src/service/artifacts.rs"); +const coordinatorQuota = read("crates/disasmer-coordinator/src/service/quota.rs"); +const coordinatorAuthenticated = read("crates/disasmer-coordinator/src/service/authenticated.rs"); +const coordinatorAuthorization = read("crates/disasmer-coordinator/src/service/authorization.rs"); +const coordinatorTcp = read("crates/disasmer-coordinator/src/service/tcp.rs"); +const coordinatorMain = read("crates/disasmer-coordinator/src/main.rs"); +const coordinatorNodes = read("crates/disasmer-coordinator/src/service/nodes.rs"); +const coordinatorSignedNodes = read("crates/disasmer-coordinator/src/service/signed_nodes.rs"); +const coordinatorSignedNodeSurface = `${coordinatorService}\n${coordinatorSignedNodes}`; +const coordinatorProcesses = `${read("crates/disasmer-coordinator/src/service/process_launch.rs")}\n${read("crates/disasmer-coordinator/src/service/processes.rs")}`; +const coordinatorMainRuntime = read("crates/disasmer-coordinator/src/service/main_runtime.rs"); +const coordinatorDebug = `${read("crates/disasmer-coordinator/src/service/debug_requests.rs")}\n${read("crates/disasmer-coordinator/src/service/debug.rs")}\n${read("crates/disasmer-coordinator/src/service/debug/validation.rs")}`; +const coordinatorLogs = read("crates/disasmer-coordinator/src/service/logs.rs"); +const coordinatorServiceTests = read("crates/disasmer-coordinator/src/service/tests.rs"); +const cliConfig = read("crates/disasmer-cli/src/config.rs"); +const cliClient = read("crates/disasmer-cli/src/client.rs"); +const cliAuth = read("crates/disasmer-cli/src/auth.rs"); +const cliLogout = read("crates/disasmer-cli/src/logout.rs"); +const cliAdmin = read("crates/disasmer-cli/src/admin.rs"); +const cliNode = read("crates/disasmer-cli/src/node.rs"); +const cliProject = read("crates/disasmer-cli/src/project.rs"); +const cliRun = read("crates/disasmer-cli/src/run.rs"); +const cliRunLocalServices = read("crates/disasmer-cli/src/run/local_services.rs"); +const cliBundle = read("crates/disasmer-cli/src/bundle.rs"); +const cliTests = read("crates/disasmer-cli/src/tests.rs"); +const nodeLib = read("crates/disasmer-node/src/lib.rs"); +const nodeDaemon = read("crates/disasmer-node/src/daemon.rs"); +const nodeAssignmentRunner = `${read("crates/disasmer-node/src/assignment_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/control_watcher.rs")}\n${read("crates/disasmer-node/src/assignment_runner/process_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/validation.rs")}`; +const sharedWasmtimeRuntime = `${read("crates/disasmer-wasm-runtime/src/lib.rs")}\n${read("crates/disasmer-wasm-runtime/src/task_host_linker.rs")}`; +const nodeIdentity = read("crates/disasmer-node/src/node_identity.rs"); +const nodeCoordinatorSession = read("crates/disasmer-node/src/coordinator_session.rs"); +const nodeTaskArtifacts = read("crates/disasmer-node/src/task_artifacts.rs"); +const nodeTaskReports = read("crates/disasmer-node/src/task_reports.rs"); +const nodeDebugAgent = read("crates/disasmer-node/src/debug_agent.rs"); +const hostedPolicyRoot = maybeRead("private/hosted-policy/src/lib.rs"); +const hostedObservability = maybeRead("private/hosted-policy/src/observability.rs"); +const hostedPolicy = hostedPolicyRoot + ? `${hostedPolicyRoot}\n${hostedObservability || ""}` + : null; +const hostedPostgresSmoke = maybeRead( + "private/hosted-policy/src/bin/disasmer-postgres-durable-smoke.rs" +); +const dapAdapter = read("crates/disasmer-dap/src/adapter.rs"); +const dapBreakpoints = read("crates/disasmer-dap/src/breakpoints.rs"); +const dapRuntimeClient = `${read("crates/disasmer-dap/src/runtime_client.rs")}\n${read("crates/disasmer-dap/src/runtime_client/transport.rs")}\n${read("crates/disasmer-dap/src/runtime_client/debug_protocol.rs")}`; +const dapVirtualModel = read("crates/disasmer-dap/src/virtual_model.rs"); +const dapTests = read("crates/disasmer-dap/src/tests.rs"); +const nodeAttachSmoke = read("scripts/node-attach-smoke.js"); +const schedulerPlacementSmoke = read("scripts/scheduler-placement-smoke.js"); +const selfHostedCoordinatorSmokeSource = read("scripts/self-hosted-coordinator-smoke.js"); +const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js"); +const vscodeF5Smoke = read("scripts/vscode-f5-smoke.js"); +const dapSmoke = read("scripts/dap-smoke.js"); +const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); +const artifactExportSmoke = read("scripts/artifact-export-smoke.js"); +const realFlagshipHarness = read("scripts/real-flagship-harness.js"); +const selfHostedCoordinatorSmoke = read("scripts/self-hosted-coordinator-smoke.js"); +const wasmtimeAssignmentSmoke = read("scripts/wasmtime-assignment-smoke.js"); +const publicLocalDemoMatrix = read("scripts/public-local-demo-matrix-smoke.js"); +const hostedClientCompatSmoke = fs.existsSync( + path.join(repo, "private/hosted-policy/scripts/hosted-client-compat-smoke.js") +) + ? read("private/hosted-policy/scripts/hosted-client-compat-smoke.js") + : null; +const publicDryrunServiceSmoke = fs.existsSync( + path.join(repo, "private/hosted-policy/scripts/public-release-dryrun-service-smoke.js") +) + ? read("private/hosted-policy/scripts/public-release-dryrun-service-smoke.js") + : null; +const hostedService = maybeRead("private/hosted-policy/src/bin/disasmer-hosted-service.rs"); +const hostedStartup = maybeRead( + "private/hosted-policy/src/bin/disasmer-hosted-service/startup.rs" +); +const hostedOperatorAuth = maybeRead( + "private/hosted-policy/src/bin/disasmer-hosted-service/operator_auth.rs" +); +const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js"); +const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js"); +const mvpCompletionReview = maybeRead("MVP_COMPLETION_REVIEW.md"); +const mvpCompletionReport = maybeRead("MVP_COMPLETION_REPORT.md"); + +if (mvpCompletionReview || mvpCompletionReport) { + assert( + mvpCompletionReview && mvpCompletionReport, + "MVP completion review and report must be present together" + ); + const reviewedCriteria = [ + ...new Set(mvpCompletionReview.match(/AC-\d{2}\.\d{2}/g) || []), + ]; + const reportedRows = mvpCompletionReport + .split(/\r?\n/) + .map((line) => line.match(/^\| (AC-\d{2}\.\d{2}) \| ([^|]+) \|/)) + .filter(Boolean); + const reportedCriteria = reportedRows.map((match) => match[1]); + assert.strictEqual( + reviewedCriteria.length, + 108, + "MVP completion review criterion inventory changed unexpectedly" + ); + assert.deepStrictEqual( + [...reportedCriteria].sort(), + [...reviewedCriteria].sort(), + "MVP completion report must contain exactly one table row for every reviewed criterion" + ); + assert.strictEqual( + new Set(reportedCriteria).size, + reportedCriteria.length, + "MVP completion report contains duplicate criterion rows" + ); + const allowedLevels = new Set([ + "Specified", + "Contract-verified", + "Integration-verified", + "Live-verified", + "Open", + ]); + for (const [, criterion, rawLevel] of reportedRows) { + assert( + allowedLevels.has(rawLevel.trim()), + `${criterion} uses an unknown verification level: ${rawLevel.trim()}` + ); + } + const openCriteria = reportedRows + .filter(([, , rawLevel]) => rawLevel.trim() === "Open") + .map(([, criterion]) => criterion); + assert( + openCriteria.length === 0 || + (openCriteria.length === 1 && openCriteria[0] === "AC-14.14"), + "the completion report may leave only immutable release provenance open" + ); + for (let index = 1; index <= 13; index += 1) { + const criterion = `AC-14.${String(index).padStart(2, "0")}`; + const row = reportedRows.find(([, candidate]) => candidate === criterion); + assert.strictEqual( + row?.[2].trim(), + "Live-verified", + `${criterion} must retain strict hosted evidence` + ); + } + for (const requiredSection of [ + "## Verification snapshot", + "## Authoritative verification runs", + "## Exact verification commands", + "## Candidate provenance (not final immutable evidence)", + "## Change accounting and replaced shortcuts", + "## Clean filtered-tree quickstart transcript", + "## Strict hosted transcript", + "## Known Windows-only issues", + "## Acceptance-criterion audit", + "## Remaining release sequence", + ]) { + assert( + mvpCompletionReport.includes(requiredSection), + `MVP completion report omitted required section: ${requiredSection}` + ); + } + if (openCriteria.length === 1) { + expect( + mvpCompletionReport, + "completed strict transcript keeps dirty provenance explicit", + /harness verdict is `partial` only[\s\S]*`source_tree_clean`[\s\S]*configuration `clean` are false/ + ); + } + expect( + mvpCompletionReport, + "exact strict command requires hosted authority and VPS restart proof", + /DISASMER_STRICT_FULL_RELEASE=1[\s\S]*DISASMER_STRICT_VPS_RESTART=1[\s\S]*DISASMER_SECOND_TENANT_(?:SESSION|EVIDENCE)_FILE=[\s\S]*DISASMER_EXPIRED_USER_SESSION_FILE=/ + ); +} + +expect( + phase2, + "phase 2 rejects type-only acceptance", + /- \[x\] \*\*Passed:\*\* A criterion cannot be accepted solely because a type, trait, schema, mock, or unit-level model exists\./ +); + +for (const [gateName, script] of [ + ["public acceptance", publicAcceptance], + ["private acceptance", privateAcceptance], + ["CLI-first acceptance", cliFirstAcceptance], + ["public split", publicSplit], +]) { + expectGate(script, gateName); +} + +expect(codeSizeGuard, "code-size guard enforces 1000-line production file limit", /maxProductionLines = 1000/); +expect( + codeSizeGuard, + "code-size guard excludes test-only Rust modules from business-logic threshold", + /isTestRustFile/ +); +expect( + phase3Ledger, + "Phase 3 ledger defines the exact pre-final and final states", + /Phase 3 ledger must contain 191 criteria[\s\S]*Passed: 0, Partial: 181, Open: 10[\s\S]*P3-GATE-[\s\S]*Passed: 191, Partial: 0, Open: 0/ +); +expect( + publicDryrunE2e, + "final public-release E2E requires the pre-final Phase 3 ledger", + /assertPreFinalLedger\(readPhase3Ledger\(repo\)\)/ +); +expect( + finalDryrunEvidence, + "final evidence requires every Phase 3 criterion to be Passed", + /assertFinalLedger\(readPhase3Ledger\(repo\)\)/ +); +expect( + dapVirtualModel, + "DAP launch state uses shared core identity and debug model types", + /use disasmer_core::\{[\s\S]*DebugRuntimeState[\s\S]*ProcessId[\s\S]*ProjectId[\s\S]*TaskDefinitionId[\s\S]*TaskInstanceId[\s\S]*TenantId[\s\S]*UserId[\s\S]*\}[\s\S]*pub\(crate\) tenant: TenantId[\s\S]*pub\(crate\) project_id: ProjectId[\s\S]*pub\(crate\) actor_user: UserId/ +); +expect( + coreBundle, + "bundle debug metadata contains source probe records", + /pub struct BundleDebugMetadata[\s\S]*pub probes: Vec[\s\S]*pub struct BundleDebugProbe[\s\S]*source_path[\s\S]*line_start[\s\S]*line_end[\s\S]*function[\s\S]*task: TaskDefinitionId[\s\S]*pub fn discover_source_debug_probes[\s\S]*disasmer_probe_task[\s\S]*namespace\.ident != "disasmer"[\s\S]*"main"[\s\S]*strip_suffix\("_main"\)[\s\S]*"task"/ +); +assert.doesNotMatch( + coreBundle, + /debug_probe_task_for_function|"file_scope"/, + "bundle debug metadata must not guess runtime task ownership from function names or invent a file-scope probe" +); +expect( + `${cliBundle}\n${cliTests}`, + "CLI bundle inspection populates generated debug probes from selected source inputs", + /discover_debug_probes\(&project, &selected_inputs\)[\s\S]*metadata\.debug_metadata\.probes = debug_probes[\s\S]*discover_source_debug_probes\(&input\.path, &source\)[\s\S]*bundle_inspect_discovers_environments_selected_inputs_and_source_providers[\s\S]*debug_metadata[\s\S]*probes[\s\S]*compile_linux/ +); +expect( + `${coreProject}\n${cliTests}`, + "project inspection derives real Disasmer entrypoints and never invents launch surfaces", + /NoEntrypoints[\s\S]*discover_entrypoints[\s\S]*segments\.as_slice\(\) == \["disasmer", "main"\][\s\S]*entrypoint_name[\s\S]*project_without_declared_entrypoint_does_not_invent_product_surfaces[\s\S]*entrypoint == "build"[\s\S]*entrypoint == "release"/ +); +expect( + sdkTaskArgs, + "SDK task boundary compile-fail examples reject host-only values", + /```compile_fail[\s\S]*borrowed\.as_str\(\)[\s\S]*```compile_fail[\s\S]*std::ptr::null[\s\S]*```compile_fail[\s\S]*std::fs::File[\s\S]*```compile_fail[\s\S]*Mutex::new/ +); +expect( + dapVirtualModel, + "DAP state stores bundle debug probe metadata", + /debug_probes: Vec[\s\S]*load_bundle_debug_probes\(&self\.project, &self\.source_path\)/ +); +expect( + dapBreakpoints, + "DAP breakpoint resolver uses bundle debug probes", + /load_bundle_debug_probes[\s\S]*discover_source_debug_probes\(source_path, &source\)[\s\S]*resolve_breakpoints[\s\S]*debug_probe_for_line[\s\S]*Mapped to Disasmer debug probe[\s\S]*No Disasmer debug probe metadata covers this source line/ +); +expect( + dapAdapter, + "DAP setBreakpoints calls probe-aware resolver", + /let requested_source_path = request[\s\S]*let requested_lines = request[\s\S]*resolve_breakpoints_for_source\([\s\S]*requested_source_path,[\s\S]*requested_lines[\s\S]*breakpoint\.to_dap\(\)/ +); +expect( + `${dapTests}\n${dapSmoke}`, + "DAP tests and smoke cover probe-backed breakpoint mapping", + /breakpoint_resolution_uses_bundle_debug_probe_metadata[\s\S]*buildMainLine[\s\S]*setBreakpoints[\s\S]*breakpointResponse\.body\.breakpoints\[0\]\.verified, true/ +); +expect( + dapRuntimeClient, + "DAP runtime client calls coordinator restart_task and parses boundary decision", + /pub\(crate\) fn restart_task\([\s\S]*state: &AdapterState,[\s\S]*task: &TaskInstanceId,[\s\S]*"type": "restart_task"[\s\S]*parse_task_restart_response[\s\S]*accepted[\s\S]*clean_boundary_available[\s\S]*requires_whole_process_restart/ +); +expect( + dapRuntimeClient, + "local and live DAP launch share the real Wasm entrypoint path", + /run_local_services_runtime[\s\S]*launch_services_debug_entrypoint\(&listen, state, &repo\)[\s\S]*fn launch_services_debug_entrypoint[\s\S]*build_debug_bundle[\s\S]*"set_debug_breakpoints"[\s\S]*"kind": "coordinator_node_wasm"[\s\S]*"wasm_module_base64"[\s\S]*run_live_services_runtime[\s\S]*launch_services_debug_entrypoint\(&coordinator, state, &repo\)/ +); +assert.doesNotMatch( + dapRuntimeClient, + /launch_live_placed_task|"command": "cargo"|"task": "compile-linux"/, + "live DAP must not launch a separate synthetic cargo task" +); +expect( + dapAdapter, + "DAP services restartFrame uses coordinator restart decision", + /state\.runtime_backend != RuntimeBackend::Simulated[\s\S]*restart_task_through_coordinator[\s\S]*restart_task\(state, &task\)[\s\S]*coordinator refused task restart[\s\S]*whole virtual-process restart required/ +); +expect( + dapRuntimeClient, + "DAP failed-main restart explicitly replaces the existing virtual process", + /relaunch_services_main_runtime[\s\S]*restart_state\.restart_existing = true[\s\S]*launch_services_debug_entrypoint/ +); +expect( + `${coordinatorProcesses}\n${coordinatorMainRuntime}`, + "process replacement retires the old main and rejects late incarnation commands", + /replacing_existing[\s\S]*interrupt_process[\s\S]*controls[\s\S]*remove[\s\S]*next_launch_id[\s\S]*is_current_scope[\s\S]*coordinator main process incarnation was replaced/ +); +expect( + `${dapTests}\n${dapSmoke}`, + "DAP tests and smoke cover coordinator restart boundary refusal", + /task_restart_response_preserves_coordinator_boundary_decision[\s\S]*client\.send\("restartFrame"[\s\S]*restartFailure[\s\S]*checkpoint boundary\|still active/ +); +expect( + dapSmoke, + "DAP restarts a terminal failed task and waits for its new acknowledged probe stop", + /failMainLine[\s\S]*restartClient\.send\("continue"[\s\S]*message\.event === "terminated"[\s\S]*send\("restartFrame"[\s\S]*response\(restartRequest, "restartFrame"\)[\s\S]*Restarted main from the rebuilt bundle[\s\S]*allThreadsStopped, true/ +); +expect( + `${sharedWasmtimeRuntime}\n${nodeAssignmentRunner}\n${dapVirtualModel}\n${dapSmoke}`, + "DAP proves real dynamic child threads, arguments, handles, and probe-boundary all-stop", + /enter_quiescent_host_boundary[\s\S]*leave_quiescent_host_boundary[\s\S]*TaskHostOperation::DebugProbe[\s\S]*debug_handle_snapshot[\s\S]*task_handle_\{handle_id\}[\s\S]*runtime_task_args[\s\S]*insert_runtime_thread[\s\S]*taskTrapLine[\s\S]*childThread[\s\S]*arg_0[\s\S]*task_handle_[\s\S]*definition=task_trap instance=ti:.*:child:\\d\+ state=active/ +); +expect( + `${dapAdapter}\n${dapRuntimeClient}\n${dapVirtualModel}\n${dapTests}`, + "DAP attach product mode reads coordinator debug/task state", + /attach_services_runtime[\s\S]*RuntimeBackend::LocalServices \| RuntimeBackend::LiveServices[\s\S]*apply_attach_record[\s\S]*"type": "debug_attach"[\s\S]*authorization[\s\S]*allowed[\s\S]*"type": "list_task_events"[\s\S]*coordinator_threads_from_events[\s\S]*attach_record_replaces_demo_threads_with_coordinator_task_events/ +); +expect( + coordinatorProtocol, + "coordinator protocol exposes probe hits and acknowledged Debug Epoch state", + /SetDebugBreakpoints \{[\s\S]*InspectDebugBreakpoints \{[\s\S]*CreateDebugEpoch \{[\s\S]*ResumeDebugEpoch \{[\s\S]*InspectDebugEpoch \{[\s\S]*ReportDebugState \{[\s\S]*ReportDebugProbeHit \{[\s\S]*DebugEpochStatus \{/ +); +expect( + `${coordinatorMain}\n${coordinatorRuntimeDurable}\n${coordinatorPostgres}\n${coordinatorServiceTests}\n${hostedStartup || ""}`, + "actual standalone and hosted services load and persist durable state through configured Postgres", + /DATABASE_URL[\s\S]*new_with_database_url[\s\S]*RuntimeDurableStore[\s\S]*PostgresDurableStore::connect[\s\S]*disasmer_cli_sessions[\s\S]*postgres_backed_runtime_service_survives_restart_without_live_process_state[\s\S]*new_with_admin_token_database_url_and_quota/ +); +if (hostedPostgresSmoke) { + expect( + hostedPostgresSmoke, + "private Postgres restart evidence authenticates the same durable CLI session after reboot", + /issue_cli_session[\s\S]*try_persist[\s\S]*Coordinator::try_boot[\s\S]*authenticate_cli_session_at[\s\S]*durable_cli_session_present/ + ); +} +expect( + coordinatorService, + "coordinator stores transient Debug Epoch maps", + /debug_epochs: BTreeMap[\s\S]*debug_epoch_runtime: BTreeMap[\s\S]*debug_breakpoints: BTreeMap[\s\S]*debug_commands: BTreeMap/ +); +expect( + coordinatorDebug, + "executing Wasm probe hits create a freeze Debug Epoch", + /handle_report_debug_probe_hit[\s\S]*wasm_debug_probe_hit[\s\S]*"freeze"[\s\S]*executing Wasm reached probe/ +); +expect( + coordinatorDebug, + "coordinator exposes freeze, resume, poll, and signed state handlers", + /handle_create_debug_epoch[\s\S]*"freeze"[\s\S]*handle_resume_debug_epoch[\s\S]*"resume"[\s\S]*handle_poll_debug_command[\s\S]*handle_report_debug_state/ +); +expect( + coordinatorDebug, + "coordinator derives all-stop and resumed state from participant acknowledgements", + /all_acknowledged[\s\S]*fully_frozen[\s\S]*DebugAcknowledgementState::Frozen[\s\S]*fully_resumed[\s\S]*DebugAcknowledgementState::Running/ +); +expect( + `${dapAdapter}\n${dapRuntimeClient}\n${dapVirtualModel}\n${dapSmoke}`, + "DAP services mode waits for coordinator Debug Epoch acknowledgement", + /run_local_services_runtime[\s\S]*set_debug_breakpoints[\s\S]*wait_for_breakpoint_hit[\s\S]*wait_for_debug_epoch_state_at[\s\S]*all_participants_frozen[\s\S]*resume_debug_epoch\(state[\s\S]*wait_for_debug_epoch_resumed/ +); +expect( + coordinatorServiceTests, + "coordinator test proves signed node freeze/resume debug commands", + /debug_epoch_commands_are_polled_by_signed_active_task_nodes[\s\S]*SetDebugBreakpoints[\s\S]*ReportDebugProbeHit[\s\S]*PollDebugCommand[\s\S]*ReportDebugState[\s\S]*fully_frozen[\s\S]*ResumeDebugEpoch[\s\S]*fully_resumed/ +); +expect( + coreCheckpoint, + "core models the exact clean task entry and VFS boundary", + /CheckpointBoundary[\s\S]*serialized_args[\s\S]*environment_digest[\s\S]*vfs_epoch[\s\S]*task_abi/ +); +expect( + coordinatorProcesses, + "coordinator captures that boundary before dispatch", + /capture_task_restart_checkpoint[\s\S]*required_artifacts[\s\S]*VfsManifest[\s\S]*task_restart_checkpoints\.insert[\s\S]*TaskRestartCheckpoint/ +); +expect( + `${coordinatorDebug}\n${coordinatorServiceTests}\n${wasmtimeAssignmentSmoke}`, + "failed task restart uses the captured boundary and relaunches the real assignment", + /RestartPolicy\.decide[\s\S]*handle_launch_task_with_actor[\s\S]*selected task restarted as new instance[\s\S]*from clean VFS entry boundary[\s\S]*service_reports_task_restart_boundary_through_public_api[\s\S]*restarted_assignment[\s\S]*trapRestart[\s\S]*clean_boundary_available, true[\s\S]*restartedTrapNodeRun\.node_status, "failed"/ +); +expect( + coordinatorProtocol, + "launch and assignment protocol carry the TaskSpec and exact Wasm module bytes", + /LaunchTask \{[\s\S]*task_spec: TaskSpec[\s\S]*wasm_module_base64: String[\s\S]*pub struct TaskAssignment \{[\s\S]*pub task_spec: TaskSpec[\s\S]*pub wasm_module_base64: String/ +); +expect( + coordinatorProcesses, + "coordinator placement preserves the TaskSpec and Wasm module in the assignment", + /let assignment = TaskAssignment \{[\s\S]*task_spec: pending\.task_spec[\s\S]*wasm_module_base64: pending\.wasm_module_base64/ +); +expect( + sharedWasmtimeRuntime, + "shared Wasmtime runtime verifies the module digest before compilation", + /fn verified_module_bytes[\s\S]*let actual = Digest::sha256\(bytes\)[\s\S]*if &actual != expected_bundle_digest[\s\S]*BundleDigestMismatch/ +); +expect( + `${nodeAssignmentRunner}\n${nodeDaemon}`, + "node assignment runner requires the bundle digest and executes the versioned task export through the verified runtime", + /run_verified_wasmtime_assignment[\s\S]*bundle_digest\.as_ref\(\)[\s\S]*run_task_export_verified_with_task_host[\s\S]*daemon_wasm_task_assignment_uses_abi_version_and_verifies_bundle_digest/ +); +expect( + nodeLib, + "node-facing runtime test rejects a digest mismatch before Wasm parsing", + /wasmtime_runtime_rejects_bundle_digest_mismatch_before_compilation[\s\S]*WasmTaskError::BundleDigestMismatch[\s\S]*failed to parse/ +); +expect( + `${coreExecution}\n${coordinatorProtocol}\n${coordinatorProcesses}\n${coordinatorServiceTests}`, + "product-mode spawn uses one Wasm TaskSpec and signed parent-scoped child launch", + /pub enum TaskDispatch[\s\S]*CoordinatorNodeWasm[\s\S]*pub struct TaskSpec[\s\S]*product_mode_uses_remote_dispatch[\s\S]*LaunchChildTask \{[\s\S]*parent_task: String[\s\S]*TaskAssignment \{[\s\S]*task_spec: TaskSpec[\s\S]*handle_launch_child_task[\s\S]*TaskDispatch::CoordinatorNodeWasm[\s\S]*signed_active_wasm_task_can_spawn_and_join_child_in_its_process_only/ +); +expect( + `${coreExecution}\n${coordinatorProtocol}\n${coordinatorLogs}\n${coordinatorService}\n${coordinatorServiceTests}`, + "join_task returns event-derived remote task results", + /pub struct TaskJoinResult[\s\S]*result: Option[\s\S]*remote_completion_observed[\s\S]*from_remote_completion[\s\S]*JoinTask \{[\s\S]*TaskJoined \{[\s\S]*handle_join_task[\s\S]*event\.result\.clone\(\)[\s\S]*waiting for signed node task_completed event[\s\S]*TaskJoinState::Pending[\s\S]*SmallJson/ +); +expect( + `${coordinatorProtocol}\n${coordinatorService}\n${coordinatorProcesses}\n${coordinatorServiceTests}`, + "launch_task can explicitly wait for a later capable node", + /LaunchTask \{[\s\S]*wait_for_node: bool[\s\S]*TaskQueued \{[\s\S]*pending_task_launches: VecDeque[\s\S]*Err\(err\) if wait_for_node[\s\S]*pending_task_launches[\s\S]*push_back[\s\S]*CoordinatorResponse::TaskQueued[\s\S]*assign_pending_task_to_node[\s\S]*DefaultScheduler\.place\(std::slice::from_ref\(&descriptor\)[\s\S]*coordinator_side_task_launch_can_wait_for_capable_worker[\s\S]*wait_for_node: true[\s\S]*late worker should receive pending assignment/ +); +expect( + schedulerPlacementSmoke, + "scheduler smoke proves queued placement through process boundary and signed node poll", + /type: "start_process"[\s\S]*type: "launch_task"[\s\S]*task_definition: "prepare_source"[\s\S]*abi: "task_v1"[\s\S]*required_capabilities: \["SourceFilesystem", "SourceGit"\][\s\S]*wait_for_node: true[\s\S]*task_queued[\s\S]*gitCapabilities[\s\S]*type: "poll_task_assignment"[\s\S]*late capable node should receive queued assignment/ +); +expect( + coreAuth, + "core agent workflow auth uses Ed25519 signatures over scoped request data", + /pub struct AgentSignedRequest[\s\S]*pub fn sign_agent_workflow_request[\s\S]*SigningKey::from_bytes[\s\S]*pub fn verify_agent_workflow_signature[\s\S]*VerifyingKey::from_bytes[\s\S]*agent_workflow_signature_message/ +); +expect( + coreAuth, + "core node request auth uses Ed25519 signatures over scoped node request data", + /pub struct NodeSignedRequest[\s\S]*pub fn sign_node_request[\s\S]*SigningKey::from_bytes[\s\S]*pub fn verify_node_request_signature[\s\S]*VerifyingKey::from_bytes[\s\S]*node_request_signature_message/ +); +expect( + coordinatorProtocol, + "coordinator protocol supports a versioned wire request envelope", + /pub enum CoordinatorWireRequest[\s\S]*Envelope\(CoordinatorRequestEnvelope\)[\s\S]*pub struct CoordinatorRequestEnvelope[\s\S]*protocol_version: u64[\s\S]*request_id: String[\s\S]*operation: String[\s\S]*authentication: Option[\s\S]*payload: CoordinatorRequest/ +); +expect( + coordinatorProtocol, + "coordinator TCP protocol accepts only versioned wire requests", + /pub enum CoordinatorWireRequest\s*\{\s*Envelope\(CoordinatorRequestEnvelope\),?\s*\}/ +); +expect( + coreWire, + "shared coordinator wire envelope includes protocol version, request id, operation, auth metadata, and payload", + /COORDINATOR_PROTOCOL_VERSION: u64 = 1[\s\S]*COORDINATOR_WIRE_REQUEST_TYPE[\s\S]*pub fn coordinator_wire_request[\s\S]*"protocol_version"[\s\S]*"request_id"[\s\S]*"operation"[\s\S]*"authentication"[\s\S]*"payload"/ +); +expect( + coordinatorProtocol, + "coordinator validates wire envelope version and operation before dispatch", + /if self\.protocol_version != COORDINATOR_PROTOCOL_VERSION[\s\S]*request_id\.trim\(\)\.is_empty\(\)[\s\S]*operation .*does not match payload operation/ +); +expect( + coordinatorTcp, + "coordinator TCP framing decodes versioned wire requests", + /decode_wire_request[\s\S]*serde_json::from_str::[\s\S]*into_request\(\)/ +); +expect( + coordinatorTcp, + "standalone Core coordinator TCP authority is strict by default", + /pub enum ClientAuthorityMode[\s\S]*Strict[\s\S]*LocalTrustedLoopback[\s\S]*pub fn serve_tcp[\s\S]*ClientAuthorityMode::Strict/ +); +expect( + coordinatorTcp, + "local trusted request authority is explicitly restricted to loopback development", + /serve_tcp_local_trusted[\s\S]*local_addr\(\)\?[\s\S]*ip\(\)\.is_loopback\(\)[\s\S]*local trusted request mode is restricted to a loopback listener/ +); +expect( + coordinatorTcp, + "strict Core Client authority rejects request-body identity", + /authorize_client_request[\s\S]*CoordinatorRequest::Authenticated[\s\S]*CoordinatorRequest::SignedNode[\s\S]*request-body identity fields are not authority/ +); +expect( + `${coordinatorMain}\n${cliRun}\n${cliRunLocalServices}`, + "local trusted loopback authority requires an explicit development opt-in", + /--allow-local-trusted-loopback[\s\S]*serve_tcp_local_trusted[\s\S]*--allow-local-trusted-loopback/ +); +expect( + dapRuntimeClient, + "DAP local-services coordinator explicitly opts into loopback development authority", + /run_local_services_runtime[\s\S]*"--listen"[\s\S]*"127\.0\.0\.1:0"[\s\S]*"--allow-local-trusted-loopback"/ +); +expect( + `${cliClient}\n${nodeDaemon}\n${dapRuntimeClient}`, + "CLI, node, and DAP transports send versioned coordinator wire requests", + /JsonLineSession[\s\S]*coordinator_wire_request[\s\S]*CoordinatorSession[\s\S]*coordinator_wire_request[\s\S]*fn coordinator_request[\s\S]*coordinator_wire_request/ +); +expect( + `${coordinatorServiceTests}\n${cliTests}`, + "focused tests prove versioned coordinator wire envelope behavior", + /service_stream_rejects_invalid_versioned_envelope_metadata[\s\S]*operation attach_node does not match payload operation ping[\s\S]*doctor_pings_configured_coordinator[\s\S]*"coordinator_request"[\s\S]*"protocol_version"/ +); +expect( + coordinatorServiceTests, + "strict TCP test proves body-authority denial and CLI-session success", + /strict_service_stream_rejects_body_authority_and_accepts_cli_session[\s\S]*victim-tenant[\s\S]*request-body identity fields are not authority[\s\S]*strict-stream-session[\s\S]*vp-authenticated/ +); +expect( + `${coordinatorProtocol}\n${coordinatorService}\n${coordinatorAuthenticated}`, + "authenticated scheduling derives scope from the CLI session", + /AuthenticatedCoordinatorRequest[\s\S]*ScheduleTask[\s\S]*AuthenticatedCoordinatorRequest::ScheduleTask[\s\S]*context\.tenant[\s\S]*context\.project/ +); +expect( + `${coreArtifact}\n${coordinatorNodes}\n${artifactDownloadSmoke}`, + "signed node retention inventory removes garbage-collected artifact availability", + /reconcile_node_retention[\s\S]*retaining_nodes\.remove\(node\)[\s\S]*reconcile_node_retention\(&node, &artifact_locations\)[\s\S]*artifact_locations: \[\][\s\S]*const collectedLink[\s\S]*unavailable from current retention/ +); +expect( + nodeTaskArtifacts, + "node artifact store reads retained chunks only after digest and size verification", + /NodeArtifactStore[\s\S]*read_verified_chunk[\s\S]*expected_digest[\s\S]*expected_size_bytes/ +); +expect( + `${coordinatorProtocol}\n${coordinatorArtifacts}`, + "coordinator reverse-transfer protocol polls and verifies uploaded artifact chunks", + /PollArtifactTransfer[\s\S]*UploadArtifactTransferChunk[\s\S]*artifact reverse transfer chunk digest mismatch/ +); +expect( + artifactDownloadSmoke, + "explicit artifact download waits for the retaining-node reverse stream", + /downloadRetainedBytes[\s\S]*retaining_node_reverse_stream_pending[\s\S]*retaining_node_reverse_stream/ +); +expect( + artifactDownloadSmoke, + "downloaded retained bytes are checked against coordinator artifact size and digest", + /downloadRetainedBytes\([\s\S]*packageEvent\.artifact_size_bytes[\s\S]*createHash\("sha256"\)[\s\S]*downloaded\.content[\s\S]*downloadedDigest, packageEvent\.artifact_digest/ +); +expect( + `${realFlagshipHarness}\n${artifactDownloadSmoke}\n${artifactExportSmoke}`, + "one real CLI flagship path runs Wasm children in rootless Podman and transfers retained bytes", + /ensureRootlessPodman[\s\S]*runFlagshipWorker[\s\S]*create_node_enrollment_grant[\s\S]*--enrollment-grant[\s\S]*"run", "build"[\s\S]*disasmer\+tcp:\/\/[\s\S]*worker_placement_requested[\s\S]*compile_linux[\s\S]*package_release[\s\S]*downloadRetainedBytes[\s\S]*launchFlagship[\s\S]*artifact_size_bytes/ +); +expect( + coordinatorProtocol, + "node heartbeat carries signed enrolled-key proof", + /NodeHeartbeat \{[\s\S]*node: String[\s\S]*node_signature: Option/ +); +expect( + coordinatorProtocol, + "node-originated public requests can be wrapped in signed node envelope", + /SignedNode \{[\s\S]*node: String[\s\S]*node_signature: NodeSignedRequest[\s\S]*request: Box/ +); +expect( + coordinatorNodes, + "coordinator verifies signed node heartbeat freshness and rejects replay", + /handle_node_heartbeat[\s\S]*authenticate_node_request[\s\S]*requires a signed proof of enrolled private-key possession[\s\S]*issued_at_epoch_seconds[\s\S]*abs_diff\(now_epoch_seconds\)[\s\S]*node_replay_nonces\.contains_key[\s\S]*verify_node_request_signature/ +); +expect( + coordinatorSignedNodeSurface, + "coordinator requires signed node envelope for node-originated requests", + /CoordinatorRequest::SignedNode[\s\S]*handle_signed_node_request[\s\S]*signed_node_request_kind[\s\S]*signed_node_request_node[\s\S]*reject_unsigned_node_request[\s\S]*node-originated request requires signed_node envelope proof/ +); +expect( + cliNode, + "CLI node attach derives and uses a node private key", + /let node_private_key = node_private_key_for_attach[\s\S]*node_ed25519_public_key_from_private_key[\s\S]*sign_node_request[\s\S]*signed_node_request_json[\s\S]*fn node_private_key_for_attach[\s\S]*DISASMER_NODE_PRIVATE_KEY/ +); +expect( + cliNode, + "CLI node attach persists fallback node credentials under project .disasmer", + /StoredNodeCredential[\s\S]*load_or_create_local_node_credential[\s\S]*"disasmer_node_credential"[\s\S]*local_node_credential_file[\s\S]*"\.disasmer"[\s\S]*"nodes"/ +); +expect( + cliNode, + "CLI node attach wraps node-originated requests in signed_node envelope", + /fn signed_node_request_json[\s\S]*sign_node_request[\s\S]*"type": "signed_node"[\s\S]*"node_signature": node_signature[\s\S]*"request": request/ +); +expect( + `${nodeDaemon}\n${nodeIdentity}`, + "node daemon derives and uses a node private key", + /let node_private_key = node_private_key_for_runtime[\s\S]*sign_node_request[\s\S]*signed_node_request_json[\s\S]*node_ed25519_public_key_from_private_key[\s\S]*fn node_private_key_for_runtime[\s\S]*DISASMER_NODE_PRIVATE_KEY/ +); +expect( + nodeIdentity, + "node daemon persists fallback node credentials under project .disasmer", + /StoredNodeCredential[\s\S]*load_or_create_local_node_credential[\s\S]*"disasmer_node_credential"[\s\S]*local_node_credential_file[\s\S]*"\.disasmer"[\s\S]*"nodes"/ +); +expect( + `${nodeDaemon}\n${nodeIdentity}`, + "node daemon wraps node-originated requests in signed_node envelope", + /fn signed_node_request_json[\s\S]*sign_node_request[\s\S]*"type": "signed_node"[\s\S]*"node_signature": node_signature[\s\S]*"request": request/ +); +expect( + `${nodeIdentity}\n${nodeCoordinatorSession}\n${sharedWasmtimeRuntime}\n${nodeAssignmentRunner}\n${nodeTaskArtifacts}\n${nodeTaskReports}\n${nodeDebugAgent}`, + "node responsibilities are split into identity, coordinator session, versioned Wasm host capabilities, artifact/VFS, logs, and debug modules", + /struct StoredNodeCredential[\s\S]*struct CoordinatorSession[\s\S]*struct WasmtimeTaskRuntime[\s\S]*command_run_v1[\s\S]*run_verified_wasmtime_assignment[\s\S]*struct TaskArtifactStore[\s\S]*record_completed_task[\s\S]*poll_task_cancellation/ +); +expect( + coordinatorAgents, + "coordinator agent authorization requires signed request freshness and registered public key verification", + /authorize_agent_project_run[\s\S]*AgentSignedRequest[\s\S]*issued_at_epoch_seconds[\s\S]*abs_diff\(now_epoch_seconds\)[\s\S]*verify_agent_workflow_signature/ +); +expect( + coordinatorProtocol, + "agent workflow requests carry signed request proof", + /LaunchTask \{[\s\S]*agent_public_key_fingerprint: Option[\s\S]*agent_signature: Option[\s\S]*StartProcess \{[\s\S]*agent_public_key_fingerprint: Option[\s\S]*agent_signature: Option/ +); +expect( + coordinatorProcesses, + "coordinator rejects fingerprint-only agent workflow dispatch and tracks nonce replay", + /requires a signed request proving private-key possession[\s\S]*agent_replay_nonces\.contains[\s\S]*agent signed request nonce has already been used[\s\S]*authorize_agent_project_run/ +); +expect( + cliRun, + "CLI signs agent workflow requests when private key identity is used", + /DISASMER_AGENT_PRIVATE_KEY[\s\S]*"agent_signature"[\s\S]*fn agent_signature_for_request[\s\S]*sign_agent_workflow_request/ +); +expect( + coordinatorDurable, + "durable state records scoped CLI session credentials", + /pub struct CliSessionRecord[\s\S]*pub session_digest: Digest[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub user: UserId[\s\S]*pub revoked: bool[\s\S]*pub cli_sessions: BTreeMap/ +); +expect( + coordinatorSessions, + "coordinator issues and authenticates CLI sessions by digest", + /pub fn issue_cli_session[\s\S]*Digest::sha256\(session_secret\)[\s\S]*pub fn authenticate_cli_session[\s\S]*cli_sessions[\s\S]*AuthContext/ +); +expect( + coordinatorSessions, + "coordinator rejects expired and revoked CLI sessions", + /expires_at_epoch_seconds[\s\S]*expires_at <= now_epoch_seconds[\s\S]*CLI session credential has expired[\s\S]*pub fn revoke_cli_session[\s\S]*record\.revoked = true/ +); +expect( + coordinatorProtocol, + "coordinator has authenticated request envelope without trusted actor fields", + /Authenticated \{[\s\S]*session_secret: String[\s\S]*request: AuthenticatedCoordinatorRequest[\s\S]*pub enum AuthenticatedCoordinatorRequest[\s\S]*AuthStatus[\s\S]*ListProjects/ +); +expect( + coordinatorProtocol, + "authenticated request protocol covers node enrollment, descriptor listing, and revocation", + /AuthenticatedCoordinatorRequest[\s\S]*CreateNodeEnrollmentGrant \{[\s\S]*ListNodeDescriptors[\s\S]*RevokeNodeCredential \{[\s\S]*node: String/ +); +expect( + coordinatorProtocol, + "authenticated request protocol covers user process, task, debug, and artifact operations", + /AuthenticatedCoordinatorRequest[\s\S]*StartProcess \{[\s\S]*CancelProcess \{[\s\S]*RestartTask \{[\s\S]*DebugAttach \{[\s\S]*ListTaskEvents \{[\s\S]*CreateArtifactDownloadLink \{[\s\S]*OpenArtifactDownloadStream \{[\s\S]*RevokeArtifactDownloadLink \{[\s\S]*ExportArtifactToNode \{/ +); +expect( + coordinatorProtocol, + "authenticated request protocol can revoke the current CLI session", + /AuthenticatedCoordinatorRequest[\s\S]*RevokeCliSession[\s\S]*CliSessionRevoked/ +); +expect( + coordinatorProtocol, + "admin protocol requests carry nonce-bound proof fields instead of bearer tokens", + /AdminStatus \{[\s\S]*actor_user: String[\s\S]*admin_proof: Digest[\s\S]*admin_nonce: String[\s\S]*issued_at_epoch_seconds: u64[\s\S]*SuspendTenant \{[\s\S]*target_tenant: String[\s\S]*admin_proof: Digest/ +); +expect( + coordinatorService, + "coordinator derives authenticated public request context from session secret", + /CoordinatorRequest::Authenticated[\s\S]*handle_authenticated_request[\s\S]*authenticate_cli_session\(&session_secret\)[\s\S]*AuthenticatedCoordinatorRequest::AuthStatus/ +); +expect( + coordinatorAuthorization, + "coordinator has centralized authorization helper for authenticated public user requests", + /pub\(super\) enum PublicUserOperation[\s\S]*AuthStatus[\s\S]*StartProcess[\s\S]*DebugAttach[\s\S]*ExportArtifactToNode[\s\S]*pub\(super\) fn authorize_authenticated_user_operation[\s\S]*authenticated .* request requires a user CLI session/ +); +expect( + coordinatorService, + "authenticated public dispatcher calls centralized authorization before handling", + /let context = self\.coordinator\.authenticate_cli_session\(&session_secret\)\?;[\s\S]*authorize_authenticated_user_operation\(&context, &request\)\?;[\s\S]*match request/ +); +expect( + coordinatorAuthorization, + "coordinator tests prove centralized authenticated public authorization rejection", + /authenticated_public_authorization_requires_user_context_and_names_operation[\s\S]*authenticated debug_attach request requires a user CLI session/ +); +expect( + `${coordinatorService}\n${coordinatorAuthenticated}\n${coordinatorDebug}`, + "coordinator routes user process/task/debug/artifact authenticated requests through session-derived context", + /AuthenticatedCoordinatorRequest::StartProcess[\s\S]*handle_start_process\([\s\S]*context\.tenant[\s\S]*Some\(actor\.as_str\(\)\.to_owned\(\)\)[\s\S]*AuthenticatedCoordinatorRequest::CancelProcess[\s\S]*handle_cancel_process[\s\S]*AuthenticatedCoordinatorRequest::RestartTask[\s\S]*handle_restart_task[\s\S]*AuthenticatedCoordinatorRequest::DebugAttach[\s\S]*handle_authenticated_debug_request[\s\S]*AuthenticatedCoordinatorRequest::ListTaskEvents[\s\S]*handle_list_task_events[\s\S]*AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink[\s\S]*handle_create_artifact_download_link[\s\S]*AuthenticatedCoordinatorRequest::ExportArtifactToNode[\s\S]*handle_export_artifact_to_node[\s\S]*handle_debug_attach/ +); +expect( + `${coreAuth}\n${coordinatorService}\n${coordinatorAdmin}\n${coordinatorServiceTests}`, + "coordinator verifies scoped self-hosted admin proof freshness and replay resistance", + /admin_request_proof_from_token_digest[\s\S]*new_with_admin_token[\s\S]*admin_token_digest[\s\S]*verify_admin_request[\s\S]*ADMIN_REQUEST_MAX_CLOCK_SKEW_SECONDS[\s\S]*admin request proof is invalid[\s\S]*admin_replay_nonces\.contains_key[\s\S]*nonce was already used[\s\S]*MAX_REPLAY_NONCES_PER_AUTHORITY[\s\S]*admin_replay_nonces[\s\S]*\.insert[\s\S]*replayed_admin_status/ +); +expect( + `${coordinatorService}\n${coordinatorQuota}`, + "coordinator keeps quota state and pre-work charging behind a focused quota module", + /mod quota;[\s\S]*quota: quota::CoordinatorQuota[\s\S]*pub\(super\) struct CoordinatorQuota[\s\S]*can_charge_workflow_spawn[\s\S]*charge_workflow_spawn[\s\S]*charge_rendezvous_attempt[\s\S]*can_charge_download[\s\S]*charge_debug_read/ +); +expect( + coordinatorService, + "coordinator revokes authenticated CLI sessions through session-derived context", + /AuthenticatedCoordinatorRequest::RevokeCliSession[\s\S]*revoke_cli_session\(&session_secret\)[\s\S]*CoordinatorResponse::CliSessionRevoked/ +); +expect( + cliConfig, + "stored CLI session can carry scoped coordinator credential without provider token fields", + /pub\(crate\) session_secret: Option/ +); +expect( + cliAuth, + "auth status uses authenticated envelope when a CLI session secret exists", + /let request = match authenticated_or_local_trusted_request\([\s\S]*coordinator,[\s\S]*stored_session,[\s\S]*"type": "auth_status"/ +); +expect( + cliClient, + "stored Client credentials are bound to their issuing coordinator endpoint", + /stored_session_for_coordinator[\s\S]*control_endpoint_identity\(&session\.coordinator\)[\s\S]*control_endpoint_identity\(coordinator\)/ +); +expect( + cliLogout, + "logout revokes stored CLI sessions through authenticated envelope before local removal", + /revoke_stored_cli_session_if_possible[\s\S]*"type": "authenticated"[\s\S]*"type": "revoke_cli_session"[\s\S]*"cli_session_revoked"/ +); +expect( + cliAdmin, + "admin CLI derives nonce-bound proofs and does not send its bearer token", + /admin_request_proof[\s\S]*command_nonce\("admin-status"\)[\s\S]*"admin_proof": admin_proof[\s\S]*"admin_nonce": admin_nonce[\s\S]*"issued_at_epoch_seconds": issued_at_epoch_seconds[\s\S]*fn admin_token_for_request[\s\S]*DISASMER_ADMIN_TOKEN[\s\S]*--admin-token/ +); +expect( + cliTests, + "CLI test proves auth status envelope omits trusted actor body field", + /assert!\(line\.contains\(r#""type":"authenticated""#\)\)[\s\S]*assert!\(line\.contains\(r#""session_secret":"disasmer-cli-session-secret""#\)\)[\s\S]*assert!\(!line\.contains\(r#""actor_user":"user-session""#\)\)/ +); +expect( + cliProject, + "project commands use authenticated envelope for signed-in CLI sessions", + /authenticated_or_local_trusted_request[\s\S]*"type": "create_project"[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "list_projects"[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "select_project"/ +); +expect( + cliNode, + "node commands use authenticated envelope for signed-in CLI sessions", + /node_enroll_report[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "create_node_enrollment_grant"[\s\S]*node_descriptors_report[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "list_node_descriptors"[\s\S]*node_revoke_report[\s\S]*authenticated_or_local_trusted_request[\s\S]*"type": "revoke_node_credential"/ +); +expect( + cliProject, + "project command reports use stored CLI session scope when a session secret exists", + /fn session_or_effective_scope_value[\s\S]*session\.session_secret\.is_some\(\)[\s\S]*session_value\(session\)/ +); +expect( + cliTests, + "CLI test proves project list envelope omits trusted actor body field", + /project_list_uses_authenticated_envelope_with_stored_cli_session[\s\S]*assert!\(line\.contains\(r#""type":"authenticated""#\)\)[\s\S]*assert!\(line\.contains\(r#""session_secret":"project-list-session-secret""#\)\)[\s\S]*assert!\(!line\.contains\(r#""actor_user":"user-session""#\)\)/ +); +expect( + cliTests, + "CLI test proves node command envelopes omit trusted actor body fields", + /node_commands_use_authenticated_envelope_with_stored_cli_session[\s\S]*"create_node_enrollment_grant"[\s\S]*"list_node_descriptors"[\s\S]*"revoke_node_credential"[\s\S]*assert!\(!line\.contains\(r#""actor_user":"ignored-user""#\)\)[\s\S]*assert!\(!line\.contains\(r#""tenant":"ignored-tenant""#\)\)[\s\S]*assert!\(!line\.contains\(r#""project":"ignored-project""#\)\)/ +); +expect( + `${cliTests}\n${coordinatorServiceTests}`, + "CLI and coordinator tests prove user control/artifact/debug requests use session-derived authenticated scope", + /user_control_commands_use_authenticated_envelope_with_stored_cli_session[\s\S]*"list_task_events"[\s\S]*"start_process"[\s\S]*"cancel_process"[\s\S]*"restart_task"[\s\S]*"create_artifact_download_link"[\s\S]*"debug_attach"[\s\S]*assert!\(!line\.contains\("ignored-tenant"\)\)[\s\S]*authenticated_envelope_derives_user_scope_from_cli_session[\s\S]*AuthenticatedCoordinatorRequest::StartProcess[\s\S]*AuthenticatedCoordinatorRequest::ListTaskEvents[\s\S]*AuthenticatedCoordinatorRequest::CreateArtifactDownloadLink[\s\S]*AuthenticatedCoordinatorRequest::CancelProcess[\s\S]*AuthenticatedCoordinatorRequest::DebugAttach/ +); +expect( + cliTests, + "CLI tests prove admin coordinator requests require a token but send only a scoped proof", + /admin_status_and_suspend_use_public_coordinator_api[\s\S]*!line\.contains\(r#""admin_token""#[\s\S]*admin_request_proof[\s\S]*admin_commands_require_explicit_admin_token_for_coordinator_requests[\s\S]*--admin-token/ +); +expect( + cliTests, + "CLI tests prove expired/revoked sessions ask for login and logout revokes server session", + /auth_status_reports_expired_or_revoked_cli_session_as_login_required[\s\S]*machine_error"\]\["category"\][\s\S]*"authentication"[\s\S]*logout_revokes_stored_cli_session_on_coordinator_before_local_removal[\s\S]*"revoke_cli_session"/ +); +expect( + cliTests, + "CLI test proves signed agent workflow requests are sent", + /run_with_agent_public_key_sends_attributable_workflow_actor[\s\S]*"agent_signature"[\s\S]*"signature":"ed25519:/ +); +expect( + coordinatorServiceTests, + "coordinator test proves signed agent workflow and fingerprint-only rejection", + /service_runs_agent_workflows_with_scoped_key_attribution[\s\S]*fingerprint_only[\s\S]*signed request[\s\S]*wrong_fingerprint[\s\S]*nonce/ +); +expect( + coordinatorServiceTests, + "coordinator test proves authenticated node operations derive scope from CLI session", + /authenticated_envelope_derives_user_scope_from_cli_session[\s\S]*AuthenticatedCoordinatorRequest::ListNodeDescriptors[\s\S]*AuthenticatedCoordinatorRequest::RevokeNodeCredential[\s\S]*tenant-a[\s\S]*project-a[\s\S]*user-a/ +); +expect( + coordinatorServiceTests, + "coordinator test proves admin operations fail closed without configured or valid proof", + /service_reports_and_enforces_public_admin_tenant_suspension[\s\S]*new_with_admin_token[\s\S]*admin credential is not configured[\s\S]*admin request proof is invalid/ +); +if (hostedService && hostedStartup && hostedOperatorAuth) { + expect( + `${hostedService}\n${hostedStartup}\n${hostedOperatorAuth}`, + "hosted client path rejects operator operations while private Operator requests require replay-resistant proofs", + /HOSTED_OPERATOR_TOKEN_ENV[\s\S]*HostedOperatorAuth[\s\S]*hosted operator operation requires an authenticated hosted_operator_request envelope[\s\S]*CoordinatorService::new_with_admin_token[\s\S]*replay_nonces[\s\S]*verify_request[\s\S]*hosted_operator_request_proof_from_token_digest/ + ); +} +expect( + coordinatorServiceTests, + "coordinator test proves node heartbeat requires signed enrolled key and rejects replay", + /service_requires_signed_node_heartbeat_from_enrolled_key[\s\S]*signed proof[\s\S]*wrong_signature[\s\S]*fresh-node-heartbeat[\s\S]*nonce/ +); +expect( + coordinatorServiceTests, + "coordinator test proves raw node-originated requests require signed envelope", + /service_rejects_raw_node_originated_requests_without_signed_envelope[\s\S]*ReportNodeCapabilities[\s\S]*signed_node envelope proof/ +); +expect( + nodeAttachSmoke, + "node attach smoke signs direct public coordinator heartbeat", + /DISASMER_NODE_PRIVATE_KEY[\s\S]*node_signature: signedNodeHeartbeat/ +); +expect( + selfHostedCoordinatorSmokeSource, + "self-hosted coordinator smoke signs public node heartbeat", + /public_key: identity\.publicKey[\s\S]*node_signature: signedNodeHeartbeat/ +); +expect( + selfHostedCoordinatorSmokeSource, + "self-hosted coordinator smoke proves strict authenticated Client authority", + /DISASMER_SELF_HOSTED_SESSION_SECRET[\s\S]*client_authority, "strict"[\s\S]*forgedBodyAuthority[\s\S]*wrongSession[\s\S]*connect-self-hosted[\s\S]*session-secret-stdin[\s\S]*used_cli_session_credential[\s\S]*authenticated\(\{[\s\S]*type: "schedule_task"/ +); +expect( + wasmtimeAssignmentSmoke, + "real SDK and Wasmtime smoke runs through strict authenticated Client authority", + /DISASMER_SELF_HOSTED_SESSION_SECRET[\s\S]*client_authority, "strict"[\s\S]*DISASMER_SDK_SESSION_SECRET[\s\S]*authenticated\(\{[\s\S]*type: "launch_task"[\s\S]*type: "join_task"/ +); +if (publicDryrunServiceSmoke) { + expect( + publicDryrunServiceSmoke, + "public release dryrun public coordinator heartbeat is signed", + /identity = nodeIdentity\("public-release-service-smoke", node\)[\s\S]*public_key: identity\.publicKey[\s\S]*node_signature: signedNodeHeartbeat/ + ); +} +if (hostedPolicy) { + expect( + hostedPolicy, + "hosted identity broker mints a non-provider CLI session secret and records only its digest in the login summary", + /generate_opaque_token\("cli_session"\)[\s\S]*cli_session_secret_digest: Digest::sha256\(&cli_session_secret\)[\s\S]*cli_session_secret/ + ); +} +if (hostedService) { + expect( + hostedService, + "hosted service delegates CLI session authority and project creation to the public coordinator", + /identity_broker\.complete_trusted_oidc_browser_login[\s\S]*core_coordinator[\s\S]*issue_cli_session[\s\S]*CoordinatorRequest::Authenticated[\s\S]*CreateProject/ + ); +} + +for (const [gateName, script] of [ + ["public acceptance", publicAcceptance], + ["private acceptance", privateAcceptance], + ["CLI-first acceptance", cliFirstAcceptance], + ["public split", publicSplit], +]) { + assert( + script.includes("node scripts/code-size-guard.js"), + `${gateName} must run code-size-guard.js` + ); +} + +expect(publicAcceptance, "public gate includes unit coverage", /cargo test --workspace/); +expect(publicAcceptance, "public gate includes binary build coverage", /cargo build --workspace --bins/); +expect(privateAcceptance, "private gate includes hosted unit coverage", /cargo test --manifest-path private\/hosted-policy\/Cargo\.toml/); +expect(publicSplit, "public split includes copied-tree unit coverage", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/); +expect(publicSplit, "public split includes copied-tree binary build coverage", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/); + +for (const [gateName, script] of [ + ["public acceptance", publicAcceptance], + ["public split", publicSplit], +]) { + for (const smoke of [ + "scripts/wasmtime-assignment-smoke.js", + "scripts/node-attach-smoke.js", + "scripts/cli-local-run-smoke.js", + "scripts/vscode-f5-smoke.js", + "scripts/dap-smoke.js", + "scripts/artifact-download-smoke.js", + "scripts/artifact-export-smoke.js", + "scripts/public-local-demo-matrix-smoke.js", + ]) { + assert(script.includes(`node ${smoke}`), `${gateName} must run boundary smoke ${smoke}`); + } +} + +for (const smoke of [ + "private/hosted-policy/scripts/hosted-deployment-smoke.js", + "private/hosted-policy/scripts/hosted-client-compat-smoke.js", + "private/hosted-policy/scripts/postgres-durable-smoke.js", +]) { + assert( + privateAcceptance.includes(`node ${smoke}`), + `private acceptance must run hosted boundary smoke ${smoke}` + ); +} +assert( + privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"), + "private acceptance must run standalone Core coordinator smoke" +); + +for (const [name, source, patterns] of [ + [ + "real coordinator Wasm assignment", + wasmtimeAssignmentSmoke, + [/cp\.spawn/, /disasmer-coordinator/, /type: "create_node_enrollment_grant"/, /disasmer-node/], + ], + [ + "node attach", + nodeAttachSmoke, + [/cp\.spawn/, /"node"[\s\S]*"attach"/, /used_enrollment_exchange/, /signedNodeHeartbeat/], + ], + [ + "CLI local run", + cliLocalRunSmoke, + [/cp\.spawn/, /disasmer[\s\S]*run/, /cli_process_started_node_process[\s\S]*true/], + ], + [ + "VS Code F5", + vscodeF5Smoke, + [ + /runtimeBackend[\s\S]*local-services/, + /buildMainLine[\s\S]*setBreakpoints[\s\S]*verified, true/, + /allThreadsStopped, true/, + /coordinator_task_events[\s\S]*value === 0/, + /Source Locals/, + /Task Args and Handles/, + /unavailable-local-diagnostic/, + /command_status[\s\S]*frozen through local services at executing Wasm probe/, + /state[\s\S]*Frozen/, + /command_spec/, + /stdout_tail/, + /stderr_tail/, + ], + ], + [ + "DAP", + dapSmoke, + [ + /runtimeBackend: "local-services"/, + /buildMainLine[\s\S]*setBreakpoints[\s\S]*verified, true/, + /allThreadsStopped, true[\s\S]*confirmed by every active participant/, + /threads[\s\S]*build virtual process/, + /build_main::wasm/, + /Source Locals/, + /Wasm Frame Locals/, + /unavailable-local-diagnostic/, + /runtime-boundary-diagnostic/, + /command_status[\s\S]*frozen through local services at executing Wasm probe/, + /taskTrapLine[\s\S]*childThread[\s\S]*arg_0[\s\S]*task_handle_/, + /source stepping is not yet available/, + /checkpoint boundary|still active/, + ], + ], + [ + "artifact download", + artifactDownloadSmoke, + [/create_artifact_download_link/, /open_artifact_download_stream/, /crossTenantOpen/], + ], + [ + "artifact export", + artifactExportSmoke, + [/export_artifact_to_node/, /node-export-receiver/, /coordinator_bulk_relay_allowed[\s\S]*false/], + ], + [ + "standalone Core coordinator", + selfHostedCoordinatorSmoke, + [/disasmer-coordinator/, /standalone-core-coordinator/, /core-coordinator-compat\.json/], + ], + [ + "public local demo matrix", + publicLocalDemoMatrix, + [/scripts\/cli-install-smoke\.js/, /scripts\/node-attach-smoke\.js/, /scripts\/vscode-f5-smoke\.js/], + ], +]) { + for (const pattern of patterns) { + expect(source, name, pattern); + } +} + +if (hostedClientCompatSmoke) { + for (const pattern of [ + /disasmer-hosted-service/, + /completeHostedBrowserLogin/, + /DISASMER_BROWSER_OPEN_COMMAND/, + /"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/, + /node_enrollment_exchanged/, + /DISASMER_HOSTED_CLI_SESSION_TTL_SECONDS/, + /expired; run disasmer login --browser again/, + /crossTenantTaskEventsDenied[\s\S]*list_task_events[\s\S]*vp-victim/, + /hosted-client-compat\.json/, + ]) { + expect(hostedClientCompatSmoke, "hosted Client compatibility", pattern); + } +} + +for (const pattern of [ + /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/, + /downloadReleaseAssets/, + /verifyChecksums/, + /git"[\s\S]*"clone"/, + /defaultLoginPlan\.coordinator/, + /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/, + /DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand/, + /node_enrollment_exchanged/, + /disasmerNode/, + /launch_task/, + /worker_assignment_poll_verified/, + /validateStandaloneCoreCoordinator/, + /core_coordinator_validated/, + /standalone-core-coordinator/, + /DISASMER_SELF_HOSTED_SESSION_SECRET/, + /client_authority, "strict"/, + /forged_body_authority_denied/, + /wrong_session_denied/, + /preTaskMainLine/, + /compileLinuxLine/, + /task_recorded/, + /list_task_events/, + /create_artifact_download_link/, + /vscode-f5-smoke\.js/, + /downloaded_release_assets: true/, + /vscode_debugger_verified: true/, + /artifact_download_or_export_verified: true/, + /public-release-dryrun-e2e\.json/, +]) { + expect(publicDryrunE2e, "public release e2e evidence", pattern); +} + +for (const pattern of [ + /public-release-dryrun-forgejo-release\.json/, + /deployment-manifest\.json/, + /public-release-dryrun-service\.json/, + /hosted-client-compat\.json/, + /core-coordinator-compat\.json/, + /public-release-dryrun-e2e\.json/, + /coordinator_validation/, + /core_coordinator_client_authority/, + /core_coordinator_forged_body_authority_denied/, + /downloaded_release_assets/, + /vscode_debugger_verified/, + /artifact_download_or_export_verified/, + /public-release-dryrun-final\.json/, +]) { + expect(finalDryrunEvidence, "public release final evidence", pattern); +} + +console.log("Acceptance evidence contract smoke passed"); diff --git a/scripts/acceptance-private.sh b/scripts/acceptance-private.sh new file mode 100755 index 0000000..c729f3a --- /dev/null +++ b/scripts/acceptance-private.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo" + +node scripts/acceptance-report.js private +node scripts/acceptance-report-smoke.js +node scripts/acceptance-doc-contract-smoke.js +node scripts/acceptance-environment-contract-smoke.js +node scripts/acceptance-evidence-contract-smoke.js +node private/hosted-policy/scripts/phase3-live-infra-contract-smoke.js +node scripts/code-size-guard.js +node scripts/public-private-boundary-smoke.js +node scripts/release-blocker-smoke.js +node scripts/resource-metering-contract-smoke.js +node scripts/hostile-input-contract-smoke.js +node scripts/tenant-isolation-contract-smoke.js +node scripts/cli-error-exit-smoke.js +scripts/release-source-scan.sh +cargo test --manifest-path private/hosted-policy/Cargo.toml +node private/hosted-policy/scripts/hosted-signup-contract-smoke.js +node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js +node private/hosted-policy/scripts/hosted-deployment-smoke.js +node private/hosted-policy/scripts/hosted-client-compat-smoke.js +node scripts/self-hosted-coordinator-smoke.js +if command -v podman >/dev/null 2>&1; then + node private/hosted-policy/scripts/postgres-durable-smoke.js +elif command -v nix >/dev/null 2>&1; then + nix shell nixpkgs#podman --command node private/hosted-policy/scripts/postgres-durable-smoke.js +else + node private/hosted-policy/scripts/postgres-durable-smoke.js +fi +if [[ -n "${DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR:-}" ]]; then + node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js +fi +if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then + node scripts/public-release-dryrun-final-evidence.js +fi diff --git a/scripts/acceptance-public.sh b/scripts/acceptance-public.sh new file mode 100755 index 0000000..b849a25 --- /dev/null +++ b/scripts/acceptance-public.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo" + +node scripts/acceptance-report.js public +node scripts/acceptance-report-smoke.js +node scripts/acceptance-doc-contract-smoke.js +node scripts/acceptance-environment-contract-smoke.js +node scripts/acceptance-evidence-contract-smoke.js +node scripts/code-size-guard.js +node scripts/public-private-boundary-smoke.js +node scripts/release-blocker-smoke.js +node scripts/resource-metering-contract-smoke.js +node scripts/hostile-input-contract-smoke.js +node scripts/tenant-isolation-contract-smoke.js +node scripts/public-story-contract-smoke.js +node scripts/public-release-dryrun-contract-smoke.js +node scripts/public-browser-login-contract-smoke.js +node scripts/self-hosted-coordinator-smoke.js +node scripts/public-local-demo-matrix-smoke.js +scripts/release-source-scan.sh +node scripts/prepare-public-release-dryrun.js +if [[ "${DISASMER_PUBLIC_RELEASE_PREFLIGHT:-}" == "1" ]]; then + node scripts/public-release-dryrun-preflight.js +fi +if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then + node scripts/publish-public-release-dryrun.js +fi +if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:-}" == "1" ]]; then + node scripts/public-release-dryrun-e2e.js +fi +if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then + node scripts/public-release-dryrun-final-evidence.js +fi +cargo fmt --all --check +cargo test --workspace +cargo build --workspace --bins +node scripts/docs-smoke.js +node scripts/cli-output-mode-smoke.js +node scripts/cli-login-smoke.js +node scripts/cli-error-exit-smoke.js +node scripts/cli-browser-login-flow-smoke.js +node scripts/cli-install-smoke.js +node scripts/user-session-token-boundary-smoke.js +node scripts/sdk-spawn-runtime-smoke.js +node scripts/node-lifecycle-contract-smoke.js +node scripts/wasmtime-node-smoke.js +node scripts/wasmtime-assignment-smoke.js +if command -v podman >/dev/null 2>&1; then + node scripts/podman-backend-smoke.js +elif command -v nix >/dev/null 2>&1; then + nix shell nixpkgs#podman --command node scripts/podman-backend-smoke.js +else + node scripts/podman-backend-smoke.js +fi +node scripts/vscode-extension-smoke.js +node scripts/vscode-f5-smoke.js +node scripts/node-attach-smoke.js +node scripts/cli-local-run-smoke.js +node scripts/artifact-download-smoke.js +node scripts/artifact-export-smoke.js +node scripts/operator-panel-smoke.js +node scripts/source-preparation-smoke.js +node scripts/scheduler-placement-smoke.js +node scripts/windows-best-effort-smoke.js +node scripts/windows-validation-contract-smoke.js +node scripts/quic-smoke.js +node scripts/dap-smoke.js +node scripts/flagship-demo-smoke.js +scripts/verify-public-split.sh diff --git a/scripts/acceptance-report-smoke.js b/scripts/acceptance-report-smoke.js new file mode 100644 index 0000000..2220639 --- /dev/null +++ b/scripts/acceptance-report-smoke.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function runReport(mode, env = {}) { + const output = cp.execFileSync( + "node", + ["scripts/acceptance-report.js", mode], + { + cwd: repo, + encoding: "utf8", + env: { ...process.env, ...env }, + } + ); + const report = JSON.parse(output); + const persistedPath = path.join( + repo, + "target", + "acceptance", + `${mode}-environment.json` + ); + const persisted = JSON.parse(fs.readFileSync(persistedPath, "utf8")); + assert.deepStrictEqual(persisted, report, `${mode} report was not persisted`); + return report; +} + +function assertString(value, name) { + assert.strictEqual(typeof value, "string", `${name} must be a string`); + assert(value.length > 0, `${name} must not be empty`); +} + +function assertNullableString(value, name) { + if (value === null) return; + assertString(value, name); +} + +function assertPodmanReport(podman) { + assert(podman && typeof podman === "object", "podman report must be an object"); + assert( + ["available", "incomplete"].includes(podman.status), + "podman.status must be available or incomplete" + ); + assertNullableString(podman.version, "podman.version"); + assertNullableString(podman.rootless, "podman.rootless"); + assertNullableString(podman.incomplete_reason, "podman.incomplete_reason"); + if (podman.status === "available") { + assertString(podman.version, "podman.version"); + assert.strictEqual(podman.rootless, "true"); + assert.strictEqual(podman.incomplete_reason, null); + } else { + assertString(podman.incomplete_reason, "podman.incomplete_reason"); + } +} + +function assertReport(report, mode, expectedWindowsValidation) { + assert.strictEqual(report.kind, "disasmer_acceptance_environment"); + assert.strictEqual(report.mode, mode); + assert.match(report.generated_at, /^\d{4}-\d{2}-\d{2}T/); + assert.match(report.commit, /^[0-9a-f]{40}$/); + assert(Array.isArray(report.tree_status), "tree_status must be an array"); + + assertString(report.os.platform, "os.platform"); + assertString(report.os.release, "os.release"); + assertString(report.os.kernel, "os.kernel"); + assertString(report.os.arch, "os.arch"); + + assertString(report.rust.rustc, "rust.rustc"); + assertString(report.rust.cargo, "rust.cargo"); + assertString(report.node.version, "node.version"); + assert(report.node.version.startsWith("v"), "node.version must be Node.js style"); + + assertPodmanReport(report.podman); + assertNullableString(report.postgres.version, "postgres.version"); + assertNullableString(report.browser_harness.version, "browser_harness.version"); + assertNullableString(report.browser_harness.command, "browser_harness.command"); + assertString(report.browser_harness.configured, "browser_harness.configured"); + + assert(Array.isArray(report.vscode_harness.smokes), "vscode smokes must be listed"); + assert( + report.vscode_harness.smokes.includes("scripts/vscode-extension-smoke.js"), + "VS Code extension smoke must be recorded" + ); + assert( + report.vscode_harness.smokes.includes("scripts/vscode-f5-smoke.js"), + "VS Code F5 smoke must be recorded" + ); + assertString(report.vscode_harness.engine, "vscode_harness.engine"); + assertString( + report.vscode_harness.extension_version, + "vscode_harness.extension_version" + ); + + assert.strictEqual(report.windows_validation, expectedWindowsValidation); +} + +for (const mode of ["public", "private"]) { + assertReport(runReport(mode), mode, "not-run"); +} + +assertReport( + runReport("windows", { + DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner", + }), + "windows", + "forgejo-windows-runner" +); + +console.log("Acceptance report smoke passed"); diff --git a/scripts/acceptance-report.js b/scripts/acceptance-report.js new file mode 100755 index 0000000..dfe6b8d --- /dev/null +++ b/scripts/acceptance-report.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +const cp = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const mode = process.argv[2] || "public"; + +function commandOutput(command, args = []) { + try { + return cp + .execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }) + .trim(); + } catch (_) { + return null; + } +} + +function packageJson() { + return JSON.parse( + fs.readFileSync(path.join(repo, "vscode-extension/package.json"), "utf8") + ); +} + +function firstCommandOutput(candidates) { + for (const [command, args] of candidates) { + const output = commandOutput(command, args); + if (output) { + return { command, version: output }; + } + } + return null; +} + +function podmanReport() { + const version = commandOutput("podman", ["--version"]); + if (!version) { + return { + status: "incomplete", + version: null, + rootless: null, + incomplete_reason: "podman command is unavailable" + }; + } + const rootless = commandOutput("podman", [ + "info", + "--format", + "{{.Host.Security.Rootless}}" + ]); + if (!rootless) { + return { + status: "incomplete", + version, + rootless: null, + incomplete_reason: "podman info did not report rootless status" + }; + } + if (rootless !== "true") { + return { + status: "incomplete", + version, + rootless, + incomplete_reason: "podman is not running in rootless mode" + }; + } + return { + status: "available", + version, + rootless, + incomplete_reason: null + }; +} + +const extensionPackage = packageJson(); +const sourceCommit = + process.env.DISASMER_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]); +const browserVersion = firstCommandOutput([ + ["chromium", ["--version"]], + ["chromium-browser", ["--version"]], + ["google-chrome", ["--version"]], + ["firefox", ["--version"]] +]); +const report = { + kind: "disasmer_acceptance_environment", + mode, + commit: sourceCommit, + tree_status: (commandOutput("git", ["status", "--short"]) || "") + .split("\n") + .filter(Boolean), + generated_at: new Date().toISOString(), + os: { + platform: os.platform(), + release: os.release(), + kernel: os.release(), + arch: os.arch() + }, + rust: { + rustc: commandOutput("rustc", ["--version"]), + cargo: commandOutput("cargo", ["--version"]) + }, + node: { + version: process.version + }, + podman: podmanReport(), + postgres: { + version: + commandOutput("postgres", ["--version"]) || + commandOutput("psql", ["--version"]) + }, + browser_harness: { + version: browserVersion && browserVersion.version, + command: browserVersion && browserVersion.command, + configured: process.env.DISASMER_BROWSER_HARNESS || "not-configured" + }, + vscode_harness: { + smokes: [ + "scripts/vscode-extension-smoke.js", + "scripts/vscode-f5-smoke.js" + ], + engine: extensionPackage.engines && extensionPackage.engines.vscode, + extension_version: extensionPackage.version + }, + windows_validation: process.env.DISASMER_WINDOWS_VALIDATION || "not-run" +}; + +const outDir = path.join(repo, "target/acceptance"); +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync( + path.join(outDir, `${mode}-environment.json`), + `${JSON.stringify(report, null, 2)}\n` +); + +console.log(JSON.stringify(report, null, 2)); diff --git a/scripts/agent-signing.js b/scripts/agent-signing.js new file mode 100644 index 0000000..88ab34a --- /dev/null +++ b/scripts/agent-signing.js @@ -0,0 +1,95 @@ +const crypto = require("crypto"); + +const { nodeIdentity, signedRequestPayloadDigest } = require("./node-signing"); + +function agentIdentity(seedPrefix, agent) { + const identity = nodeIdentity(seedPrefix, agent); + return { + ...identity, + publicKeyFingerprint: `sha256:${crypto + .createHash("sha256") + .update(identity.publicKey) + .digest("hex")}`, + }; +} + +function agentWorkflowSignatureMessage({ + tenant, + project, + agent, + requestKind, + process: processId, + task = "", + payloadDigest, + nonce, + issuedAtEpochSeconds, +}) { + const parts = [ + "disasmer-agent-workflow-signature:v2", + tenant, + project, + agent, + requestKind, + processId, + task, + payloadDigest, + nonce, + String(issuedAtEpochSeconds), + ]; + return Buffer.concat( + parts.flatMap((part) => [ + Buffer.from(`${Buffer.byteLength(part)}:`), + Buffer.from(part), + Buffer.from("\n"), + ]) + ); +} + +function signedAgentWorkflowProof(identity, request, options = {}) { + const nonce = + options.nonce || + `${request.type}-${process.pid}-${Date.now()}-${crypto + .randomBytes(8) + .toString("hex")}`; + const issuedAtEpochSeconds = + options.issuedAtEpochSeconds || Math.floor(Date.now() / 1000); + const signature = crypto.sign( + null, + agentWorkflowSignatureMessage({ + tenant: request.tenant, + project: request.project, + agent: request.actor_agent, + requestKind: request.type, + process: request.process, + task: request.task || "", + payloadDigest: signedRequestPayloadDigest(request), + nonce, + issuedAtEpochSeconds, + }), + identity.privateKeyObject + ); + return { + nonce, + issued_at_epoch_seconds: issuedAtEpochSeconds, + signature: `ed25519:${signature.toString("base64")}`, + }; +} + +function signedAgentWorkflowRequest(identity, request, options = {}) { + const unsignedRequest = { + ...request, + agent_public_key_fingerprint: + options.publicKeyFingerprint || identity.publicKeyFingerprint, + }; + return { + ...unsignedRequest, + agent_signature: signedAgentWorkflowProof(identity, unsignedRequest, options), + }; +} + +module.exports = { + agentIdentity, + agentWorkflowSignatureMessage, + signedAgentWorkflowProof, + signedAgentWorkflowRequest, +}; diff --git a/scripts/artifact-download-smoke.js b/scripts/artifact-download-smoke.js new file mode 100755 index 0000000..7af084d --- /dev/null +++ b/scripts/artifact-download-smoke.js @@ -0,0 +1,401 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { nodeIdentity, signedNodeRequest } = require("./node-signing"); +const { + ensureRootlessPodman, + flagshipNodeCapabilities, + launchFlagship, + repo, + runFlagshipWorker, + send, + waitForJsonLine, +} = require("./real-flagship-harness"); + +const downloadNode = "node-download"; +const downloadNodeIdentity = nodeIdentity("artifact-download-smoke", downloadNode); + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function downloadRetainedBytes(addr, link, artifact, expectedSize) { + const chunks = []; + let offset = 0; + for (let attempt = 0; attempt < 500; attempt += 1) { + const response = await send(addr, { + type: "open_artifact_download_stream", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + token_digest: link.link.scoped_token_digest, + chunk_bytes: 256 * 1024, + }); + assert.strictEqual(response.type, "artifact_download_stream"); + if (!response.content_bytes_available) { + assert.strictEqual(response.content_source, "retaining_node_reverse_stream_pending"); + await delay(10); + continue; + } + assert.strictEqual(response.content_source, "retaining_node_reverse_stream"); + assert.strictEqual(response.content_offset, offset); + const bytes = Buffer.from(response.content_base64, "base64"); + assert.strictEqual(response.streamed_bytes, bytes.length); + chunks.push(bytes); + offset += bytes.length; + if (response.content_eof) { + const content = Buffer.concat(chunks); + assert.strictEqual(content.length, expectedSize); + return { content, response }; + } + } + throw new Error("timed out waiting for retained artifact reverse stream"); +} + +function downloadNodeCapabilities() { + return flagshipNodeCapabilities(); +} + +(async () => { + ensureRootlessPodman(); + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback", + ], + { cwd: repo } + ); + + let worker; + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + worker = await runFlagshipWorker(addr, downloadNode, downloadNodeIdentity); + const workerReady = await worker.ready; + assert.strictEqual(workerReady.node_status, "ready"); + assert.strictEqual(workerReady.mode, "worker"); + const { compileEvent, packageEvent, process: virtualProcess } = await launchFlagship(addr); + assert.strictEqual(compileEvent.status_code, 0); + assert.strictEqual(packageEvent.status_code, 0); + assert.deepStrictEqual(compileEvent.result, { + Artifact: { + id: compileEvent.artifact_path.slice("/vfs/artifacts/".length), + digest: compileEvent.artifact_digest, + size_bytes: compileEvent.artifact_size_bytes, + }, + }); + assert.deepStrictEqual(packageEvent.result, { + Artifact: { + id: packageEvent.artifact_path.slice("/vfs/artifacts/".length), + digest: packageEvent.artifact_digest, + size_bytes: packageEvent.artifact_size_bytes, + }, + }); + assert.ok(compileEvent.artifact_size_bytes > 0); + assert.ok(packageEvent.artifact_size_bytes > compileEvent.artifact_size_bytes); + assert.match(compileEvent.artifact_digest, /^sha256:[0-9a-f]{64}$/); + assert.match(packageEvent.artifact_digest, /^sha256:[0-9a-f]{64}$/); + const artifactPath = packageEvent.artifact_path; + assert.match(artifactPath, /^\/vfs\/artifacts\/release\.tar-[0-9a-f]{64}$/); + const artifact = artifactPath.slice("/vfs/artifacts/".length); + + const disconnectedReport = await send(addr, signedNodeRequest(downloadNode, downloadNodeIdentity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node: downloadNode, + capabilities: downloadNodeCapabilities(), + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [artifact], + direct_connectivity: false, + online: true, + })); + assert.strictEqual(disconnectedReport.type, "node_capabilities_recorded"); + + const disconnectedLink = await send(addr, { + type: "create_artifact_download_link", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + ttl_seconds: 60, + }); + assert.strictEqual(disconnectedLink.type, "artifact_download_link"); + + const connectedReport = await send(addr, signedNodeRequest(downloadNode, downloadNodeIdentity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node: downloadNode, + capabilities: downloadNodeCapabilities(), + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [artifact], + direct_connectivity: true, + online: true, + })); + assert.strictEqual(connectedReport.type, "node_capabilities_recorded"); + + const link = await send(addr, { + type: "create_artifact_download_link", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + ttl_seconds: 60, + }); + assert.strictEqual(link.type, "artifact_download_link"); + assert.strictEqual(link.link.tenant, "tenant"); + assert.strictEqual(link.link.project, "project"); + assert.strictEqual(link.link.process, virtualProcess); + assert.deepStrictEqual(link.link.actor, { User: "user" }); + assert.match(link.link.policy_context_digest, /^sha256:[0-9a-f]{64}$/); + assert.ok(link.link.expires_at_epoch_seconds > Math.floor(Date.now() / 1000)); + assert.ok(link.link.expires_at_epoch_seconds <= Math.floor(Date.now() / 1000) + 60); + assert.ok( + link.link.url_path.endsWith( + `/artifacts/tenant/project/${virtualProcess}/${artifact}` + ) + ); + assert.deepStrictEqual(link.link.source, { RetainedNode: "node-download" }); + + const crossTenant = await send(addr, { + type: "create_artifact_download_link", + tenant: "other", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + ttl_seconds: 60, + }); + assert.strictEqual(crossTenant.type, "error"); + assert.match(crossTenant.message, /tenant mismatch/); + + const crossProject = await send(addr, { + type: "create_artifact_download_link", + tenant: "tenant", + project: "other-project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + ttl_seconds: 60, + }); + assert.strictEqual(crossProject.type, "error"); + assert.match(crossProject.message, /project mismatch/); + + const crossTenantOpen = await send(addr, { + type: "open_artifact_download_stream", + tenant: "other", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + token_digest: link.link.scoped_token_digest, + chunk_bytes: 1, + }); + assert.strictEqual(crossTenantOpen.type, "error"); + assert.match(crossTenantOpen.message, /tenant mismatch/); + + const crossProjectOpen = await send(addr, { + type: "open_artifact_download_stream", + tenant: "tenant", + project: "other-project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + token_digest: link.link.scoped_token_digest, + chunk_bytes: 1, + }); + assert.strictEqual(crossProjectOpen.type, "error"); + assert.match(crossProjectOpen.message, /project mismatch/); + + const guessed = await send(addr, { + type: "open_artifact_download_stream", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + token_digest: "sha256:guessed", + chunk_bytes: 1, + }); + assert.strictEqual(guessed.type, "error"); + assert.match(guessed.message, /token is invalid/); + + const crossActorOpen = await send(addr, { + type: "open_artifact_download_stream", + tenant: "tenant", + project: "project", + actor_user: "other-user", + artifact, + max_bytes: 1024 * 1024, + token_digest: link.link.scoped_token_digest, + chunk_bytes: 1, + }); + assert.strictEqual(crossActorOpen.type, "error"); + assert.match(crossActorOpen.message, /token is invalid/); + + const downloaded = await downloadRetainedBytes( + addr, + link, + artifact, + packageEvent.artifact_size_bytes, + ); + const downloadedDigest = `sha256:${crypto + .createHash("sha256") + .update(downloaded.content) + .digest("hex")}`; + assert.strictEqual(downloadedDigest, packageEvent.artifact_digest); + const inspect = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-release-")); + try { + const archive = path.join(inspect, "release.tar"); + fs.writeFileSync(archive, downloaded.content); + const listing = cp.execFileSync("tar", ["-tf", archive], { encoding: "utf8" }); + assert.strictEqual(listing.trim(), "hello-disasmer"); + cp.execFileSync("tar", ["-xf", archive, "-C", inspect]); + fs.chmodSync(path.join(inspect, "hello-disasmer"), 0o755); + assert.strictEqual( + cp.execFileSync(path.join(inspect, "hello-disasmer"), { encoding: "utf8" }), + "hello from a real Disasmer build\n" + ); + } finally { + fs.rmSync(inspect, { recursive: true, force: true }); + } + const cliDownloadDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "disasmer-cli-download-") + ); + try { + const cliDownloadPath = path.join(cliDownloadDirectory, "release.tar"); + const cliDownload = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", + "artifact", "download", artifact, + "--to", cliDownloadPath, + "--max-bytes", "1048576", + "--coordinator", `disasmer+tcp://${addr.host}:${addr.port}`, + "--tenant", "tenant", + "--project-id", "project", + "--user", "user", + "--json", + ], + { cwd: repo, env: process.env, encoding: "utf8" } + ) + ); + assert.strictEqual(cliDownload.command, "artifact download"); + assert.strictEqual(cliDownload.local_download.status, "local_bytes_written"); + assert.strictEqual( + cliDownload.local_download.verified_digest, + packageEvent.artifact_digest + ); + assert.strictEqual( + `sha256:${crypto + .createHash("sha256") + .update(fs.readFileSync(cliDownloadPath)) + .digest("hex")}`, + packageEvent.artifact_digest + ); + } finally { + fs.rmSync(cliDownloadDirectory, { recursive: true, force: true }); + } + assert.strictEqual(downloaded.response.content_eof, true); + assert.strictEqual( + downloaded.response.charged_download_bytes, + packageEvent.artifact_size_bytes + ); + assert.strictEqual(downloaded.response.link.artifact, artifact); + + const crossActorRevoke = await send(addr, { + type: "revoke_artifact_download_link", + tenant: "tenant", + project: "project", + actor_user: "other-user", + artifact, + token_digest: link.link.scoped_token_digest, + }); + assert.strictEqual(crossActorRevoke.type, "error"); + assert.match(crossActorRevoke.message, /token is invalid/); + + const revoked = await send(addr, { + type: "revoke_artifact_download_link", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + token_digest: link.link.scoped_token_digest, + }); + assert.strictEqual(revoked.type, "artifact_download_link_revoked"); + assert.strictEqual(revoked.link.scoped_token_digest, link.link.scoped_token_digest); + + const revokedOpen = await send(addr, { + type: "open_artifact_download_stream", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + token_digest: link.link.scoped_token_digest, + chunk_bytes: 1, + }); + assert.strictEqual(revokedOpen.type, "error"); + assert.match(revokedOpen.message, /revoked/); + + const gcReport = await send(addr, signedNodeRequest(downloadNode, downloadNodeIdentity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node: downloadNode, + capabilities: downloadNodeCapabilities(), + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: false, + online: true, + })); + assert.strictEqual(gcReport.type, "node_capabilities_recorded"); + + const collectedLink = await send(addr, { + type: "create_artifact_download_link", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + max_bytes: 1024 * 1024, + ttl_seconds: 60, + }); + assert.strictEqual(collectedLink.type, "error"); + assert.match(collectedLink.message, /unavailable from current retention/); + } finally { + worker?.child.kill("SIGTERM"); + coordinator.kill("SIGTERM"); + } + + console.log("Artifact download smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/artifact-export-smoke.js b/scripts/artifact-export-smoke.js new file mode 100644 index 0000000..d90c4f3 --- /dev/null +++ b/scripts/artifact-export-smoke.js @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { nodeIdentity, signedNodeRequest } = require("./node-signing"); +const { + ensureRootlessPodman, + flagshipNodeCapabilities, + launchFlagship, + repo, + runFlagshipWorker, + send, + waitForJsonLine, +} = require("./real-flagship-harness"); + +const sourceNode = "node-export-source"; +const sourceIdentity = nodeIdentity("artifact-export-smoke", sourceNode); + +function nodeCapabilities() { + return flagshipNodeCapabilities(); +} + +function runJson(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = cp.spawn(command, args, { cwd: repo, ...options }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.once("error", reject); + child.once("exit", (code) => { + if (code !== 0) { + reject( + new Error( + `${command} ${args.join(" ")} failed with code ${code}\n${stderr}\n${stdout}` + ) + ); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch (error) { + reject(new Error(`${command} did not return JSON\n${stdout}\n${error.message}`)); + } + }); + }); +} + +async function reportNode( + addr, + node, + identity, + { directConnectivity = true, online = true, artifacts = [] } = {} +) { + const response = await send(addr, signedNodeRequest(node, identity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node, + capabilities: nodeCapabilities(), + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: artifacts, + direct_connectivity: directConnectivity, + online, + })); + assert.strictEqual(response.type, "node_capabilities_recorded"); + assert.strictEqual(response.node, node); +} + +(async () => { + ensureRootlessPodman(); + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback", + ], + { cwd: repo } + ); + + let worker; + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + worker = await runFlagshipWorker(addr, sourceNode, sourceIdentity); + const workerReady = await worker.ready; + assert.strictEqual(workerReady.node_status, "ready"); + assert.strictEqual(workerReady.mode, "worker"); + const firstNodeTaskCompletion = waitForJsonLine(worker.child); + const { compileEvent, process: virtualProcess } = await launchFlagship(addr); + const workerCompletion = await firstNodeTaskCompletion; + assert.strictEqual(workerCompletion.node_status, "completed"); + assert.strictEqual( + workerCompletion.task_assignment_response.task_spec.task_definition, + "prepare_source" + ); + assert.strictEqual( + workerCompletion.virtual_thread, + workerCompletion.task_assignment_response.task_spec.task_instance + ); + assert.strictEqual(compileEvent.status_code, 0); + assert.match( + compileEvent.artifact_path, + /^\/vfs\/artifacts\/hello-disasmer-[0-9a-f]{64}$/ + ); + const artifact = compileEvent.artifact_path.slice("/vfs/artifacts/".length); + + await reportNode(addr, sourceNode, sourceIdentity, { + artifacts: [artifact], + }); + + const receiverIdentity = nodeIdentity("artifact-export-smoke", "node-export-receiver"); + const attachedReceiver = await send(addr, { + type: "attach_node", + tenant: "tenant", + project: "project", + node: "node-export-receiver", + public_key: receiverIdentity.publicKey, + }); + assert.strictEqual(attachedReceiver.type, "node_attached"); + await reportNode(addr, "node-export-receiver", receiverIdentity); + + const exportPlan = await send(addr, { + type: "export_artifact_to_node", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + receiver_node: "node-export-receiver", + direct_connectivity: true, + failure_reason: "", + }); + assert.strictEqual(exportPlan.type, "artifact_export_plan"); + assert.strictEqual(exportPlan.source_node, "node-export-source"); + assert.strictEqual(exportPlan.receiver_node, "node-export-receiver"); + assert.strictEqual(exportPlan.plan.transport, "NativeQuic"); + assert.strictEqual(exportPlan.plan.scope.tenant, "tenant"); + assert.strictEqual(exportPlan.plan.scope.project, "project"); + assert.strictEqual(exportPlan.plan.scope.process, virtualProcess); + assert.deepStrictEqual(exportPlan.plan.scope.object, { Artifact: artifact }); + assert.strictEqual(exportPlan.plan.source.node, "node-export-source"); + assert.strictEqual(exportPlan.plan.destination.node, "node-export-receiver"); + assert.strictEqual(exportPlan.plan.coordinator_assisted_rendezvous, true); + assert.strictEqual(exportPlan.plan.coordinator_bulk_relay_allowed, false); + assert.match(exportPlan.plan.authorization_digest, /^sha256:[0-9a-f]{64}$/); + assert.strictEqual(exportPlan.artifact_size_bytes, compileEvent.artifact_size_bytes); + + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-artifact-export-")); + const exportPath = path.join(temp, "hello-disasmer"); + const cliExport = await runJson("cargo", [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "artifact", + "export", + "--coordinator", + `${addr.host}:${addr.port}`, + "--tenant", + "tenant", + "--project-id", + "project", + "--user", + "user", + "--json", + artifact, + "--receiver-node", + "node-export-receiver", + "--to", + exportPath, + ]); + assert.strictEqual(cliExport.command, "artifact export"); + assert.strictEqual(cliExport.export_plan.local_bytes_written_by_cli, true); + assert.strictEqual(cliExport.export_plan.local_export_status, "local_bytes_written"); + assert.strictEqual( + cliExport.export_plan.bytes_written, + compileEvent.artifact_size_bytes + ); + assert.strictEqual(cliExport.local_export.stream.content_material_returned_in_report, false); + assert.strictEqual( + cliExport.local_export.verified_digest, + compileEvent.artifact_digest + ); + fs.chmodSync(exportPath, 0o755); + assert.strictEqual( + cp.execFileSync(exportPath, { encoding: "utf8" }), + "hello from a real Disasmer build\n" + ); + + const crossTenant = await send(addr, { + type: "export_artifact_to_node", + tenant: "other", + project: "project", + actor_user: "user", + artifact, + receiver_node: "node-export-receiver", + direct_connectivity: true, + failure_reason: "", + }); + assert.strictEqual(crossTenant.type, "error"); + assert.match(crossTenant.message, /tenant mismatch/); + + const failedDirect = await send(addr, { + type: "export_artifact_to_node", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + receiver_node: "node-export-receiver", + direct_connectivity: false, + failure_reason: "nat traversal failed", + }); + assert.strictEqual(failedDirect.type, "error"); + assert.match(failedDirect.message, /nat traversal failed/); + assert.match(failedDirect.message, /coordinator bulk relay is disabled/); + + await reportNode(addr, "node-export-receiver", receiverIdentity, { online: false }); + const offlineReceiver = await send(addr, { + type: "export_artifact_to_node", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact, + receiver_node: "node-export-receiver", + direct_connectivity: true, + failure_reason: "", + }); + assert.strictEqual(offlineReceiver.type, "error"); + assert.match(offlineReceiver.message, /offline/); + } finally { + worker?.child.kill("SIGTERM"); + coordinator.kill("SIGTERM"); + } + + console.log("Artifact export smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/cli-browser-login-flow-smoke.js b/scripts/cli-browser-login-flow-smoke.js new file mode 100644 index 0000000..65f9b35 --- /dev/null +++ b/scripts/cli-browser-login-flow-smoke.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const net = require("net"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const tmp = path.join(repo, "target", "acceptance", "tmp", "cli-browser-login-flow"); +const project = path.join(tmp, "project"); +fs.rmSync(tmp, { recursive: true, force: true }); +fs.mkdirSync(project, { recursive: true }); + +function writeOpener() { + const opener = path.join(tmp, "browser-opener.js"); + const trace = path.join(tmp, "browser-opener.log"); + fs.writeFileSync( + opener, + `#!/usr/bin/env node +const fs = require("fs"); +const trace = ${JSON.stringify(trace)}; +const loginUrl = new URL(process.argv[2]); +const state = loginUrl.searchParams.get("state"); +const nonce = loginUrl.searchParams.get("nonce"); +const challenge = loginUrl.searchParams.get("code_challenge"); +if (loginUrl.protocol !== "https:" || !state || !nonce || !challenge) { + fs.appendFileSync(trace, "missing server-owned OIDC parameters\\n"); + process.exit(1); +} +fs.appendFileSync(trace, "server-owned browser transaction\\n"); +// Model a real browser/opener that remains alive after the CLI transaction. +// Its descriptors must not keep the invoking CLI process open. +setTimeout(() => process.exit(0), 5000); +` + ); + fs.chmodSync(opener, 0o755); + return opener; +} + +function startCoordinator() { + return new Promise((resolve, reject) => { + const requests = []; + const server = net.createServer((socket) => { + let buffered = ""; + socket.on("data", (chunk) => { + buffered += chunk.toString("utf8"); + while (buffered.includes("\n")) { + const newline = buffered.indexOf("\n"); + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + const envelope = JSON.parse(line); + requests.push(envelope); + assert.strictEqual(envelope.type, "coordinator_request"); + assert.strictEqual(envelope.protocol_version, 1); + assert.strictEqual(envelope.authentication.kind, "none"); + const request = envelope.payload; + + if (requests.length === 1) { + assert.strictEqual(envelope.request_id, "cli-1"); + assert.strictEqual(envelope.operation, "begin_oidc_browser_login"); + assert.deepStrictEqual(request, { + type: "begin_oidc_browser_login", + requested_project: "project-smoke", + }); + socket.write( + `${JSON.stringify({ + type: "oidc_browser_login_started", + transaction_id: "login-transaction", + polling_secret: "opaque-polling-secret", + authorization_url: + "https://auth.michelpaulissen.com/application/o/authorize/?state=server-state&nonce=server-nonce&code_challenge=server-pkce&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fdisasmer.michelpaulissen.com%2Fauth%2Fcallback", + expires_at_epoch_seconds: 1800000000, + })}\n` + ); + continue; + } + + assert.strictEqual(envelope.request_id, "cli-2"); + assert.strictEqual(envelope.operation, "poll_oidc_browser_login"); + assert.deepStrictEqual(request, { + type: "poll_oidc_browser_login", + transaction_id: "login-transaction", + polling_secret: "opaque-polling-secret", + }); + socket.end( + `${JSON.stringify({ + type: "oidc_browser_session", + session: { + tenant: "tenant-smoke", + project: "project-smoke", + user: "user-smoke", + cli_session_credential_kind: "CliDeviceSession", + cli_session_secret: "scoped-cli-session-secret", + expires_at_epoch_seconds: 1800000000, + provider_tokens_sent_to_nodes: false, + }, + })}\n` + ); + server.close(); + } + }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve({ + url: `${address.address}:${address.port}`, + requests, + close: () => + new Promise((closeResolve) => { + if (!server.listening) closeResolve(); + else server.close(() => closeResolve()); + }), + }); + }); + }); +} + +function runDisasmer(args, env) { + return new Promise((resolve, reject) => { + const child = cp.spawn( + "cargo", + [ + "run", + "-q", + "--manifest-path", + path.join(repo, "Cargo.toml"), + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + ...args, + ], + { cwd: project, env, stdio: ["ignore", "pipe", "pipe"] } + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk.toString("utf8"))); + child.stderr.on("data", (chunk) => (stderr += chunk.toString("utf8"))); + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`disasmer exited ${code}\n${stderr}\n${stdout}`)); + }); + }); +} + +(async () => { + const opener = writeOpener(); + const coordinator = await startCoordinator(); + try { + const loginStarted = Date.now(); + const report = JSON.parse( + await runDisasmer( + [ + "login", + "--browser", + "--json", + "--coordinator", + coordinator.url, + "--project-id", + "project-smoke", + ], + { + ...process.env, + DISASMER_BROWSER_OPEN_COMMAND: opener, + DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS: "5", + } + ) + ); + assert( + Date.now() - loginStarted < 3000, + "a long-lived browser opener must not keep CLI output pipes or login completion open" + ); + assert.strictEqual(report.plan.coordinator, coordinator.url); + assert.strictEqual(report.boundary.cli_contacted_coordinator, true); + assert.strictEqual(report.boundary.scoped_cli_session_received, true); + assert.strictEqual(report.boundary.local_cli_session_file_written, true); + assert.strictEqual(report.boundary.provider_tokens_persisted_locally, false); + assert.strictEqual(report.boundary.provider_tokens_exposed_to_cli, false); + assert.strictEqual(report.boundary.provider_tokens_sent_to_nodes, false); + assert.strictEqual(report.boundary.coordinator_session_requests, 2); + assert.strictEqual(coordinator.requests.length, 2); + + const sessionFile = path.join(project, ".disasmer", "session.json"); + const sessionText = fs.readFileSync(sessionFile, "utf8"); + const session = JSON.parse(sessionText); + assert.strictEqual(session.kind, "human"); + assert.strictEqual(session.coordinator, coordinator.url); + assert.strictEqual(session.tenant, "tenant-smoke"); + assert.strictEqual(session.project, "project-smoke"); + assert.strictEqual(session.user, "user-smoke"); + assert.strictEqual(session.cli_session_credential_kind, "CliDeviceSession"); + assert.strictEqual(session.token_expiry_posture, "expires_at"); + assert.strictEqual(session.expires_at, "1800000000"); + assert.strictEqual(session.provider_tokens_exposed_to_cli, false); + assert.strictEqual(session.provider_tokens_sent_to_nodes, false); + assert.doesNotMatch( + sessionText, + /access_token|refresh_token|id_token|provider-secret|authorization_code|Bearer/ + ); + + const authStatus = JSON.parse( + await runDisasmer(["auth", "status", "--json"], process.env) + ); + assert.strictEqual(authStatus.active_coordinator, coordinator.url); + assert.strictEqual(authStatus.principal, "user-smoke"); + assert.strictEqual(authStatus.tenant, "tenant-smoke"); + assert.strictEqual(authStatus.project, "project-smoke"); + assert.strictEqual(authStatus.session.kind, "human"); + assert.strictEqual(authStatus.session.source, "session_file"); + assert.strictEqual(authStatus.session.provider_tokens_exposed_to_cli, false); + assert.strictEqual(authStatus.session.provider_tokens_exposed_to_nodes, false); + } finally { + await coordinator.close(); + } + console.log("CLI browser login flow smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/cli-error-exit-smoke.js b/scripts/cli-error-exit-smoke.js new file mode 100755 index 0000000..1d8b733 --- /dev/null +++ b/scripts/cli-error-exit-smoke.js @@ -0,0 +1,365 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const http = require("http"); +const os = require("os"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const project = path.join(repo, "examples/launch-build-demo"); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-error-")); + +function runDisasmer(args) { + return new Promise((resolve) => { + const child = cp.spawn( + "cargo", + ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + { + cwd: repo, + stdio: ["ignore", "pipe", "pipe"], + } + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("close", (code, signal) => { + resolve({ code, signal, stdout, stderr }); + }); + }); +} + +async function runWithOneCoordinatorResponse(buildArgs, response) { + let request = ""; + const server = http.createServer((incoming, outgoing) => { + incoming.setEncoding("utf8"); + incoming.on("data", (chunk) => { + request += chunk; + }); + incoming.on("end", () => { + outgoing.writeHead(200, { "content-type": "application/json" }); + outgoing.end(JSON.stringify(response)); + server.close(); + }); + }); + + const address = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address())); + }); + const coordinator = `http://${address.address}:${address.port}`; + const result = await runDisasmer(buildArgs(coordinator)); + return { request, result }; +} + +async function main() { + const environmentProject = path.join(tempRoot, "missing-env-project"); + fs.mkdirSync(path.join(environmentProject, "src"), { recursive: true }); + fs.writeFileSync( + path.join(environmentProject, "Cargo.toml"), + "[package]\nname = \"missing-env-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n" + ); + fs.writeFileSync( + path.join(environmentProject, "src", "main.rs"), + "fn main() { let _target = env!(\"linux\"); }\n" + ); + const environmentFailure = await runDisasmer([ + "build", + "--project", + environmentProject, + "--json", + ]); + assert.strictEqual(environmentFailure.signal, null, environmentFailure.stderr); + assert.strictEqual(environmentFailure.code, 26, environmentFailure.stderr); + const environmentReport = JSON.parse(environmentFailure.stdout); + assert.strictEqual(environmentReport.status, "blocked_before_schedule"); + assert.strictEqual(environmentReport.scheduled_work, false); + assert.strictEqual(environmentReport.machine_error.category, "environment"); + assert.strictEqual( + environmentReport.machine_error.process_exit_code_applied, + true + ); + assert( + environmentReport.machine_error.next_actions.includes("disasmer inspect") + ); + assert.strictEqual(environmentReport.diagnostics[0].code, "missing_environment"); + + const nonInteractive = await runDisasmer([ + "run", + "build", + "--project", + project, + "--non-interactive", + "--json", + ]); + assert.strictEqual(nonInteractive.signal, null, nonInteractive.stderr); + assert.strictEqual(nonInteractive.code, 20, nonInteractive.stderr); + assert.doesNotMatch(nonInteractive.stderr, /Opening Disasmer browser login/); + const nonInteractiveReport = JSON.parse(nonInteractive.stdout); + assert.strictEqual(nonInteractiveReport.status, "authentication_required"); + assert.strictEqual(nonInteractiveReport.non_interactive, true); + assert.strictEqual(nonInteractiveReport.browser_opened, false); + assert.strictEqual(nonInteractiveReport.machine_error.category, "authentication"); + assert.strictEqual(nonInteractiveReport.machine_error.stable_exit_code, 20); + assert.strictEqual( + nonInteractiveReport.machine_error.process_exit_code_applied, + true + ); + assert( + nonInteractiveReport.machine_error.next_actions.includes( + "pass --local to run against local services" + ) + ); + + let request = ""; + const server = http.createServer((incoming, outgoing) => { + incoming.setEncoding("utf8"); + incoming.on("data", (chunk) => { + request += chunk; + }); + incoming.on("end", () => { + outgoing.writeHead(200, { "content-type": "application/json" }); + outgoing.end( + JSON.stringify({ + type: "error", + message: "quota unavailable: resource limit exceeded for api_calls", + }) + ); + server.close(); + }); + }); + + const address = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address())); + }); + const coordinator = `http://${address.address}:${address.port}`; + + const result = await runDisasmer([ + "run", + "build", + "--project", + project, + "--coordinator", + coordinator, + "--json", + ]); + + assert.strictEqual(result.signal, null, result.stderr); + assert.strictEqual(result.code, 22, result.stderr); + assert.match(request, /"type":"start_process"/); + const report = JSON.parse(result.stdout); + assert.strictEqual(report.status, "coordinator_rejected"); + assert.strictEqual(report.run_start.machine_error.category, "quota"); + assert.strictEqual(report.run_start.machine_error.resource_category, "api_calls"); + assert.strictEqual(report.run_start.machine_error.community_tier_language, true); + assert.strictEqual( + report.run_start.machine_error.community_tier_label, + "community tier" + ); + assert.doesNotMatch(result.stdout, new RegExp(["free", "tier"].join(" "), "i")); + assert.strictEqual( + report.run_start.machine_error.private_abuse_heuristics_exposed, + false + ); + assert.strictEqual(report.run_start.machine_error.stable_exit_code, 22); + assert.strictEqual( + report.run_start.machine_error.process_exit_code_applied, + true + ); + assert( + report.run_start.machine_error.next_actions.includes("disasmer quota status") + ); + + const capabilityFailure = await runWithOneCoordinatorResponse( + (coordinator) => [ + "run", + "build", + "--project", + project, + "--coordinator", + coordinator, + "--json", + ], + { + type: "error", + message: + "scheduler placement failed: no capable node for placement: missing capability Command", + } + ); + assert.strictEqual(capabilityFailure.result.signal, null, capabilityFailure.result.stderr); + assert.strictEqual(capabilityFailure.result.code, 24, capabilityFailure.result.stderr); + assert.match(capabilityFailure.request, /"type":"start_process"/); + const capabilityReport = JSON.parse(capabilityFailure.result.stdout); + assert.strictEqual( + capabilityReport.run_start.machine_error.category, + "capability" + ); + assert.strictEqual( + capabilityReport.run_start.machine_error.process_exit_code_applied, + true + ); + assert( + capabilityReport.run_start.machine_error.next_actions.includes( + "attach a node with the required capabilities" + ) + ); + + const nodePolicyFailure = await runWithOneCoordinatorResponse( + (coordinator) => [ + "run", + "build", + "--project", + project, + "--coordinator", + coordinator, + "--json", + ], + { + type: "error", + message: "node policy denied native command execution", + } + ); + assert.strictEqual(nodePolicyFailure.result.signal, null, nodePolicyFailure.result.stderr); + assert.strictEqual(nodePolicyFailure.result.code, 23, nodePolicyFailure.result.stderr); + assert.match(nodePolicyFailure.request, /"type":"start_process"/); + const nodePolicyReport = JSON.parse(nodePolicyFailure.result.stdout); + assert.strictEqual( + nodePolicyReport.run_start.machine_error.category, + "policy" + ); + assert.strictEqual( + nodePolicyReport.run_start.machine_error.process_exit_code_applied, + true + ); + assert( + nodePolicyReport.run_start.machine_error.next_actions.includes( + "check coordinator policy for this action" + ) + ); + + const programFailure = await runWithOneCoordinatorResponse( + (coordinator) => [ + "task", + "list", + "--coordinator", + coordinator, + "--json", + ], + { + type: "task_events", + events: [ + { + process: "vp-current", + task: "compile", + terminal_state: "failed", + environment: "linux", + node: "node-linux", + status_code: 1, + stderr_tail: "task exited with status 1", + }, + ], + } + ); + assert.strictEqual(programFailure.result.signal, null, programFailure.result.stderr); + assert.strictEqual(programFailure.result.code, 0, programFailure.result.stderr); + assert.match(programFailure.request, /"type":"list_task_events"/); + const programReport = JSON.parse(programFailure.result.stdout); + assert.strictEqual(programReport.tasks[0].machine_error.category, "program"); + assert.strictEqual(programReport.tasks[0].machine_error.stable_exit_code, 27); + assert( + programReport.tasks[0].machine_error.next_actions.includes("disasmer logs") + ); + + const artifactDownload = await runWithOneCoordinatorResponse( + (coordinator) => [ + "artifact", + "download", + "app.txt", + "--coordinator", + coordinator, + "--json", + ], + { + type: "artifact_download_denied", + message: "artifact download unauthorized for project", + } + ); + assert.strictEqual(artifactDownload.result.signal, null, artifactDownload.result.stderr); + assert.strictEqual(artifactDownload.result.code, 21, artifactDownload.result.stderr); + assert.match(artifactDownload.request, /"type":"create_artifact_download_link"/); + const artifactDownloadReport = JSON.parse(artifactDownload.result.stdout); + assert.strictEqual( + artifactDownloadReport.download_session.machine_error.category, + "authorization" + ); + assert.strictEqual( + artifactDownloadReport.download_session.machine_error.process_exit_code_applied, + true + ); + + const artifactExport = await runWithOneCoordinatorResponse( + (coordinator) => [ + "artifact", + "export", + "app.txt", + "--to", + path.join(project, "target", "blocked-artifact.txt"), + "--coordinator", + coordinator, + "--json", + ], + { + type: "artifact_export_unavailable", + message: "direct connectivity unavailable for artifact export", + } + ); + assert.strictEqual(artifactExport.result.signal, null, artifactExport.result.stderr); + assert.strictEqual(artifactExport.result.code, 25, artifactExport.result.stderr); + assert.match(artifactExport.request, /"type":"export_artifact_to_node"/); + const artifactExportReport = JSON.parse(artifactExport.result.stdout); + assert.strictEqual( + artifactExportReport.export_plan.machine_error.category, + "connectivity" + ); + assert.strictEqual( + artifactExportReport.export_plan.machine_error.process_exit_code_applied, + true + ); + + const confirmation = await runDisasmer([ + "process", + "cancel", + "--coordinator", + "127.0.0.1:9", + "--json", + ]); + assert.strictEqual(confirmation.signal, null, confirmation.stderr); + assert.strictEqual(confirmation.code, 23, confirmation.stderr); + const confirmationReport = JSON.parse(confirmation.stdout); + assert.strictEqual(confirmationReport.status, "confirmation_required"); + assert.strictEqual(confirmationReport.coordinator_request_sent, false); + assert.strictEqual(confirmationReport.machine_error.category, "policy"); + assert.strictEqual( + confirmationReport.machine_error.process_exit_code_applied, + true + ); + assert( + confirmationReport.next_actions.some((action) => action.includes("--yes")) + ); +} + +main() + .then(() => { + console.log("CLI error exit smoke passed"); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js new file mode 100644 index 0000000..eaf92fa --- /dev/null +++ b/scripts/cli-first-contract-smoke.js @@ -0,0 +1,773 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + const fullPath = path.join(repo, relativePath); + if (!fs.existsSync(fullPath)) { + if (fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json"))) { + console.log( + `CLI-first contract smoke skipped: ${relativePath} is filtered from this public tree` + ); + process.exit(0); + } + throw new Error(`${relativePath} is missing`); + } + return fs.readFileSync(fullPath, "utf8"); +} + +function readRustTree(relativePath) { + const root = path.join(repo, relativePath); + const sourcePaths = []; + + function visit(directory) { + for (const entry of fs + .readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name))) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".rs")) { + sourcePaths.push(fullPath); + } + } + } + + visit(root); + const priority = (filePath) => { + const name = path.basename(filePath); + if (name === "main.rs" || name === "service.rs") return 0; + if (name === "lib.rs") return 1; + if (name === "protocol.rs") return 2; + if (name === "tests.rs") return 100; + return 10; + }; + sourcePaths.sort( + (left, right) => + priority(left) - priority(right) || left.localeCompare(right) + ); + return sourcePaths + .map((filePath) => fs.readFileSync(filePath, "utf8")) + .join("\n"); +} + +function criterionLines(source) { + return source + .split(/\r?\n/) + .filter((line) => /^- \[[ x]\] \*\*/.test(line)); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing CLI-first contract evidence: ${name}`); +} + +const criteria = read("cli_acceptance_criteria.md"); +// Phase 3 deliberately split the former CLI and coordinator mega-files into +// focused modules. Contract evidence therefore follows the complete source +// trees instead of assuming every implementation and test still lives in one +// entrypoint file. +const cli = readRustTree("crates/disasmer-cli/src"); +const coordinator = readRustTree("crates/disasmer-coordinator/src"); +const coordinatorLib = read("crates/disasmer-coordinator/src/lib.rs"); +const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); +const outputModeSmoke = read("scripts/cli-output-mode-smoke.js"); +const errorExitSmoke = read("scripts/cli-error-exit-smoke.js"); +const browserLoginFlowSmoke = read("scripts/cli-browser-login-flow-smoke.js"); +const nodeAttachSmoke = read("scripts/node-attach-smoke.js"); +const dapSmoke = read("scripts/dap-smoke.js"); +const cliHappyPathSmoke = read("scripts/cli-happy-path-live-smoke.js"); + +expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); +expect( + criteria, + "addendum status", + /\*\*Status:\*\* CLI-first addendum to `acceptance_criteria\.md` and `acceptance_criteria_phase2\.md`/ +); +expect( + criteria, + "website exception", + /hosted account creation as the only intentional private website exception/ +); +expect( + criteria, + "no duplicate work note", + /does not automatically mean new product code, a new feature, or even actual implementation work is required/ +); +expect( + criteria, + "future hosted business non-goal", + /billing, paid-plan checkout, team\/org management, provider setup wizards, secret-manager UI, full support tooling, broad moderation consoles, and durable account\/business-process management are intentionally outside this CLI-first MVP slice/ +); +expect( + criteria, + "billing plan flags are placeholders", + /Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP CLI commands, coordinator routes, schemas, migrations, website controls, or service logic/ +); +expect( + criteria, + "hosted signup remains private Authentik-backed flow", + /Hosted account creation is available only through the private hosted identity flow, backed by Authentik[\s\S]*first-login account\/project creation stays inside/ +); +expect( + criteria, + "hosted signup provider and password policy criteria", + /approved external identity-provider requirement[\s\S]*disasmer_native_password_signup_allowed: false/ +); +expect( + criteria, + "hosted provider normalization criteria", + /Google sign-in may be treated as OIDC; GitHub sign-in is OAuth2 social login[\s\S]*normalizes Google as `google_oidc` and GitHub as `github_oauth2` from an Authentik-issued OIDC session[\s\S]*does not consume provider-specific tokens directly/ +); +expect( + criteria, + "hosted CLI cannot create accounts directly", + /The CLI cannot create a hosted account directly[\s\S]*parser coverage rejects `disasmer signup`, `disasmer account create`, and `disasmer login --create-account`/ +); +expect( + criteria, + "hosted safe claim failure criteria", + /If identity is ambiguous, missing required claims, unverified where required, or blocked by policy[\s\S]*exactly one approved external identity provider[\s\S]*stable subject[\s\S]*verified email/ +); +expect( + criteria, + "hosted signup sanitized failure criteria", + /Signup failures do not disclose moderation, abuse-scoring, or allow\/deny internals[\s\S]*stripping issuer, allowlist, abuse-score, moderation-note, and provider-secret details/ +); +expect( + criteria, + "hosted account state privacy criteria", + /Account suspension, deletion, manual review, and abuse handling are private hosted-admin concerns[\s\S]*public CLI displays clear suspended, disabled, deleted, and manual-review status[\s\S]*strips private moderation\/signup details/ +); + +const lines = criterionLines(criteria); +assert(lines.length > 0, "CLI-first criteria must contain criteria lines"); +for (const line of lines) { + assert.match( + line, + /^- \[[ x]\] \*\*(Passed|Partial|Open|Postponed)(?: \([^)]+\))?:\*\*/, + `CLI-first criterion lacks an explicit status prefix: ${line}` + ); +} + +const openCriteria = lines.filter((line) => /\*\*Open(?::| \()/.test(line)); +assert.deepStrictEqual( + openCriteria, + [], + "CLI-first criteria should not leave Open items once the non-e2e gate exists; remaining unfinished facts stay Partial" +); + +expect( + criteria, + "CLI-first non-e2e gate wording", + /scripts\/acceptance-cli-first\.sh[\s\S]*scripts\/cli-happy-path-live-smoke\.js[\s\S]*live hosted coordinator/ +); +expect( + cliFirstAcceptance, + "CLI-first gate can run live happy path when explicitly enabled", + /DISASMER_CLI_HAPPY_PATH_LIVE[\s\S]*cli-happy-path-live-smoke\.js/ +); +for (const [name, pattern] of [ + ["happy path uses released binaries", /disasmer-public-binaries-/], + ["happy path completes server-owned browser login", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND[\s\S]*DISASMER_BROWSER_OPEN_COMMAND/], + ["happy path initializes project", /"project"[\s\S]*"init"/], + ["happy path inspects project", /"inspect"[\s\S]*"--project"/], + ["happy path enrolls node", /"node"[\s\S]*"enroll"/], + ["happy path attaches node", /"node"[\s\S]*"attach"/], + ["happy path starts worker", /--worker/], + ["happy path runs build", /"run"[\s\S]*"build"/], + ["happy path checks logs", /"logs"/], + ["happy path checks artifacts", /"artifact"[\s\S]*"list"/], + ["happy path downloads artifact", /"artifact"[\s\S]*"download"/], + ["happy path restarts process", /"process"[\s\S]*"restart"/], + ["happy path cancels process", /"process"[\s\S]*"cancel"/], + ["happy path writes evidence", /cli-happy-path-live\.json/], +]) { + expect(cliHappyPathSmoke, name, pattern); +} +expect( + criteria, + "artifact export explicit local byte write", + /artifact export --to ` writes bytes[\s\S]*explicit bounded download stream[\s\S]*complete staged content is available/ +); +expect( + criteria, + "artifact download grant disclosure criteria", + /artifact download ` creates a secure[\s\S]*explicit artifact-download grant disclosure[\s\S]*Download links or sessions are not guessable public URLs[\s\S]*guessable_public_url: false[\s\S]*cross-tenant no-reuse[\s\S]*unauthorized-project no-reuse[\s\S]*Every command that grants[\s\S]*artifact download ability[\s\S]*grant_disclosures/ +); +expect( + criteria, + "locality failure safe guidance", + /If direct transfer or locality assumptions fail[\s\S]*connectivity-category safe failures[\s\S]*coordinator bulk relay was not used/ +); +expect( + criteria, + "mutating commands require confirmation", + /Mutating or dangerous commands support `--yes`[\s\S]*confirmation-required safe failure[\s\S]*do not send coordinator requests/ +); +expect( + criteria, + "log redaction criteria", + /Logs are capped, truncated honestly[\s\S]*preserve byte counts and truncation flags[\s\S]*Secret-like values are redacted[\s\S]*common token\/password\/bearer patterns/ +); +expect( + criteria, + "doctor node readiness criteria", + /`disasmer doctor` reports missing local dependencies[\s\S]*explicit node readiness summary[\s\S]*missing local dependencies[\s\S]*node next actions/ +); +expect( + criteria, + "admin bootstrap self-hosted sequence criteria", + /admin bootstrap now reports a CLI-only self-hosted sequence[\s\S]*node enrollment\/attach[\s\S]*quota status[\s\S]*node revoke/ +); +expect( + criteria, + "quota resource-category criteria", + /Hitting a quota produces a clear error[\s\S]*resource category[\s\S]*private abuse heuristics[\s\S]*quota machine errors now extract the resource category/ +); +expect( + criteria, + "quota before work criteria", + /Quotas are checked before expensive work starts[\s\S]*workflow spawns now charge `Spawn` before coordinator-side process\/task state is created or queued[\s\S]*debug, artifact-download, and rendezvous metering/ +); +expect( + criteria, + "community tier CLI wording criteria", + /Community tier language is used instead of "free[ -]tier" in user-facing CLI output/ +); +expect( + criteria, + "DAP launch-critical surface criteria", + /launch-critical DAP surface required by the MVP[\s\S]*`initialize`[\s\S]*`source`[\s\S]*`next`\/step-over[\s\S]*`restartFrame`/ +); +expect( + criteria, + "DAP variables experience criteria", + /normal debugger variables path exposes the MVP debugging experience[\s\S]*selected source locals around Disasmer API calls[\s\S]*runtime-captured Wasmtime frame locals/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance report", + /node scripts\/acceptance-report\.js cli-first/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance refuses final e2e", + /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E[\s\S]*does not run final public-release e2e/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance refuses final evidence", + /DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL[\s\S]*does not run final public-release evidence/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance composes public API contracts", + /node scripts\/cli-output-mode-smoke\.js[\s\S]*node scripts\/cli-login-smoke\.js[\s\S]*node scripts\/cli-error-exit-smoke\.js[\s\S]*node scripts\/cli-browser-login-flow-smoke\.js/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance composes service boundary checks", + /node scripts\/wasmtime-assignment-smoke\.js[\s\S]*node scripts\/cli-local-run-smoke\.js/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance composes self-hosted checks", + /node scripts\/self-hosted-coordinator-smoke\.js/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance composes debug and artifact checks", + /node scripts\/vscode-f5-smoke\.js[\s\S]*node scripts\/artifact-download-smoke\.js[\s\S]*node scripts\/artifact-export-smoke\.js/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance composes DAP checks", + /node scripts\/dap-smoke\.js/ +); +expect( + cliFirstAcceptance, + "CLI-first acceptance can include private hosted gate", + /DISASMER_CLI_FIRST_INCLUDE_PRIVATE[\s\S]*scripts\/acceptance-private\.sh/ +); +assert.doesNotMatch( + cliFirstAcceptance, + /node scripts\/public-release-dryrun-e2e\.js|node scripts\/public-release-dryrun-final-evidence\.js/, + "CLI-first non-e2e gate must not invoke final public release e2e or final evidence verifier" +); + +for (const [name, pattern] of [ + ["top-level version metadata", /#\[command\([\s\S]*name = "disasmer"[\s\S]*version[\s\S]*arg_required_else_help = true[\s\S]*\)\]/], + ["top-level primary workflow help", /after_help = "Primary workflow:[\s\S]*disasmer login --browser[\s\S]*disasmer node attach; disasmer-node --worker[\s\S]*Disasmer: Launch Virtual Process[\s\S]*Hosted account creation happens in the browser login flow\."/], + ["top-level logout command", /enum Commands[\s\S]*Logout\(AuthLogoutArgs\)[\s\S]*Auth \{/], + ["login non-interactive flag", /struct LoginArgs[\s\S]*non_interactive: bool/], + ["run non-interactive flag", /struct RunArgs[\s\S]*non_interactive: bool/], + ["stored CLI session model", /struct StoredCliSession[\s\S]*cli_session_credential_kind[\s\S]*provider_tokens_exposed_to_cli[\s\S]*provider_tokens_sent_to_nodes/], + ["read CLI session helper", /fn read_cli_session\(project: &Path\) -> Result>/], + ["write CLI session helper", /fn write_cli_session\(project: &Path, session: &StoredCliSession\) -> Result/], + ["session source fallback", /fn session_from_sources\(project: &Path\) -> Result[\s\S]*read_cli_session\(project\)\?\.is_some\(\)/], + ["browser login writes local CLI session", /local_cli_session_file_written[\s\S]*write_cli_session\(&cwd, &stored_session\)\?/], + ["browser login does not persist provider tokens", /provider_tokens_persisted_locally:\s*false/], + ["non-interactive auth report", /fn non_interactive_auth_machine_error[\s\S]*browser_opened[\s\S]*false/], + ["human report renderer", /fn human_report\(value: &Value\) -> String/], + ["shared report emitter", /fn emit_report\(report: &T, json_output: bool\) -> Result<\(\)>/], + ["doctor command", /Doctor\(DoctorArgs\)/], + ["doctor node readiness summary", /fn node_readiness_summary[\s\S]*ready_to_attach[\s\S]*explicit_attach_required[\s\S]*missing_local_dependencies[\s\S]*next_actions/], + ["auth status command", /enum AuthCommands[\s\S]*Status\(AuthStatusArgs\)/], + ["auth logout command", /enum AuthCommands[\s\S]*Logout\(AuthLogoutArgs\)/], + ["key lifecycle commands", /enum KeyCommands[\s\S]*Add\(KeyAddArgs\)[\s\S]*List\(KeyListArgs\)[\s\S]*Revoke\(KeyRevokeArgs\)/], + ["project commands", /enum ProjectCommands[\s\S]*Init\(ProjectInitArgs\)[\s\S]*Status\(ProjectStatusArgs\)[\s\S]*List\(ProjectListArgs\)[\s\S]*Select\(ProjectSelectArgs\)/], + ["inspect command", /Inspect\(BundleInspectArgs\)/], + ["build command", /Build\(BuildArgs\)/], + ["node lifecycle commands", /enum NodeCommands[\s\S]*Attach\(AttachArgs\)[\s\S]*Enroll\(NodeEnrollArgs\)[\s\S]*List\(NodeListArgs\)[\s\S]*Status\(NodeStatusArgs\)[\s\S]*Revoke\(NodeRevokeArgs\)/], + ["process commands", /enum ProcessCommands[\s\S]*Status\(ProcessStatusArgs\)[\s\S]*Restart\(ProcessRestartArgs\)[\s\S]*Cancel\(ProcessCancelArgs\)/], + ["task lifecycle commands", /enum TaskCommands[\s\S]*List\(TaskListArgs\)[\s\S]*Restart\(TaskRestartArgs\)/], + ["logs command", /Logs\(LogsArgs\)/], + ["artifact commands", /enum ArtifactCommands[\s\S]*List\(ArtifactListArgs\)[\s\S]*Download\(ArtifactDownloadArgs\)[\s\S]*Export\(ArtifactExportArgs\)/], + ["DAP command", /Dap\(DapArgs\)/], + ["debug attach command", /enum DebugCommands[\s\S]*Attach\(DebugAttachArgs\)/], + ["quota command", /enum QuotaCommands[\s\S]*Status\(QuotaStatusArgs\)/], + ["admin commands", /enum AdminCommands[\s\S]*Status\(AdminStatusArgs\)[\s\S]*Bootstrap\(AdminBootstrapArgs\)[\s\S]*RevokeNode\(NodeRevokeArgs\)[\s\S]*StopProcess\(ProcessCancelArgs\)[\s\S]*SuspendTenant\(AdminSuspendTenantArgs\)/], +]) { + expect(cli, name, pattern); +} + +for (const [name, pattern] of [ + ["CLI parse coverage", /fn cli_first_mvp_command_surface_parses\(\)/], + ["CLI primary workflow help coverage", /fn top_level_help_exposes_primary_workflow_without_auth\(\)/], + ["CLI non-interactive run auth coverage", /fn non_interactive_run_without_session_requires_explicit_auth_or_local\(\)/], + ["CLI non-interactive browser login coverage", /fn browser_login_non_interactive_fails_before_opening_browser\(\)/], + ["CLI stored browser session coverage", /fn stored_browser_login_session_omits_provider_token_values\(\)/], + ["CLI auth status session-file coverage", /fn auth_status_reads_stored_cli_session_without_provider_tokens\(\)/], + ["CLI auth status account privacy coverage", /fn auth_status_queries_coordinator_account_state_without_private_moderation_details\(\)/], + ["CLI version coverage", /fn top_level_version_is_available\(\)/], + ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], + ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], + ["CLI key lifecycle coverage", /fn key_lifecycle_reports_project_scoped_agent_credentials\(\)/], + ["CLI agent workflow actor coverage", /fn run_with_agent_public_key_sends_attributable_workflow_actor\(\)/], + ["CLI node revoke coverage", /fn node_revoke_reports_scoped_credential_revocation\(\)/], + ["CLI admin public API coverage", /fn admin_status_and_suspend_use_public_coordinator_api\(\)/], + ["CLI admin bootstrap coverage", /fn admin_bootstrap_reports_self_hosted_cli_only_path\(\)/], + ["CLI debug attach coverage", /fn debug_attach_reports_public_authorization\(\)/], + ["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/], + ["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/], + ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], + ["project init public create coverage", /fn project_init_uses_public_create_before_writing_local_config\(\)/], + ["project public API list/select coverage", /fn project_list_and_select_use_public_api_without_website\(\)/], + ["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/], + ["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/], + ["CLI error classifier coverage", /fn cli_error_classifier_distinguishes_mvp_failure_categories\(\)/], + ["CLI command exit-code coverage", /fn command_report_exit_code_marks_command_failures_only\(\)/], + ["CLI top-level logout alias coverage", /fn top_level_logout_alias_removes_only_cli_session_state\(\)/], + ["CLI confirmation gate helper", /fn confirmation_required_report[\s\S]*coordinator_request_sent[\s\S]*confirmation_required/], + ["CLI mutating confirmation coverage", /fn mutating_commands_require_yes_before_side_effects\(\)/], + ["CLI run rejection category coverage", /fn run_rejection_reports_machine_readable_error_category\(\)/], + ["CLI locality failure classifier", /fn classify_cli_error_message\(message: &str\)[\s\S]*message_mentions_locality_failure\(&message\)[\s\S]*return "connectivity"/], + ["CLI locality failure report helper", /fn task_locality_failure_from_reason\(reason: &Value\) -> Value[\s\S]*coordinator_bulk_relay_used[\s\S]*safe_next_actions/], + ["CLI locality failure human output", /fn push_task_locality_failures\(lines: &mut Vec, tasks: &\[Value\]\)[\s\S]*locality \{task_name\}/], + ["node attach auto-detection coverage", /fn node_attach_detects_and_accepts_capability_overrides\(\)[\s\S]*detection\.auto_detected[\s\S]*command_backend[\s\S]*source_provider_backends/], + ["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/], + ["node enroll public API grant coverage", /fn node_enroll_reports_short_lived_public_api_grant\(\)/], + ["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/], + ["quota coordinator usage coverage", /fn quota_status_queries_public_coordinator_usage\(\)/], + ["task event summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)/], + ["task locality failure summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)[\s\S]*source snapshot unavailable and direct connectivity unavailable[\s\S]*locality_failure/], + ["log secret redaction coverage", /fn log_and_task_reports_redact_secret_like_values\(\)/], + ["artifact download/export report coverage", /fn artifact_download_and_export_reports_expose_safe_session_boundaries\(\)/], + ["process control report coverage", /fn process_restart_cancel_and_abort_reports_expose_control_boundaries\(\)/], + ["task restart report coverage", /fn task_restart_reports_clean_boundary_requirements\(\)/], + ["build no full repo upload coverage", /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)/], + ["bundle rebuild restart compatibility coverage", /fn bundle_rebuild_after_source_edit_keeps_restart_compatibility_contract\(\)/], + ["inspect missing environment coverage", /fn bundle_inspect_reports_missing_environment_references_before_schedule\(\)/], + ["inspect source provider override coverage", /fn bundle_inspect_reports_source_provider_overrides_before_schedule\(\)/], + ["build missing environment gate coverage", /fn build_blocks_before_schedule_on_missing_environment_reference\(\)/], + ["safe coordinator-required plans", /fn node_enroll_and_process_commands_have_safe_plan_without_coordinator\(\)/], +]) { + expect(cli, name, pattern); +} + +expect( + browserLoginFlowSmoke, + "browser login smoke checks session file", + /provider_tokens_persisted_locally[\s\S]*path\.join\(project, "\.disasmer", "session\.json"\)[\s\S]*\["auth", "status", "--json"\]/ +); +expect( + browserLoginFlowSmoke, + "browser login smoke rejects provider token persistence", + /assert\.doesNotMatch\([\s\S]*access_token\|refresh_token\|id_token/ +); +expect( + nodeAttachSmoke, + "node attach smoke verifies auto-detection evidence", + /plan\.detection\.auto_detected[\s\S]*plan\.detection\.command_backend[\s\S]*source_provider_backends/ +); +expect( + nodeAttachSmoke, + "node attach smoke verifies policy-limited grant disclosures", + /grant_disclosures\.length > 0[\s\S]*coordinator_policy_limited === true[\s\S]*native_command_execution[\s\S]*source_access/ +); +for (const [name, pattern] of [ + ["DAP smoke verifies source request", /client\.send\("source"[\s\S]*assert\.match\(source\.content, \/build_main\//], + ["DAP smoke rejects synthetic source stepping", /client\.send\("next"[\s\S]*client\.failure\(step, "next"\)[\s\S]*source stepping is not yet available[\s\S]*synthetic step/], + ["DAP smoke verifies DAP variables scopes", /Source Locals[\s\S]*Wasm Frame Locals[\s\S]*Task Args and Handles[\s\S]*Disasmer Runtime/], + ["DAP smoke reports unavailable source locals truthfully", /unavailable-local-diagnostic[\s\S]*cannot be inspected/], + ["DAP smoke reports unavailable Wasm frame locals truthfully", /wasm-local-diagnostic[\s\S]*did not report inspectable Wasm frame locals/], + ["DAP smoke verifies real local-services all-stop", /runtimeBackend: "local-services"[\s\S]*allThreadsStopped, true[\s\S]*confirmed by every active participant/], + ["DAP smoke verifies coordinator checkpoint refusal", /client\.send\("restartFrame"[\s\S]*checkpoint boundary\|still active[\s\S]*whole virtual-process restart/], +]) { + expect(dapSmoke, name, pattern); +} + +expect( + coordinator, + "coordinator whole-process cancellation coverage", + /fn service_cancels_whole_process_and_blocks_new_task_launches\(\)/ +); +expect( + coordinator, + "coordinator single active process coverage", + /fn service_rejects_second_active_process_unless_restarting_same_process\(\)/ +); +expect( + coordinator, + "coordinator agent key lifecycle coverage", + /fn service_manages_project_scoped_agent_public_keys\(\)/ +); +expect( + coordinator, + "coordinator agent workflow dispatch coverage", + /fn service_runs_agent_workflows_with_scoped_key_attribution\(\)/ +); +expect( + coordinator, + "coordinator agent workflow authorization", + /authorize_agent_project_run\([\s\S]*project:run/ +); +expect( + coordinator, + "coordinator agent workflow actor fields", + /pub struct WorkflowActor[\s\S]*agent: Option[\s\S]*public_key_fingerprint: Option[\s\S]*authenticated_without_browser/ +); +expect( + coordinator, + "coordinator node revoke coverage", + /fn service_revokes_node_credentials_and_live_descriptors\(\)/ +); +expect( + coordinator, + "coordinator public admin suspension coverage", + /fn service_reports_and_enforces_public_admin_tenant_suspension\(\)/ +); +expect( + coordinator, + "coordinator debug attach coverage", + /fn service_authorizes_debug_attach_through_public_api\(\)/ +); +expect( + coordinator, + "coordinator task restart boundary coverage", + /fn service_reports_task_restart_boundary_through_public_api\(\)/ +); +for (const [name, pattern] of [ + ["task completion placement field", /pub struct TaskCompletionEvent[\s\S]*placement: Option/], + ["task placement state", /task_placements/], + ["task completion retains placement", /event\.placement = self\.task_placements\.remove/], +]) { + expect(coordinator, `coordinator task events retain placement reasons: ${name}`, pattern); +} +expect( + coordinator, + "coordinator workflow spawn metering", + /struct MeterKey[\s\S]*kind: LimitKind[\s\S]*window: u64[\s\S]*fn can_charge_workflow_spawn[\s\S]*self\.can_charge\(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds\)[\s\S]*fn charge_workflow_spawn[\s\S]*self\.charge\(tenant, project, LimitKind::Spawn, 1, now_epoch_seconds\)[\s\S]*charged_spawns/ +); +expect( + coordinator, + "coordinator spawn quota before work coverage", + /fn service_checks_spawn_quota_before_process_or_task_work_starts\(\)[\s\S]*quota\.set_workflow_limits\(ResourceLimits[\s\S]*LimitKind::Spawn[\s\S]*compile-linux-denied[\s\S]*active_tasks\.contains[\s\S]*other-project[\s\S]*fn project_quota_resets_at_the_configured_window_boundary\(\)[\s\S]*set_server_time\(59\)[\s\S]*set_server_time\(60\)/ +); +expect( + coordinator, + "public debug operation audit event", + /pub struct DebugAuditEvent[\s\S]*charged_debug_read_bytes[\s\S]*used_debug_read_bytes/ +); +expect( + coordinator, + "public debug operation metering", + /record_debug_audit_event\([\s\S]*charge_debug_read\([\s\S]*&tenant[\s\S]*&project[\s\S]*DEBUG_CONTROL_READ_BYTES[\s\S]*used_debug_read_bytes\(&tenant, &project, now_epoch_seconds\)/ +); +expect( + cli, + "CLI surfaces debug audit and quota fields", + /debug_reads_quota_limited[\s\S]*charged_debug_read_bytes[\s\S]*used_debug_read_bytes/ +); +expect( + cli, + "CLI sends agent workflow actor fields", + /fn add_workflow_actor_fields[\s\S]*actor_agent[\s\S]*agent_public_key_fingerprint/ +); +expect( + cli, + "CLI exposes node attach detection evidence", + /struct NodeAttachDetectionEvidence[\s\S]*command_backend[\s\S]*container_backend[\s\S]*source_provider_backends[\s\S]*manual_capability_overrides[\s\S]*os_arch_capabilities_require_manual_flags/ +); +expect( + cli, + "CLI renders node attach detection evidence", + /fn push_node_attach_detection[\s\S]*command backend[\s\S]*container backend[\s\S]*source providers[\s\S]*capability overrides/ +); +expect( + cli, + "CLI exposes node attach grant disclosures", + /struct CapabilityGrantDisclosure[\s\S]*coordinator_policy_limited[\s\S]*fn capability_grant_disclosures/ +); +expect( + cli, + "CLI exposes artifact download grant disclosures", + /fn artifact_download_grant_disclosures[\s\S]*"grant": "artifact_download"[\s\S]*"coordinator_policy_limited": true[\s\S]*"authorization_required": true[\s\S]*"guessable_public_url": false[\s\S]*"cross_tenant_reuse_allowed": false[\s\S]*"unauthorized_project_reuse_allowed": false/ +); +expect( + cli, + "CLI exposes normalized node enrollment grants", + /fn node_enroll_report[\s\S]*create_node_enrollment_grant[\s\S]*enrollment_grant[\s\S]*private_website_required[\s\S]*fn node_enrollment_grant_summary[\s\S]*short_lived[\s\S]*node_credentials_separate_from_user_session/ +); +expect( + cli, + "CLI exposes task placement reasons", + /fn task_summaries[\s\S]*node_placement[\s\S]*reasons[\s\S]*explanation_available/ +); +expect( + cli, + "CLI project init report current-directory link", + /fn project_init_report[\s\S]*current_directory_link[\s\S]*links_current_directory[\s\S]*safe_defaults[\s\S]*coordinator_create_before_local_write/ +); +expect( + cli, + "CLI project init writes config after public create", + /let coordinator_response = if let Some\(coordinator\)[\s\S]*"type": "create_project"[\s\S]*coordinator_session_requests = session\.requests\(\)[\s\S]*write_project_config\(&cwd, &config\)\?/ +); +expect( + cli, + "CLI project init renders current-directory link", + /current_directory_link[\s\S]*current directory linked: true[\s\S]*current directory config/ +); +expect( + cli, + "CLI project list/select report public API boundary", + /fn project_list_report[\s\S]*public_coordinator_api[\s\S]*private_website_required[\s\S]*fn project_select_report[\s\S]*project_config_written[\s\S]*private_website_required/ +); +expect( + cli, + "CLI project select writes config after coordinator response", + /fn project_select_report[\s\S]*let coordinator_response = if let Some\(coordinator\)[\s\S]*let request = authenticated_or_local_trusted_request\([\s\S]*"type": "select_project"[\s\S]*Some\(session\.request\(request\)\?\)[\s\S]*write_project_config\(&cwd, &config\)\?/ +); +expect( + cli, + "CLI renders task placement reasons", + /fn push_task_placement_reasons[\s\S]*placement \{task_name\}: \{node\}/ +); +expect( + cli, + "CLI redacts secret-like log values", + /fn log_entries[\s\S]*redact_secret_like_text[\s\S]*secret_like_values_redacted[\s\S]*redacted_fields[\s\S]*fn redact_secret_like_text[\s\S]*access_token=[\s\S]*password=[\s\S]*bearer / +); +expect( + cli, + "CLI classifies machine-readable error categories", + /fn classify_cli_error_message[\s\S]*"authentication"[\s\S]*"authorization"[\s\S]*"quota"[\s\S]*"policy"[\s\S]*"capability"[\s\S]*"connectivity"[\s\S]*"environment"[\s\S]*"program"/ +); +expect( + cli, + "CLI exposes quota machine-error posture", + /fn cli_error_summary_for_category[\s\S]*resource_category[\s\S]*quota_error_resource_category[\s\S]*community_tier_language[\s\S]*community_tier_label[\s\S]*private_abuse_heuristics_exposed[\s\S]*fn quota_error_resource_category[\s\S]*resource limit exceeded for / +); +expect( + coordinator, + "coordinator exposes sanitized auth status API", + /AuthStatus \{[\s\S]*tenant: String[\s\S]*project: String[\s\S]*actor_user: String[\s\S]*AuthStatus \{[\s\S]*account_status: String[\s\S]*suspended: bool[\s\S]*disabled: bool[\s\S]*private_moderation_details_exposed: bool[\s\S]*signup_failure_details_exposed: bool/ +); +expect( + cli, + "CLI auth status queries public account state", + /fn coordinator_auth_status_summary[\s\S]*"type": "auth_status"[\s\S]*"private_moderation_details_exposed": false[\s\S]*"signup_failure_details_exposed": false/ +); +expect( + cli, + "CLI auth status does not expose raw private moderation response fields", + /fn auth_status_queries_coordinator_account_state_without_private_moderation_details[\s\S]*abuse_score[\s\S]*moderation_notes[\s\S]*!serialized\.contains\("abuse_score"\)[\s\S]*!serialized\.contains\("moderation_notes"\)/ +); +expect( + cli, + "CLI auth status reports inactive account states safely", + /fn auth_status_reports_disabled_deleted_and_manual_review_safely[\s\S]*account_status[\s\S]*disabled[\s\S]*deleted[\s\S]*manual_review[\s\S]*!serialized\.contains\("signup_policy_trace"\)/ +); +expect( + coordinatorLib, + "coordinator summarizes private account policy state safely", + /fn tenant_disabled\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:disabled[\s\S]*fn tenant_deleted\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:deleted[\s\S]*fn tenant_manual_review\(&self, tenant: &TenantId\) -> bool[\s\S]*tenant:manual_review[\s\S]*fn account_policy_state\(&self, tenant: &TenantId\) -> AccountPolicyState[\s\S]*"deleted"[\s\S]*"disabled"[\s\S]*"manual_review"[\s\S]*"account or tenant is pending hosted review"/ +); +expect( + cli, + "CLI renders sanitized account status", + /coordinator_account_status[\s\S]*account status checked: \{checked\}[\s\S]*account status[\s\S]*private moderation details exposed/ +); +expect( + cli, + "CLI renders community tier wording", + /push_string_field\(&mut lines, value, "quota_tier", "quota tier"\)[\s\S]*community_tier_label[\s\S]*quota tier: \{tier\}/ +); +assert.doesNotMatch(cli, /free[- ]tier/i, "CLI source should use community tier wording"); +expect( + cli, + "CLI reports stable error-code contract", + /fn cli_error_exit_code[\s\S]*"authentication" => 20[\s\S]*"authorization" => 21[\s\S]*"quota" => 22[\s\S]*"policy" => 23[\s\S]*"capability" => 24[\s\S]*"connectivity" => 25[\s\S]*"environment" => 26[\s\S]*"program" => 27/ +); +expect( + cli, + "CLI attaches machine errors to run and task reports", + /run_start_summary[\s\S]*"machine_error": machine_error/ +); +expect( + cli, + "CLI attaches machine errors to task failures", + /task_failure_machine_error[\s\S]*cli_error_summary_with_default/ +); +expect( + cli, + "CLI applies process exit code after printing command failure report", + /fn emit_report[\s\S]*apply_command_report_exit_code[\s\S]*std::process::exit\(exit_code\)[\s\S]*fn human_report/ +); +expect( + cli, + "CLI applies artifact nested failure exit codes", + /fn apply_command_report_exit_code[\s\S]*"\/download_session\/machine_error"[\s\S]*"\/export_plan\/machine_error"[\s\S]*"\/local_export\/machine_error"[\s\S]*"\/local_export\/download_session\/machine_error"[\s\S]*"\/local_export\/stream\/machine_error"/ +); +expect( + cli, + "CLI wraps fallible main with classified exit", + /fn main\(\)[\s\S]*cli_error_summary\(&message\)[\s\S]*std::process::exit\(exit_code\)/ +); +expect( + cli, + "CLI parses dangerous capability overrides", + /"host-filesystem"[\s\S]*Capability::HostFilesystem[\s\S]*"network"[\s\S]*Capability::Network[\s\S]*"secrets"[\s\S]*Capability::Secrets/ +); +expect( + cli, + "CLI exposes source provider diagnostics", + /source_provider_statuses[\s\S]*source_provider_selection[\s\S]*source_provider_unsupported/ +); +expect( + cli, + "CLI exposes environment pre-schedule diagnostics", + /environment_diagnostics_for_inputs[\s\S]*diagnose_environment_references[\s\S]*missing_environment/ +); +expect( + cli, + "CLI bundle metadata exposes MVP build facets", + /BundleIdentityInputs[\s\S]*entrypoints[\s\S]*default_entrypoint[\s\S]*source_transfer_policy[\s\S]*wasm_source_proxy_digest[\s\S]*task_abi_digest/ +); +expect( + cli, + "CLI bundle coverage verifies Dockerfile and debug metadata", + /fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers\(\)[\s\S]*envs\/docker\/Dockerfile[\s\S]*task_metadata[\s\S]*source_metadata[\s\S]*debug_metadata/ +); +expect( + cli, + "CLI build coverage verifies inspectable metadata", + /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)[\s\S]*wasm_code[\s\S]*task_metadata[\s\S]*source_metadata[\s\S]*debug_metadata/ +); +expect( + cli, + "CLI build coverage verifies large input handle posture", + /large_input_policy[\s\S]*selected_inputs_are_content_digests[\s\S]*selected_input_bytes_included[\s\S]*silent_task_argument_serialization[\s\S]*supported_handle_types/ +); +expect( + cli, + "CLI bundle coverage verifies restart compatibility metadata", + /restart_compatibility[\s\S]*source_edits_can_restart_from_clean_task_boundary[\s\S]*requires_clean_checkpoint_boundary[\s\S]*compares_task_abi[\s\S]*incompatible_changes_require_whole_process_restart/ +); +expect( + cli, + "CLI blocks build before scheduling unsafe inputs", + /blocked_before_schedule[\s\S]*scheduled_work[\s\S]*false/ +); +expect( + cli, + "CLI artifact export writes explicit local bytes from stream content", + /fn artifact_export_local_write_followup[\s\S]*open_artifact_download_stream[\s\S]*content_base64[\s\S]*std::fs::write/ +); +expect( + cli, + "CLI artifact export keeps content out of reports", + /fn artifact_stream_summary[\s\S]*content_material_returned_in_report[\s\S]*false/ +); +expect( + cli, + "CLI artifact export carries download grant disclosure", + /fn artifact_export_local_write_followup[\s\S]*artifact_download_grant_disclosures[\s\S]*"grant_disclosures": grant_disclosures/ +); +expect( + cli, + "CLI exposes admin bootstrap self-hosted path", + /fn admin_bootstrap_report[\s\S]*self_hosted_cli_only[\s\S]*bootstrap_sequence[\s\S]*create_node_enrollment_grant[\s\S]*attach_worker_node[\s\S]*inspect_status_logs_artifacts[\s\S]*revoke_access/ +); +expect( + coordinator, + "coordinator reverse-streams verified retained artifact bytes through a bounded spool", + /struct ArtifactReverseTransfer[\s\S]*spool: tempfile::NamedTempFile[\s\S]*received_bytes: u64[\s\S]*delivered_offset: u64[\s\S]*handle_open_artifact_download_stream[\s\S]*read_exact\(&mut content\)[\s\S]*charge_download\([\s\S]*delivered_offset = end[\s\S]*BASE64_STANDARD\.encode\(content\)/ +); + +for (const [name, pattern] of [ + ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["bundle inspect --json flag", /struct BundleInspectArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["build --json flag", /struct BuildArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["run --json flag", /struct RunArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["node attach --json flag", /struct AttachArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["DAP plan --json flag", /struct DapArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["shared scope --json flag", /struct CliScopeArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], +]) { + expect(cli, name, pattern); +} + +for (const [name, pattern] of [ + ["human default assertion", /default output should be human-readable text, not JSON/], + [ + "login plan JSON mode", + /"login",\s*"--plan",\s*"--coordinator",\s*"https:\/\/coord\.example\.test",\s*"--json"/, + ], + ["doctor human mode", /\["doctor"\]/], + ["doctor reachability JSON mode", /doctorJson\.coordinator_reachability\.status/], + ["doctor node readiness JSON mode", /doctorJson\.node_readiness_summary\.status[\s\S]*explicit_attach_required[\s\S]*missing_local_dependencies/], + ["bundle inspect JSON mode", /\["bundle", "inspect", "--project", project, "--json"\]/], + ["bundle inspect large input JSON mode", /large_input_policy[\s\S]*SourceSnapshot/], + ["bundle inspect restart compatibility JSON mode", /restart_compatibility[\s\S]*source_edits_can_restart_from_clean_task_boundary/], + ["auth expiry posture", /token_expiry_posture[\s\S]*expires_at/], +]) { + expect(outputModeSmoke, name, pattern); +} + +for (const [name, pattern] of [ + ["environment build rejection exit code", /environmentFailure[\s\S]*assert\.strictEqual\(environmentFailure\.code, 26/], + ["environment rejection machine category", /environmentReport\.machine_error\.category, "environment"/], + ["fake coordinator quota rejection", /quota unavailable: resource limit exceeded for api_calls/], + ["run uses JSON mode", /"run"[\s\S]*"--json"/], + ["actual quota exit code", /assert\.strictEqual\(result\.code, 22/], + ["actual quota resource-category assertion", /machine_error\.resource_category, "api_calls"/], + ["actual quota community-tier label assertion", /machine_error\.community_tier_label,[\s\S]*"community tier"/], + ["actual quota abuse-heuristics assertion", /machine_error\.private_abuse_heuristics_exposed,[\s\S]*false/], + ["capability rejection exit code", /capabilityFailure[\s\S]*assert\.strictEqual\(capabilityFailure\.result\.code, 24/], + ["capability rejection next action", /attach a node with the required capabilities/], + ["node policy rejection exit code", /nodePolicyFailure[\s\S]*assert\.strictEqual\(nodePolicyFailure\.result\.code, 23/], + ["node policy rejection next action", /check coordinator policy for this action/], + ["program task failure category", /programReport\.tasks\[0\]\.machine_error\.category, "program"/], + ["artifact download rejection exit code", /artifactDownload[\s\S]*assert\.strictEqual\(artifactDownload\.result\.code, 21/], + ["artifact export rejection exit code", /artifactExport[\s\S]*assert\.strictEqual\(artifactExport\.result\.code, 25/], + ["exit-code application in JSON", /process_exit_code_applied[\s\S]*true/], +]) { + expect(errorExitSmoke, name, pattern); +} + +console.log("CLI-first contract smoke passed"); diff --git a/scripts/cli-happy-path-live-smoke.js b/scripts/cli-happy-path-live-smoke.js new file mode 100644 index 0000000..afd42df --- /dev/null +++ b/scripts/cli-happy-path-live-smoke.js @@ -0,0 +1,2116 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const https = require("https"); +const os = require("os"); +const path = require("path"); +const { DapClient } = require("./dap-client"); +const { agentIdentity, signedAgentWorkflowRequest } = require("./agent-signing"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { + nodeIdentity, + nodeIdentityFromPrivateKey, + signedNodeHeartbeat, + signedNodeRequest, +} = require("./node-signing"); +const { configurePodmanTestEnvironment } = require("./podman-test-env"); + +const repo = path.resolve(__dirname, ".."); +configurePodmanTestEnvironment(repo); +if ( + !process.env.DISASMER_PODMAN_NIX_SHELL && + cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status !== 0 && + cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0 +) { + cp.execFileSync( + "nix", + ["shell", "nixpkgs#podman", "--command", "node", __filename], + { + cwd: repo, + env: { ...process.env, DISASMER_PODMAN_NIX_SHELL: "1" }, + stdio: "inherit", + } + ); + process.exit(0); +} + +const releaseRoot = path.resolve( + process.env.DISASMER_PUBLIC_RELEASE_DIR || + path.join(repo, "target/public-release-dryrun") +); +const acceptanceRoot = path.join(repo, "target/acceptance"); +const manifestPath = path.join(releaseRoot, "public-release-manifest.json"); +const reportPath = path.join(acceptanceRoot, "cli-happy-path-live.json"); +const serviceEndpoint = "https://disasmer.michelpaulissen.com"; +const serviceAddr = + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || + "disasmer.michelpaulissen.com:443"; +const browserOpenCommand = + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND; +const enabled = process.env.DISASMER_CLI_HAPPY_PATH_LIVE === "1"; +const strictFullRelease = process.env.DISASMER_STRICT_FULL_RELEASE === "1"; +const strictVpsRestart = process.env.DISASMER_STRICT_VPS_RESTART === "1"; +const strictVpsHost = process.env.DISASMER_STRICT_VPS_HOST; +const strictVpsIdentity = process.env.DISASMER_STRICT_VPS_IDENTITY; +const strictServiceUnit = + process.env.DISASMER_STRICT_SERVICE_UNIT || + "disasmer-public-release-dryrun.service"; +const strictSoakSeconds = Number( + process.env.DISASMER_STRICT_SOAK_SECONDS || "300" +); +const commands = []; + +function requireEnabled() { + if (!enabled) { + throw new Error( + "DISASMER_CLI_HAPPY_PATH_LIVE=1 is required because this runs the live CLI happy path" + ); + } + if (!browserOpenCommand) { + throw new Error( + "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND is required to complete the server-owned hosted browser login" + ); + } + if ( + strictFullRelease && + !process.env.DISASMER_SECOND_TENANT_SESSION_FILE && + !process.env.DISASMER_SECOND_TENANT_EVIDENCE_FILE + ) { + throw new Error( + "DISASMER_STRICT_FULL_RELEASE=1 requires DISASMER_SECOND_TENANT_SESSION_FILE or DISASMER_SECOND_TENANT_EVIDENCE_FILE for the isolation proof" + ); + } + if (strictFullRelease && !process.env.DISASMER_EXPIRED_USER_SESSION_FILE) { + throw new Error( + "DISASMER_STRICT_FULL_RELEASE=1 requires DISASMER_EXPIRED_USER_SESSION_FILE" + ); + } + if (strictFullRelease && !strictVpsRestart) { + throw new Error( + "DISASMER_STRICT_FULL_RELEASE=1 requires DISASMER_STRICT_VPS_RESTART=1" + ); + } + if (strictVpsRestart && (!strictVpsHost || !strictVpsIdentity)) { + throw new Error( + "strict VPS restart requires DISASMER_STRICT_VPS_HOST and DISASMER_STRICT_VPS_IDENTITY" + ); + } + if ( + !Number.isInteger(strictSoakSeconds) || + strictSoakSeconds < 120 || + strictSoakSeconds > 1800 + ) { + throw new Error("DISASMER_STRICT_SOAK_SECONDS must be an integer from 120 to 1800"); + } +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function sha256(bytes) { + return `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`; +} + +function nonInteractiveGitEnv(extra = {}) { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false", + SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false", + GIT_SSH_COMMAND: + process.env.GIT_SSH_COMMAND || + "ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0", + ...extra, + }; +} + +function commandEnv(command, env) { + if (command !== "git") return env; + return nonInteractiveGitEnv(env); +} + +function recordCommand(command, args) { + const redacted = []; + let hideNext = false; + for (const argument of args) { + if (hideNext) { + redacted.push(""); + hideNext = false; + } else { + redacted.push(argument); + hideNext = ["--enrollment-grant", "--admin-token"].includes(argument); + } + } + commands.push([command, ...redacted].join(" ")); +} + +function run(command, args, options = {}) { + recordCommand(command, args); + return cp.execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 15 * 60 * 1000, + ...options, + env: commandEnv(command, options.env), + }); +} + +function parseJsonOutput(output) { + const trimmed = output.trim(); + if (!trimmed) throw new Error("expected JSON output, got empty stdout"); + try { + return JSON.parse(trimmed); + } catch (_) { + // Commands may print progress before their final JSON report. + } + const lines = trimmed.split(/\r?\n/).filter(Boolean); + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + return JSON.parse(lines.slice(index).join("\n")); + } catch (_) { + // Keep scanning for the trailing JSON value. + } + } + throw new Error(`could not parse JSON output:\n${trimmed}`); +} + +function runJson(command, args, options = {}) { + return parseJsonOutput(run(command, args, options)); +} + +function authenticatedRequest(sessionSecret, request) { + return { + type: "authenticated", + session_secret: sessionSecret, + request, + }; +} + +function sendHostedControl(payload) { + const body = Buffer.from( + JSON.stringify(coordinatorWireRequest(payload, "strict-live")) + ); + const url = new URL("/api/v1/control", serviceEndpoint); + return new Promise((resolve, reject) => { + const request = https.request( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": body.length, + }, + timeout: 30_000, + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const responseBody = Buffer.concat(chunks).toString("utf8"); + if (response.statusCode < 200 || response.statusCode >= 300) { + reject( + new Error( + `hosted control HTTP ${response.statusCode}: ${responseBody}` + ) + ); + return; + } + try { + resolve(JSON.parse(responseBody)); + } catch (error) { + reject( + new Error(`hosted control returned invalid JSON: ${error.message}`) + ); + } + }); + } + ); + request.on("timeout", () => + request.destroy(new Error("hosted control request timed out")) + ); + request.on("error", reject); + request.end(body); + }); +} + +function assertDenied(response, pattern, label) { + assert.strictEqual(response.type, "error", `${label}: ${JSON.stringify(response)}`); + assert.match(response.message, pattern, `${label}: ${JSON.stringify(response)}`); + return { + denied: true, + category: response.error?.category || null, + message: response.message, + }; +} + +function readNodeCredential(projectDir, node) { + const directory = path.join(projectDir, ".disasmer", "nodes"); + for (const file of fs.readdirSync(directory)) { + if (!file.endsWith(".json")) continue; + const absolute = path.join(directory, file); + const credential = readJson(absolute); + if (credential.node === node) return { absolute, credential }; + } + throw new Error(`persisted credential for ${node} was not found`); +} + +function directoryStats(root) { + if (!fs.existsSync(root)) return { files: 0, bytes: 0 }; + let files = 0; + let bytes = 0; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile()) { + files += 1; + bytes += fs.statSync(absolute).size; + } + } + }; + visit(root); + return { files, bytes }; +} + +function sshArgs(...remoteArgs) { + assert(strictVpsHost && strictVpsIdentity); + return [ + "-i", + path.resolve(strictVpsIdentity.replace(/^~(?=\/)/, os.homedir())), + "-o", + "BatchMode=yes", + strictVpsHost, + ...remoteArgs, + ]; +} + +function strictInfrastructureSample(projectDir) { + const memory = Object.fromEntries( + run( + "ssh", + sshArgs( + "systemctl", + "show", + strictServiceUnit, + "--property=MemoryCurrent", + "--property=MemoryPeak" + ) + ) + .trim() + .split(/\r?\n/) + .map((line) => line.split("=")) + ); + const serviceDisk = Number( + run( + "ssh", + sshArgs("du", "-sb", "/var/lib/disasmer-public-release-dryrun") + ) + .trim() + .split(/\s+/)[0] + ); + const database = run( + "ssh", + sshArgs( + "runuser", + "-u", + "postgres", + "--", + "psql", + "-d", + "disasmer", + "-AtF," + ), + { + input: + "SELECT pg_database_size('disasmer'), (SELECT count(*) FROM disasmer_tenants), (SELECT count(*) FROM disasmer_projects), (SELECT count(*) FROM disasmer_node_identities), (SELECT count(*) FROM disasmer_cli_sessions);\n", + stdio: ["pipe", "pipe", "pipe"], + } + ) + .trim() + .split(",") + .map(Number); + assert(Number.isFinite(Number(memory.MemoryCurrent))); + assert(Number.isFinite(Number(memory.MemoryPeak))); + assert.strictEqual(database.length, 5); + return { + sampled_at_epoch_ms: Date.now(), + service_memory_current_bytes: Number(memory.MemoryCurrent), + service_memory_peak_bytes: Number(memory.MemoryPeak), + service_state_disk_bytes: serviceDisk, + postgres_database_bytes: database[0], + durable_rows: { + tenants: database[1], + projects: database[2], + node_identities: database[3], + cli_sessions: database[4], + }, + local_node_state: directoryStats(path.join(projectDir, ".disasmer")), + }; +} + +function executable(root, name) { + return path.join(root, "bin", process.platform === "win32" ? `${name}.exe` : name); +} + +function binaryArchive(manifest) { + const platform = `${os.platform()}-${os.arch()}`; + const asset = manifest.assets.find( + (candidate) => + candidate.name.startsWith("disasmer-public-binaries-") && + candidate.name.includes(platform) + ); + assert(asset, `missing released binary archive for ${platform}`); + assert(fs.existsSync(asset.file), `missing released binary archive ${asset.file}`); + return asset.file; +} + +function publicRepoUrl(manifest) { + const url = + process.env.DISASMER_PUBLIC_REPO_URL || + process.env.DISASMER_PUBLIC_REPO_REMOTE || + manifest.public_repo_url || + manifest.public_repo_remote; + assert(url, "public repository URL is required"); + assert(url.includes("git.michelpaulissen.com")); + return url; +} + +function stagePublicCheckout(manifest, checkout) { + const candidateTree = process.env.DISASMER_LIVE_PUBLIC_TREE; + if (candidateTree) { + const source = path.resolve(candidateTree); + assert.strictEqual( + source, + path.resolve(manifest.public_tree), + "explicit live candidate tree must be the tree recorded by the release manifest" + ); + fs.cpSync(source, checkout, { recursive: true }); + return { + kind: "local_filtered_release_candidate", + path: source, + published: false, + }; + } + run("git", ["clone", "--depth", "1", publicRepoUrl(manifest), checkout]); + return { + kind: "published_forgejo_checkout", + url: publicRepoUrl(manifest), + published: true, + }; +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitForCli(label, action, predicate, timeoutMs = 10 * 60 * 1000) { + const deadline = Date.now() + timeoutMs; + let lastValue; + let lastError; + while (Date.now() < deadline) { + try { + lastValue = action(); + if (predicate(lastValue)) return lastValue; + lastError = undefined; + } catch (error) { + lastError = error; + } + await delay(250); + } + throw new Error( + `${label} timed out; last value=${JSON.stringify(lastValue)}${ + lastError ? `; last error=${lastError.message}` : "" + }` + ); +} + +function spawnJsonLines(command, args, options) { + recordCommand(command, args); + const child = cp.spawn(command, args, { + ...options, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const values = []; + const waiters = []; + + function deliver(value) { + values.push(value); + for (let index = waiters.length - 1; index >= 0; index -= 1) { + const waiter = waiters[index]; + if (waiter.predicate(value)) { + waiters.splice(index, 1); + clearTimeout(waiter.timer); + waiter.resolve(value); + } + } + } + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + while (stdout.includes("\n")) { + const newline = stdout.indexOf("\n"); + const line = stdout.slice(0, newline).trim(); + stdout = stdout.slice(newline + 1); + if (!line) continue; + try { + deliver(JSON.parse(line)); + } catch (_) { + // Non-JSON progress stays out of the evidence report. + } + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + function waitFor(predicate, label, timeoutMs = 120000) { + const existing = values.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const waiter = { predicate, resolve, reject, timer: undefined }; + waiter.timer = setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + reject(new Error(`${label} timed out\n${stderr}`)); + }, timeoutMs); + waiters.push(waiter); + }); + } + + child.once("exit", (code) => { + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error(`${waiter.label || "child"} exited with ${code}\n${stderr}`)); + } + }); + return { child, values, waitFor, stderr: () => stderr }; +} + +async function stopChild(child) { + if (!child || child.exitCode !== null) return; + child.kill("SIGTERM"); + const exited = new Promise((resolve) => child.once("exit", resolve)); + const forced = delay(5000).then(() => { + if (child.exitCode === null) child.kill("SIGKILL"); + }); + await Promise.race([exited, forced]); + if (child.exitCode === null) await exited; +} + +function rawTaskEvents(report) { + return report?.events?.response?.events || []; +} + +function completedFlagship(events) { + const completedDefinitions = new Set( + events + .filter((event) => event.terminal_state === "completed") + .map((event) => event.task_definition) + ); + return ( + completedDefinitions.has("prepare_source") && + completedDefinitions.has("compile_linux") && + completedDefinitions.has("package_release") && + events.some( + (event) => + event.executor === "coordinator_main" && event.terminal_state === "completed" + ) + ); +} + +function contentAddressedArtifactId(event, logicalName) { + const digest = event?.artifact_digest; + if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) { + return null; + } + const artifactId = `${logicalName}-${digest.slice("sha256:".length)}`; + return event.artifact_path?.endsWith(`/vfs/artifacts/${artifactId}`) + ? artifactId + : null; +} + +async function prepareLiveNodeCredentialSecurity({ + disasmer, + projectDir, + scope, + tenant, + project, + suffix, +}) { + const node = `security-node-${suffix}`; + const grant = runJson(disasmer, ["node", "enroll", ...scope], { + cwd: projectDir, + }); + const attach = runJson( + disasmer, + [ + "node", + "attach", + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--node", + node, + "--enrollment-grant", + grant.enrollment_grant.grant, + "--json", + ], + { cwd: projectDir } + ); + assert.strictEqual(attach.boundary.used_enrollment_exchange, true); + const stored = readNodeCredential(projectDir, node); + const identity = nodeIdentityFromPrivateKey(stored.credential.private_key); + assert.strictEqual(identity.publicKey, stored.credential.public_key); + + const heartbeat = { + type: "node_heartbeat", + node, + node_signature: signedNodeHeartbeat(node, identity, { + nonce: `strict-valid-${suffix}`, + }), + }; + const valid = await sendHostedControl(heartbeat); + assert.strictEqual(valid.type, "node_heartbeat"); + const replay = assertDenied( + await sendHostedControl(heartbeat), + /nonce.*already.*used|replay/i, + "replayed node credential" + ); + + const expired = assertDenied( + await sendHostedControl({ + type: "node_heartbeat", + node, + node_signature: signedNodeHeartbeat(node, identity, { + nonce: `strict-expired-${suffix}`, + issuedAtEpochSeconds: Math.floor(Date.now() / 1000) - 3600, + }), + }), + /expired|clock skew/i, + "expired node credential" + ); + + const forgedIdentity = nodeIdentity("strict-forged-node", node); + const forged = assertDenied( + await sendHostedControl({ + type: "node_heartbeat", + node, + node_signature: signedNodeHeartbeat(node, forgedIdentity, { + nonce: `strict-forged-${suffix}`, + }), + }), + /signature|public key/i, + "forged node credential" + ); + + const originalCapabilityBody = { + type: "report_node_capabilities", + tenant, + project, + node, + capabilities: { + os: "Linux", + arch: process.arch === "x64" ? "x86_64" : process.arch, + capabilities: [], + environment_backends: [], + source_providers: [], + }, + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: false, + online: true, + }; + const modifiedEnvelope = signedNodeRequest( + node, + identity, + "report_node_capabilities", + originalCapabilityBody, + { nonce: `strict-modified-${suffix}` } + ); + modifiedEnvelope.request.online = false; + const bodyModified = assertDenied( + await sendHostedControl(modifiedEnvelope), + /signature/i, + "body-modified node credential" + ); + + return { + node, + identity, + credential_path: stored.absolute, + credential_digest: sha256(fs.readFileSync(stored.absolute)), + capability_body: originalCapabilityBody, + evidence: { + valid_request: valid.type, + replay, + expired, + forged, + body_modified: bodyModified, + }, + }; +} + +async function runLiveAgentCredentialSecurity({ + disasmer, + projectDir, + scope, + sessionSecret, + tenant, + project, + suffix, +}) { + const agent = `security-agent-${suffix}`; + const identity = agentIdentity("strict-live-agent", agent); + const added = runJson( + disasmer, + ["key", "add", ...scope, "--agent", agent, "--public-key", identity.publicKey], + { cwd: projectDir } + ); + assert.strictEqual(added.command, "key add"); + + const processId = `vp-agent-security-${suffix}`; + const unsigned = { + type: "start_process", + tenant, + project, + actor_agent: agent, + process: processId, + restart: false, + }; + const validRequest = signedAgentWorkflowRequest(identity, unsigned, { + nonce: `strict-agent-valid-${suffix}`, + }); + const valid = await sendHostedControl(validRequest); + assert.strictEqual(valid.type, "process_started", JSON.stringify(valid)); + const replay = assertDenied( + await sendHostedControl(validRequest), + /nonce.*already.*used|replay/i, + "replayed agent credential" + ); + + const expired = assertDenied( + await sendHostedControl( + signedAgentWorkflowRequest( + identity, + { ...unsigned, process: `${processId}-expired` }, + { + nonce: `strict-agent-expired-${suffix}`, + issuedAtEpochSeconds: Math.floor(Date.now() / 1000) - 3600, + } + ) + ), + /expired|clock skew/i, + "expired agent credential" + ); + + const modifiedRequest = signedAgentWorkflowRequest( + identity, + { ...unsigned, process: `${processId}-modified` }, + { nonce: `strict-agent-modified-${suffix}` } + ); + modifiedRequest.restart = true; + const bodyModified = assertDenied( + await sendHostedControl(modifiedRequest), + /signature/i, + "body-modified agent credential" + ); + + const forgedIdentity = agentIdentity("strict-live-forged-agent", agent); + const forged = assertDenied( + await sendHostedControl( + signedAgentWorkflowRequest( + forgedIdentity, + { ...unsigned, process: `${processId}-forged` }, + { + nonce: `strict-agent-forged-${suffix}`, + publicKeyFingerprint: identity.publicKeyFingerprint, + } + ) + ), + /signature|public key/i, + "forged agent credential" + ); + + const aborted = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "abort_process", + process: processId, + }) + ); + assert.strictEqual(aborted.type, "process_aborted", JSON.stringify(aborted)); + const revoked = runJson( + disasmer, + ["key", "revoke", ...scope, "--agent", agent, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(revoked.command, "key revoke"); + const revokedUse = assertDenied( + await sendHostedControl( + signedAgentWorkflowRequest( + identity, + { ...unsigned, process: `${processId}-revoked` }, + { nonce: `strict-agent-revoked-${suffix}` } + ) + ), + /revoked/i, + "revoked agent credential" + ); + return { + agent, + valid_request: valid.type, + replay, + expired, + forged, + body_modified: bodyModified, + revoked: revokedUse, + }; +} + +async function runSecondTenantIsolation({ + firstTenant, + firstProject, + firstProcess, + firstNode, + firstArtifact, + manifest, +}) { + const file = process.env.DISASMER_SECOND_TENANT_SESSION_FILE; + const evidenceFile = process.env.DISASMER_SECOND_TENANT_EVIDENCE_FILE; + if (!file && evidenceFile) { + const evidenceBytes = fs.readFileSync(path.resolve(evidenceFile)); + const previous = JSON.parse(evidenceBytes); + const isolation = previous.tenant_isolation; + assert.strictEqual(isolation?.executed, true); + assert.notStrictEqual(isolation.second_tenant, firstTenant); + assert.strictEqual(isolation.project?.denied, true); + assert.strictEqual(isolation.process_hidden, true); + assert.strictEqual(isolation.node_hidden, true); + assert.strictEqual(isolation.tasks_and_logs?.denied, true); + assert.strictEqual(isolation.debug?.denied, true); + assert.strictEqual(isolation.debug?.audited, true); + assert.strictEqual(isolation.debug?.charged_debug_read_bytes, 0); + assert.strictEqual(isolation.artifact_and_download?.denied, true); + assert.strictEqual(isolation.process_control?.denied, true); + const previousDeployment = previous.release_binding?.deployment; + const currentDeployment = deploymentProvenance(); + assert.strictEqual( + previousDeployment?.hosted_service_sha256, + currentDeployment.hosted_service_sha256, + "reused isolation evidence must target the exact deployed hosted binary" + ); + assert.deepStrictEqual( + previous.release_binding?.binary_digests, + manifest.binary_digests, + "reused isolation evidence must target the exact public binaries" + ); + return { + ...isolation, + reused_from_immutable_evidence: { + report_sha256: sha256(evidenceBytes), + source_commit: previous.source_commit, + hosted_service_sha256: currentDeployment.hosted_service_sha256, + public_binary_digests: manifest.binary_digests, + }, + }; + } + if (!file) return { executed: false, reason: "second tenant session not supplied" }; + const second = readJson(path.resolve(file)); + assert.strictEqual(second.coordinator, serviceEndpoint); + assert(second.session_secret, "second tenant session omitted its session secret"); + assert.notStrictEqual( + second.tenant, + firstTenant, + "isolation proof requires a genuinely distinct hosted tenant" + ); + const call = (request) => + sendHostedControl(authenticatedRequest(second.session_secret, request)); + + const project = assertDenied( + await call({ type: "select_project", project: firstProject }), + /outside|not visible|tenant|project/i, + "cross-tenant project selection" + ); + const processes = await call({ type: "list_processes" }); + assert.strictEqual( + processes.type, + "process_statuses", + JSON.stringify(processes) + ); + assert(!JSON.stringify(processes).includes(firstProcess)); + const nodes = await call({ type: "list_node_descriptors" }); + assert.strictEqual(nodes.type, "node_descriptors", JSON.stringify(nodes)); + assert(!JSON.stringify(nodes).includes(firstNode)); + const tasksAndLogs = assertDenied( + await call({ type: "list_task_events", process: firstProcess }), + /outside|scope|tenant|project|not active|requires an active|unknown/i, + "cross-tenant task and log listing" + ); + const debugResponse = await call({ + type: "debug_attach", + process: firstProcess, + }); + assert.strictEqual(debugResponse.type, "debug_attach", JSON.stringify(debugResponse)); + assert.strictEqual(debugResponse.authorization?.allowed, false); + assert.strictEqual(debugResponse.audit_event?.allowed, false); + assert.match( + debugResponse.authorization?.reason || "", + /outside|scope|permission|tenant|project|not active|unknown/i + ); + assert.strictEqual(debugResponse.charged_debug_read_bytes, 0); + const debug = { + denied: true, + reason: debugResponse.authorization.reason, + audited: true, + charged_debug_read_bytes: debugResponse.charged_debug_read_bytes, + }; + const artifact = assertDenied( + await call({ + type: "create_artifact_download_link", + artifact: firstArtifact, + max_bytes: 1024, + ttl_seconds: 60, + }), + /outside|scope|tenant|project|not found|unavailable/i, + "cross-tenant artifact download" + ); + const control = assertDenied( + await call({ type: "abort_process", process: firstProcess }), + /outside|scope|tenant|project|not active|requires an active|unknown/i, + "cross-tenant process control" + ); + return { + executed: true, + second_tenant: second.tenant, + project, + process_hidden: true, + node_hidden: true, + tasks_and_logs: tasksAndLogs, + debug, + artifact_and_download: artifact, + process_control: control, + }; +} + +async function runLiveSoak({ + disasmer, + projectDir, + scope, + securityNode, +}) { + if (!strictVpsRestart) { + return { executed: false, reason: "strict VPS measurement access not supplied" }; + } + const runReport = runJson( + disasmer, + ["run", "build", "--project", ".", "--json"], + { cwd: projectDir } + ); + assert.strictEqual(runReport.status, "main_launched"); + const processId = runReport.process; + const parked = await waitForCli( + "soak coordinator main to park without a capable node", + () => + runJson( + disasmer, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ), + (status) => + status.live_process?.main_state === "running" && + status.live_process?.main_wait_state === "waiting_for_node", + 120000 + ); + + const startedAt = Date.now(); + const deadline = startedAt + strictSoakSeconds * 1000; + const samples = []; + let sampleIndex = 0; + while (true) { + const capabilities = await sendHostedControl( + signedNodeRequest( + securityNode.node, + securityNode.identity, + "report_node_capabilities", + securityNode.capability_body, + { nonce: `strict-soak-capabilities-${sampleIndex}-${Date.now()}` } + ) + ); + assert.strictEqual( + capabilities.type, + "node_capabilities_recorded", + JSON.stringify(capabilities) + ); + const poll = await sendHostedControl( + signedNodeRequest( + securityNode.node, + securityNode.identity, + "poll_task_assignment", + { + type: "poll_task_assignment", + tenant: securityNode.capability_body.tenant, + project: securityNode.capability_body.project, + node: securityNode.node, + }, + { nonce: `strict-soak-poll-${sampleIndex}-${Date.now()}` } + ) + ); + assert.strictEqual(poll.type, "task_assignment", JSON.stringify(poll)); + assert.strictEqual(poll.assignment, null); + const status = runJson( + disasmer, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ); + assert.strictEqual(status.live_process.main_wait_state, "waiting_for_node"); + samples.push(strictInfrastructureSample(projectDir)); + sampleIndex += 1; + if (Date.now() >= deadline) break; + await delay(Math.min(30_000, deadline - Date.now())); + } + + const currentMemory = samples.map( + (sample) => sample.service_memory_current_bytes + ); + const serviceDisk = samples.map((sample) => sample.service_state_disk_bytes); + const databaseDisk = samples.map((sample) => sample.postgres_database_bytes); + const localDisk = samples.map((sample) => sample.local_node_state.bytes); + const spread = (values) => Math.max(...values) - Math.min(...values); + assert(spread(currentMemory) <= 128 * 1024 * 1024); + assert(spread(serviceDisk) <= 16 * 1024 * 1024); + assert(spread(databaseDisk) <= 32 * 1024 * 1024); + assert(spread(localDisk) <= 16 * 1024 * 1024); + assert.deepStrictEqual( + samples.at(-1).durable_rows, + samples[0].durable_rows, + "durable object counts grew during the parked-process soak" + ); + const aborted = runJson( + disasmer, + ["process", "abort", ...scope, "--process", processId, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(aborted.abort_request.process_slot_released, true); + return { + executed: true, + process: processId, + duration_seconds: Math.floor((Date.now() - startedAt) / 1000), + sample_interval_seconds: 30, + sample_count: samples.length, + parked_main: { + main_state: parked.live_process.main_state, + main_wait_state: parked.live_process.main_wait_state, + }, + signed_node_poll_cycles: samples.length, + memory_spread_bytes: spread(currentMemory), + service_disk_spread_bytes: spread(serviceDisk), + postgres_disk_spread_bytes: spread(databaseDisk), + local_node_state_spread_bytes: spread(localDisk), + samples, + }; +} + +async function revokeSecurityNode({ + disasmer, + projectDir, + scope, + securityNode, +}) { + const revoked = runJson( + disasmer, + ["node", "revoke", ...scope, "--node", securityNode.node, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(revoked.command, "node revoke"); + const denied = assertDenied( + await sendHostedControl({ + type: "node_heartbeat", + node: securityNode.node, + node_signature: signedNodeHeartbeat( + securityNode.node, + securityNode.identity, + { nonce: `strict-node-revoked-${Date.now()}` } + ), + }), + /revoked|unknown node|not recognized|not enrolled/i, + "revoked node credential" + ); + return { node: securityNode.node, denied }; +} + +async function runLiveQuotaPreallocation({ sessionSecret, suffix }) { + let denial; + let deniedProcess; + let acceptedStarts = 0; + for (let index = 0; index < 96; index += 1) { + const processId = `vp-quota-${suffix}-${index}`; + const started = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "start_process", + process: processId, + restart: false, + }) + ); + if (started.type === "error") { + assert.match(started.message, /quota|spawn|limit|community tier/i); + denial = started; + deniedProcess = processId; + break; + } + assert.strictEqual(started.type, "process_started", JSON.stringify(started)); + acceptedStarts += 1; + const aborted = await sendHostedControl( + authenticatedRequest(sessionSecret, { + type: "abort_process", + process: processId, + }) + ); + assert.strictEqual(aborted.type, "process_aborted", JSON.stringify(aborted)); + } + assert(denial, "live community-tier spawn quota was not reached in 96 attempts"); + const processes = await sendHostedControl( + authenticatedRequest(sessionSecret, { type: "list_processes" }) + ); + assert.strictEqual( + processes.type, + "process_statuses", + JSON.stringify(processes) + ); + assert( + !JSON.stringify(processes).includes(deniedProcess), + "quota-denied process was allocated before denial" + ); + return { + accepted_starts_before_denial: acceptedStarts, + denied_process: deniedProcess, + denial: { + type: denial.type, + category: denial.error?.category || null, + message: denial.message, + }, + denied_process_allocated: false, + }; +} + +function deploymentProvenance() { + if (!strictVpsRestart) return null; + const systemGeneration = run( + "ssh", + sshArgs("readlink", "-f", "/run/current-system") + ).trim(); + const binarySha256 = run( + "ssh", + sshArgs( + "sha256sum", + "/opt/disasmer-public-release-dryrun/bin/disasmer-hosted-service" + ) + ) + .trim() + .split(/\s+/)[0]; + const fragment = run( + "ssh", + sshArgs("systemctl", "show", strictServiceUnit, "--property=FragmentPath", "--value") + ).trim(); + const serviceUnitSha256 = run( + "ssh", + sshArgs("sha256sum", fragment) + ) + .trim() + .split(/\s+/)[0]; + return { + system_generation: systemGeneration, + hosted_service_sha256: `sha256:${binarySha256}`, + service_unit: fragment, + service_unit_sha256: `sha256:${serviceUnitSha256}`, + }; +} + +function configurationProvenance() { + const configRepo = path.resolve( + process.env.DISASMER_DEPLOYMENT_CONFIG_REPO || + path.join(repo, "..", "michelpaulissen.com") + ); + return { + repository: configRepo, + revision: run("git", ["rev-parse", "HEAD"], { cwd: configRepo }).trim(), + clean: + run("git", ["status", "--short"], { cwd: configRepo }).trim() === "", + }; +} + +async function restartHostedServiceAndResume({ + disasmer, + disasmerNode, + projectDir, + scope, + workerArgs, + workerNode, + credentialPath, + credentialDigest, +}) { + if (!strictVpsRestart) { + return { + executed: false, + reason: "strict VPS restart access not supplied", + worker: null, + }; + } + const before = deploymentProvenance(); + run("ssh", sshArgs("systemctl", "restart", strictServiceUnit)); + const deadline = Date.now() + 120000; + let ping; + let lastError; + while (Date.now() < deadline) { + try { + ping = await sendHostedControl({ type: "ping" }); + if (ping.type === "pong") break; + } catch (error) { + lastError = error; + } + await delay(500); + } + assert.strictEqual( + ping?.type, + "pong", + `hosted service did not recover after restart: ${lastError?.message || "no pong"}` + ); + const afterFirstRestart = deploymentProvenance(); + assert.strictEqual(afterFirstRestart.system_generation, before.system_generation); + assert.strictEqual( + afterFirstRestart.hosted_service_sha256, + before.hosted_service_sha256 + ); + + const projects = runJson(disasmer, ["project", "list", ...scope], { + cwd: projectDir, + }); + assert(projects.project_count >= 1, "CLI session/project did not survive restart"); + const ephemeralRun = runJson( + disasmer, + ["run", "build", "--project", ".", "--json"], + { cwd: projectDir } + ); + assert.strictEqual(ephemeralRun.status, "main_launched"); + const ephemeralProcess = ephemeralRun.process; + await waitForCli( + "pre-restart ephemeral main to park", + () => + runJson( + disasmer, + ["process", "status", ...scope, "--process", ephemeralProcess], + { cwd: projectDir } + ), + (status) => status.live_process?.main_wait_state === "waiting_for_node", + 120000 + ); + + run("ssh", sshArgs("systemctl", "restart", strictServiceUnit)); + const secondDeadline = Date.now() + 120000; + ping = undefined; + lastError = undefined; + while (Date.now() < secondDeadline) { + try { + ping = await sendHostedControl({ type: "ping" }); + if (ping.type === "pong") break; + } catch (error) { + lastError = error; + } + await delay(500); + } + assert.strictEqual( + ping?.type, + "pong", + `hosted service did not recover after ephemeral-state restart: ${ + lastError?.message || "no pong" + }` + ); + const after = deploymentProvenance(); + assert.strictEqual(after.system_generation, before.system_generation); + assert.strictEqual(after.hosted_service_sha256, before.hosted_service_sha256); + const oldStatus = runJson( + disasmer, + ["process", "status", ...scope, "--process", ephemeralProcess], + { cwd: projectDir } + ); + assert.strictEqual(oldStatus.state, "not_active"); + assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); + + const worker = spawnJsonLines(disasmerNode, workerArgs, { + cwd: projectDir, + env: { ...process.env }, + }); + const ready = await worker.waitFor( + (value) => value.node_status === "ready", + "persisted worker ready after hosted service restart" + ); + assert.strictEqual(ready.node, workerNode); + assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); + + const restartedRun = runJson( + disasmer, + ["run", "restart", "--project", ".", "--json"], + { cwd: projectDir } + ); + assert.strictEqual(restartedRun.status, "main_launched"); + const restartedProcess = restartedRun.process; + const completed = await waitForCli( + "new process completion after hosted service restart", + () => + runJson( + disasmer, + ["task", "list", ...scope, "--process", restartedProcess], + { cwd: projectDir } + ), + (tasks) => + rawTaskEvents(tasks).some( + (event) => + event.task_definition === "task_add_one" && + event.terminal_state === "completed" && + JSON.stringify(event.result) === JSON.stringify({ SmallJson: 42 }) + ), + 5 * 60 * 1000 + ); + const completedEvent = rawTaskEvents(completed).find( + (event) => event.task_definition === "task_add_one" + ); + const aborted = runJson( + disasmer, + ["process", "abort", ...scope, "--process", restartedProcess, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(aborted.abort_request.process_slot_released, true); + return { + executed: true, + account_project_session_preserved: true, + node_identity_preserved: true, + old_process_terminated_honestly: true, + terminated_ephemeral_process: ephemeralProcess, + new_process: restartedProcess, + new_process_result: completedEvent.result, + before, + after_first_restart: afterFirstRestart, + after, + worker, + }; +} + +async function finishUserCredentialSecurity({ + disasmer, + projectDir, + scope, + sessionSecret, +}) { + const forged = assertDenied( + await sendHostedControl( + authenticatedRequest( + `forged-session-${crypto.randomBytes(24).toString("hex")}`, + { type: "list_projects" } + ) + ), + /not recognized|invalid|authentication|session/i, + "forged user session" + ); + + let expired = null; + const expiredFile = process.env.DISASMER_EXPIRED_USER_SESSION_FILE; + if (expiredFile) { + const oldSession = readJson(path.resolve(expiredFile)); + assert(oldSession.session_secret, "expired session evidence omitted its secret"); + expired = assertDenied( + await sendHostedControl( + authenticatedRequest(oldSession.session_secret, { type: "list_projects" }) + ), + /expired/i, + "expired user session" + ); + } else if (strictFullRelease) { + throw new Error( + "strict full release requires DISASMER_EXPIRED_USER_SESSION_FILE for a genuinely expired hosted session" + ); + } + + const logout = runJson(disasmer, ["logout", ...scope, "--yes"], { + cwd: projectDir, + }); + assert.strictEqual(logout.server_session_revocation.revoked, true); + const revoked = assertDenied( + await sendHostedControl( + authenticatedRequest(sessionSecret, { type: "list_projects" }) + ), + /revoked|not recognized/i, + "revoked user session" + ); + return { forged, expired, revoked }; +} + +async function dapVariables(client, variablesReference) { + const request = client.send("variables", { variablesReference }); + return (await client.response(request, "variables")).body.variables; +} + +async function runLiveDapEditRestart({ + disasmerDap, + disasmer, + projectDir, + scope, + worker, +}) { + const sourcePath = fs.realpathSync(path.join(projectDir, "src/build.rs")); + const originalSource = fs.readFileSync(sourcePath, "utf8"); + const sourceLines = originalSource.split(/\r?\n/); + const lineFor = (needle) => { + const line = sourceLines.findIndex((candidate) => candidate.includes(needle)) + 1; + assert(line > 0, `flagship source omitted ${needle}`); + return line; + }; + const mainLine = lineFor("pub async fn restart_main()"); + const taskLine = lineFor("fn task_add_one("); + const client = new DapClient({ + cwd: projectDir, + command: disasmerDap, + args: [], + env: { ...process.env }, + }); + let sourceRestored = false; + try { + const initialize = client.send("initialize", { + adapterID: "disasmer", + linesStartAt1: true, + columnsStartAt1: true, + }); + await client.response(initialize, "initialize"); + + const launch = client.send("launch", { + entry: "restart", + project: projectDir, + runtimeBackend: "live-services", + coordinatorEndpoint: serviceEndpoint, + }); + await client.response(launch, "launch"); + await client.waitFor( + (message) => message.type === "event" && message.event === "initialized" + ); + + const setBreakpoints = client.send("setBreakpoints", { + source: { path: sourcePath }, + breakpoints: [{ line: mainLine }, { line: taskLine }], + }); + const breakpointResponse = await client.response( + setBreakpoints, + "setBreakpoints" + ); + assert.deepStrictEqual( + breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified), + [true, true] + ); + + const configurationDone = client.send("configurationDone"); + await client.response(configurationDone, "configurationDone"); + const mainStop = await client.waitFor( + (message) => + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint" + ); + assert.strictEqual(mainStop.body.allThreadsStopped, true); + + const mainContinue = client.send("continue", { threadId: mainStop.body.threadId }); + await client.response(mainContinue, "continue"); + const taskStop = await client.waitFor( + (message) => + message.seq > mainStop.seq && + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint" + ); + assert.strictEqual(taskStop.body.allThreadsStopped, true); + assert.notStrictEqual(taskStop.body.threadId, mainStop.body.threadId); + + const stackTrace = client.send("stackTrace", { + threadId: taskStop.body.threadId, + startFrame: 0, + levels: 1, + }); + const taskFrames = (await client.response(stackTrace, "stackTrace")).body + .stackFrames; + assert.strictEqual(taskFrames[0].line, taskLine); + + const scopesRequest = client.send("scopes", { frameId: taskFrames[0].id }); + const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; + const argsScope = scopes.find( + (candidate) => candidate.name === "Task Args and Handles" + ); + const runtimeScope = scopes.find( + (candidate) => candidate.name === "Disasmer Runtime" + ); + assert(argsScope && runtimeScope); + const taskArgs = await dapVariables(client, argsScope.variablesReference); + assert( + taskArgs.some( + (variable) => + variable.name === "arg_0" && String(variable.value).includes("41") + ) + ); + const runtime = await dapVariables(client, runtimeScope.variablesReference); + assert( + runtime.some( + (variable) => + variable.name === "runtime_backend" && variable.value === "LiveServices" + ) + ); + assert( + runtime.some( + (variable) => variable.name === "state" && variable.value === "Frozen" + ) + ); + const dapProcess = runtime.find( + (variable) => variable.name === "virtual_process_id" + )?.value; + assert.match(dapProcess || "", /^vp-[0-9a-f]{12}$/); + + const taskContinue = client.send("continue", { threadId: taskStop.body.threadId }); + await client.response(taskContinue, "continue"); + await client.waitFor( + (message) => + message.seq > taskStop.seq && + message.type === "event" && + message.event === "terminated", + 5 * 60 * 1000 + ); + + const beforeEdit = runJson( + disasmer, + ["task", "list", ...scope, "--process", dapProcess], + { cwd: projectDir } + ); + const originalTaskEvent = rawTaskEvents(beforeEdit).find( + (event) => + event.task_definition === "task_add_one" && + event.terminal_state === "completed" + ); + assert(originalTaskEvent, "DAP restart entry omitted its original child event"); + assert.deepStrictEqual(originalTaskEvent.result, { SmallJson: 42 }); + + const editedSource = originalSource.replace( + " input + 1\n", + " input + 2 // strict hosted compatible restart probe\n" + ); + assert.notStrictEqual(editedSource, originalSource); + fs.writeFileSync(sourcePath, editedSource); + + const restartFrame = client.send("restartFrame", { + frameId: taskFrames[0].id, + }); + const restartResponse = await client.response(restartFrame, "restartFrame"); + const restartedStop = await client.waitFor( + (message) => + message.seq > restartResponse.seq && + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint" + ); + assert.strictEqual(restartedStop.body.allThreadsStopped, true); + const restartedContinue = client.send("continue", { + threadId: restartedStop.body.threadId, + }); + await client.response(restartedContinue, "continue"); + const restartedNodeReport = await worker.waitFor( + (value) => + value.virtual_thread !== originalTaskEvent.task && + value.task_assignment_response?.task_spec?.task_definition === + "task_add_one", + "edited task to execute on the live restarted node", + 5 * 60 * 1000 + ); + assert.strictEqual( + restartedNodeReport.node_status, + "completed", + `edited task restart failed on the live node: ${JSON.stringify(restartedNodeReport)}` + ); + assert.match(restartedNodeReport.stdout_tail, /"SmallJson":43/); + const restartedTask = restartedNodeReport.virtual_thread; + + const afterEdit = await waitForCli( + "edited live task event", + () => + runJson(disasmer, ["task", "list", ...scope, "--process", dapProcess], { + cwd: projectDir, + }), + (tasks) => + rawTaskEvents(tasks).some( + (event) => + event.task === restartedTask && + event.terminal_state === "completed" && + JSON.stringify(event.result) === JSON.stringify({ SmallJson: 43 }) + ), + 5 * 60 * 1000 + ); + const restartedEvent = rawTaskEvents(afterEdit).find( + (event) => event.task === restartedTask + ); + fs.writeFileSync(sourcePath, originalSource); + sourceRestored = true; + + return { + process: dapProcess, + main_breakpoint_line: mainLine, + task_breakpoint_line: taskLine, + main_all_threads_stopped: mainStop.body.allThreadsStopped, + task_all_threads_stopped: taskStop.body.allThreadsStopped, + original_task_instance: originalTaskEvent.task, + original_result: originalTaskEvent.result, + restarted_task_instance: restartedTask, + restarted_result: restartedEvent.result, + compatible_source_edit: "task_add_one: input + 1 -> input + 2", + original_arguments_preserved: true, + clean_vfs_boundary_used: true, + }; + } finally { + if (!sourceRestored) fs.writeFileSync(sourcePath, originalSource); + await client.close().catch(() => { + if (client.child.exitCode === null) client.child.kill("SIGKILL"); + }); + } +} + +async function main() { + requireEnabled(); + const manifest = readJson(manifestPath); + assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); + assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint); + assert.strictEqual(serviceAddr, "disasmer.michelpaulissen.com:443"); + + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-happy-")); + const installDir = path.join(workRoot, "install"); + const checkout = path.join(workRoot, "public-repo"); + const downloadPath = path.join(workRoot, "release.tar"); + const extractedArtifact = path.join(workRoot, "artifact"); + ensureDir(installDir); + run("tar", ["-xzf", binaryArchive(manifest), "-C", installDir]); + const publicSource = stagePublicCheckout(manifest, checkout); + + const disasmer = executable(installDir, "disasmer"); + const disasmerNode = executable(installDir, "disasmer-node"); + const disasmerDap = executable(installDir, "disasmer-debug-dap"); + const projectDir = path.join(checkout, "examples/launch-build-demo"); + const suffix = String(Date.now()); + const requestedProject = `project-${suffix}`; + const workerNode = `worker-${suffix}`; + + const login = runJson( + disasmer, + ["login", "--browser", "--json", "--project-id", requestedProject], + { + cwd: projectDir, + env: { ...process.env, DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand }, + } + ); + assert.strictEqual(login.plan.coordinator, serviceEndpoint); + assert.strictEqual(login.boundary.scoped_cli_session_received, true); + assert.strictEqual(login.boundary.provider_tokens_exposed_to_cli, false); + assert.strictEqual(login.boundary.provider_tokens_sent_to_nodes, false); + const loginSession = login.coordinator_response.session; + assert(loginSession, "hosted browser login omitted its scoped session"); + const sessionSecret = + loginSession.cli_session_secret || loginSession.session_secret; + assert(sessionSecret, "hosted browser login omitted the CLI session credential"); + const tenant = loginSession.tenant; + const project = loginSession.project; + const user = loginSession.user; + for (const [name, value] of Object.entries({ tenant, project, user })) { + assert.strictEqual(typeof value, "string", `hosted session omitted ${name}`); + assert.notStrictEqual(value, "", `hosted session returned an empty ${name}`); + } + const receivedRequestedProject = + project === requestedProject || project.endsWith(`-${requestedProject}`); + assert( + receivedRequestedProject, + "login did not return the requested server-owned default project" + ); + const scope = [ + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--user", + user, + "--json", + ]; + + const projectInit = runJson( + disasmer, + [ + "project", + "init", + ...scope, + "--new-project", + project, + "--name", + "CLI Happy Path", + "--yes", + ], + { cwd: projectDir } + ); + assert.strictEqual(projectInit.command, "project init"); + assert.strictEqual(projectInit.project_config_written, true); + assert.strictEqual(projectInit.coordinator_response.type, "project_created"); + + const projectList = runJson(disasmer, ["project", "list", ...scope], { + cwd: projectDir, + }); + assert(projectList.project_count >= 1); + const projectSelect = runJson( + disasmer, + ["project", "select", ...scope, project], + { cwd: projectDir } + ); + assert.strictEqual(projectSelect.command, "project select"); + + const inspection = runJson(disasmer, ["inspect", "--project", ".", "--json"], { + cwd: projectDir, + }); + assert( + inspection.metadata.environments.some((environment) => environment.name === "linux") + ); + assert(Array.isArray(inspection.source_provider_statuses)); + assert(inspection.source_provider_manifest); + + const runReport = runJson( + disasmer, + ["run", "build", "--project", ".", "--json"], + { cwd: projectDir } + ); + assert.strictEqual(runReport.command, "run"); + assert.strictEqual(runReport.status, "main_launched"); + assert.strictEqual(runReport.task_launch.type, "main_launched"); + const processId = runReport.process; + + const parkedStatus = await waitForCli( + "hosted coordinator main to park before node enrollment", + () => + runJson( + disasmer, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ), + (status) => + status.live_process?.main_state === "running" && + status.live_process?.main_wait_state === "waiting_for_node" && + status.live_process?.connected_nodes?.length === 0, + 120000 + ); + + const grant = runJson(disasmer, ["node", "enroll", ...scope], { + cwd: projectDir, + }); + assert.strictEqual(grant.command, "node enroll"); + const attach = runJson( + disasmer, + [ + "node", + "attach", + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--node", + workerNode, + "--enrollment-grant", + grant.enrollment_grant.grant, + "--json", + ], + { cwd: projectDir } + ); + assert.strictEqual(attach.command, "node attach"); + assert.strictEqual(attach.boundary.used_enrollment_exchange, true); + + const credentialDir = path.join(projectDir, ".disasmer", "nodes"); + const credentialFiles = fs + .readdirSync(credentialDir) + .filter((file) => file.endsWith(".json")); + assert.strictEqual(credentialFiles.length, 1, "expected one persisted node credential"); + const credentialPath = path.join(credentialDir, credentialFiles[0]); + const credentialBefore = fs.readFileSync(credentialPath); + const credentialDigest = sha256(credentialBefore); + if (process.platform !== "win32") { + assert.strictEqual(fs.statSync(credentialPath).mode & 0o777, 0o600); + } + + const workerArgs = [ + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--node", + workerNode, + "--worker", + "--project-root", + projectDir, + "--assignment-poll-ms", + "500", + "--emit-ready", + ]; + const spawnWorker = () => + spawnJsonLines(disasmerNode, workerArgs, { + cwd: projectDir, + env: { ...process.env }, + }); + + let worker = spawnWorker(); + try { + const firstReady = await worker.waitFor( + (value) => value.node_status === "ready", + "first persisted worker ready" + ); + assert.strictEqual(firstReady.node, workerNode); + + const completedTasks = await waitForCli( + "real hosted flagship completion", + () => + runJson(disasmer, ["task", "list", ...scope, "--process", processId], { + cwd: projectDir, + }), + (tasks) => completedFlagship(rawTaskEvents(tasks)) + ); + const firstEvents = rawTaskEvents(completedTasks); + assert( + firstEvents + .filter((event) => event.executor === "node") + .every((event) => event.node === workerNode) + ); + assert( + firstEvents.some( + (event) => + event.task_definition === "package_release" && + event.terminal_state === "completed" && + contentAddressedArtifactId(event, "release.tar") + ) + ); + + await stopChild(worker.child); + worker = spawnWorker(); + const secondReady = await worker.waitFor( + (value) => value.node_status === "ready", + "restarted persisted worker ready" + ); + assert.strictEqual(secondReady.node, workerNode); + assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); + + const nodeStatus = runJson( + disasmer, + ["node", "status", ...scope, "--node", workerNode], + { cwd: projectDir } + ); + assert(JSON.stringify(nodeStatus.response).includes(workerNode)); + + const sourceEvent = firstEvents.find( + (event) => + event.task_definition === "prepare_source" && + event.terminal_state === "completed" + ); + assert(sourceEvent, "flagship omitted its completed source task"); + const restartTask = runJson( + disasmer, + [ + "task", + "restart", + ...scope, + sourceEvent.task, + "--process", + processId, + "--yes", + ], + { cwd: projectDir } + ); + assert.strictEqual(restartTask.restart_request.accepted, true); + const restartedTask = restartTask.response.restarted_task_instance; + assert(restartedTask && restartedTask !== sourceEvent.task); + const restartedCompletion = await worker.waitFor( + (value) => + value.node_status === "completed" && value.virtual_thread === restartedTask, + "restarted node to complete work under the persisted identity", + 5 * 60 * 1000 + ); + assert.strictEqual(restartedCompletion.terminal_state, "completed"); + assert.strictEqual(sha256(fs.readFileSync(credentialPath)), credentialDigest); + + const processStatus = runJson( + disasmer, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ); + assert.strictEqual(processStatus.command, "process status"); + assert(processStatus.current_task_count >= 5); + + const logs = runJson( + disasmer, + ["logs", ...scope, "--process", processId], + { cwd: projectDir } + ); + assert(logs.log_entries.length >= 4); + + const artifacts = runJson( + disasmer, + ["artifact", "list", ...scope, "--process", processId], + { cwd: projectDir } + ); + const releaseArtifact = artifacts.artifacts.find((artifact) => { + const digest = artifact.digest; + return ( + typeof digest === "string" && + /^sha256:[0-9a-f]{64}$/.test(digest) && + artifact.artifact === `release.tar-${digest.slice("sha256:".length)}` + ); + }); + assert(releaseArtifact, "hosted flagship did not publish release.tar"); + assert.strictEqual(releaseArtifact.state, "metadata_flushed"); + + const download = runJson( + disasmer, + [ + "artifact", + "download", + ...scope, + releaseArtifact.artifact, + "--to", + downloadPath, + ], + { cwd: projectDir } + ); + assert.strictEqual(download.download_session.link_issued, true); + assert.strictEqual(download.local_download.local_bytes_written_by_cli, true); + assert.strictEqual(download.local_download.verified_digest, releaseArtifact.digest); + assert.strictEqual(sha256(fs.readFileSync(downloadPath)), releaseArtifact.digest); + + ensureDir(extractedArtifact); + const archiveEntries = run("tar", ["-tf", downloadPath]); + assert.match(archiveEntries, /(?:^|\n)hello-disasmer(?:\n|$)/); + run("tar", ["-xf", downloadPath, "-C", extractedArtifact]); + const builtExecutable = path.join(extractedArtifact, "hello-disasmer"); + assert(fs.existsSync(builtExecutable)); + const builtOutput = run(builtExecutable, []).trim(); + assert.strictEqual(builtOutput, "hello from a real Disasmer build"); + + const abortFlagship = runJson( + disasmer, + ["process", "abort", ...scope, "--process", processId, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(abortFlagship.abort_request.accepted, true); + assert.strictEqual(abortFlagship.abort_request.process_slot_released, true); + await waitForCli( + "flagship process slot release before live DAP launch", + () => + runJson( + disasmer, + ["process", "status", ...scope, "--process", processId], + { cwd: projectDir } + ), + (status) => status.state === "not_active", + 120000 + ); + + const dapEditRestart = await runLiveDapEditRestart({ + disasmerDap, + disasmer, + projectDir, + scope, + worker, + }); + + const restart = runJson( + disasmer, + ["process", "restart", ...scope, "--process", dapEditRestart.process, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(restart.restart_request.accepted, true); + const cancel = runJson( + disasmer, + ["process", "cancel", ...scope, "--process", dapEditRestart.process, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(cancel.cancel_request.accepted, true); + + const tenantIsolation = await runSecondTenantIsolation({ + firstTenant: tenant, + firstProject: project, + firstProcess: dapEditRestart.process, + firstNode: workerNode, + firstArtifact: releaseArtifact.artifact, + manifest, + }); + const abortDap = runJson( + disasmer, + ["process", "abort", ...scope, "--process", dapEditRestart.process, "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(abortDap.abort_request.process_slot_released, true); + + const securityNode = await prepareLiveNodeCredentialSecurity({ + disasmer, + projectDir, + scope, + tenant, + project, + suffix, + }); + const agentCredentialSecurity = await runLiveAgentCredentialSecurity({ + disasmer, + projectDir, + scope, + sessionSecret, + tenant, + project, + suffix, + }); + + await stopChild(worker.child); + const liveSoak = await runLiveSoak({ + disasmer, + projectDir, + scope, + securityNode, + }); + const revokedNodeCredential = await revokeSecurityNode({ + disasmer, + projectDir, + scope, + securityNode, + }); + const quotaPreallocation = await runLiveQuotaPreallocation({ + sessionSecret, + suffix, + }); + const serviceRestart = await restartHostedServiceAndResume({ + disasmer, + disasmerNode, + projectDir, + scope, + workerArgs, + workerNode, + credentialPath, + credentialDigest, + }); + if (serviceRestart.worker) worker = serviceRestart.worker; + const userCredentialSecurity = await finishUserCredentialSecurity({ + disasmer, + projectDir, + scope, + sessionSecret, + }); + const configProvenance = configurationProvenance(); + const fullReleasePassed = + strictFullRelease && + tenantIsolation.executed && + liveSoak.executed && + serviceRestart.executed && + Boolean(userCredentialSecurity.expired) && + manifest.source_tree_clean === true && + configProvenance.clean; + + const report = { + kind: "disasmer-cli-happy-path-live", + release_name: manifest.release_name, + source_commit: manifest.source_commit, + public_tree_identity: manifest.public_tree_identity, + service_addr: serviceAddr, + default_hosted_coordinator_endpoint: serviceEndpoint, + public_repository_url: publicSource.published ? publicSource.url : null, + public_source: publicSource, + tenant, + project, + worker_node: workerNode, + process: processId, + signed_in: true, + received_default_project: receivedRequestedProject, + process_started_before_node: true, + parked_main_observed: { + main_state: parkedStatus.live_process.main_state, + main_wait_state: parkedStatus.live_process.main_wait_state, + connected_nodes: parkedStatus.live_process.connected_nodes, + }, + node_enrollment_grants_used: 2, + persisted_node_credential_mode: + process.platform === "win32" ? null : fs.statSync(credentialPath).mode & 0o777, + persisted_node_credential_digest: credentialDigest, + node_restarted_without_grant: true, + restarted_node_completed_task: restartedTask, + flagship_task_definitions: [ + "prepare_source", + "compile_linux", + "package_release", + ], + rootless_podman_flagship_completed: true, + downstream_release_artifact: releaseArtifact, + artifact_download: { + bytes_written: download.local_download.bytes_written, + verified_digest: download.local_download.verified_digest, + executable_output: builtOutput, + }, + flagship_process_aborted_for_dap: true, + live_dap_edit_restart: dapEditRestart, + process_restart_requested: true, + process_cancel_requested: true, + dap_process_aborted: true, + tenant_isolation: tenantIsolation, + credential_security: { + user: userCredentialSecurity, + node: { + node: securityNode.node, + credential_digest: securityNode.credential_digest, + ...securityNode.evidence, + revoked: revokedNodeCredential.denied, + }, + agent: agentCredentialSecurity, + }, + quota_preallocation: quotaPreallocation, + live_soak: liveSoak, + hosted_service_restart: { + ...serviceRestart, + worker: undefined, + }, + release_binding: { + manifest: manifestPath, + source_commit: manifest.source_commit, + source_tree_clean: manifest.source_tree_clean, + public_tree_identity: manifest.public_tree_identity, + binary_digests: manifest.binary_digests, + deployment: serviceRestart.after || deploymentProvenance(), + configuration: configProvenance, + }, + commands, + acceptance_result: fullReleasePassed ? "passed" : "partial", + }; + ensureDir(path.dirname(reportPath)); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`CLI happy-path live smoke passed: ${reportPath}`); + } finally { + await stopChild(worker?.child); + } +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/cli-install-smoke.js b/scripts/cli-install-smoke.js new file mode 100755 index 0000000..b2fb2d6 --- /dev/null +++ b/scripts/cli-install-smoke.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const temp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-install-")); +const installRoot = path.join(temp, "install"); +const targetDir = + process.env.DISASMER_CLI_INSTALL_CARGO_TARGET_DIR || + process.env.CARGO_TARGET_DIR || + path.join(repo, "target"); +const project = path.join(repo, "examples/launch-build-demo"); +const binName = process.platform === "win32" ? "disasmer.exe" : "disasmer"; +const installedBin = path.join(installRoot, "bin", binName); + +try { + cp.execFileSync( + "cargo", + [ + "install", + "--path", + "crates/disasmer-cli", + "--bin", + "disasmer", + "--root", + installRoot, + "--debug" + ], + { + cwd: repo, + env: { + ...process.env, + CARGO_TARGET_DIR: targetDir + }, + stdio: "inherit" + } + ); + + assert(fs.existsSync(installedBin), "installed disasmer binary must exist"); + + const inspection = JSON.parse( + cp.execFileSync( + installedBin, + ["bundle", "inspect", "--project", project, "--json"], + { cwd: repo, encoding: "utf8" } + ) + ); + + assert.strictEqual(inspection.project, project); + assert.strictEqual(inspection.metadata.embeds_full_container_images, false); + assert(inspection.metadata.environments.some((env) => env.name === "linux")); + assert(inspection.metadata.selected_inputs.some((input) => input.path === "src/build.rs")); +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} + +console.log("CLI install smoke passed"); diff --git a/scripts/cli-local-run-smoke.js b/scripts/cli-local-run-smoke.js new file mode 100755 index 0000000..7bdb737 --- /dev/null +++ b/scripts/cli-local-run-smoke.js @@ -0,0 +1,269 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { configurePodmanTestEnvironment } = require("./podman-test-env"); + +const repo = path.resolve(__dirname, ".."); +configurePodmanTestEnvironment(repo); +if ( + !process.env.DISASMER_PODMAN_NIX_SHELL && + cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status !== 0 && + cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0 +) { + cp.execFileSync( + "nix", + ["shell", "nixpkgs#podman", "--command", "node", __filename], + { + cwd: repo, + env: { + ...process.env, + DISASMER_PODMAN_NIX_SHELL: "1", + }, + stdio: "inherit", + } + ); + process.exit(0); +} +const project = path.join(repo, "examples/launch-build-demo"); + +cp.execFileSync( + "cargo", + ["build", "-q", "-p", "disasmer-node", "--bin", "disasmer-node"], + { cwd: repo, stdio: "inherit" } +); + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function runCli(args, env = {}) { + return new Promise((resolve, reject) => { + const child = cp.spawn( + "cargo", + ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + { + cwd: repo, + env: { + ...process.env, + ...env + } + } + ); + const cliPid = child.pid; + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("exit", (code) => { + if (code !== 0) { + reject(new Error(`CLI run failed with code ${code}\n${stderr}`)); + return; + } + try { + resolve({ pid: cliPid, report: JSON.parse(stdout) }); + } catch (error) { + reject(new Error(`CLI output was not JSON: ${stdout}\n${error.stack || error.message}`)); + } + }); + }); +} + +(async () => { + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback" + ], + { cwd: repo } + ); + assert(Number.isInteger(coordinator.pid)); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const { pid: cliPid, report } = await runCli([ + "run", + "--coordinator", + `${addr.host}:${addr.port}`, + "--project", + project, + "--json", + ]); + assert(Number.isInteger(cliPid)); + assert.notStrictEqual(cliPid, coordinator.pid); + assert.strictEqual(report.plan.entry, "build"); + assert.deepStrictEqual(report.plan.session, "Anonymous"); + assert.strictEqual(report.boundary.cli_process_started_node_process, true); + assert.strictEqual(report.boundary.cli_process_started_coordinator_process, false); + assert(Number.isInteger(report.boundary.spawned_node_process_id)); + assert.notStrictEqual(report.boundary.spawned_node_process_id, cliPid); + assert.notStrictEqual(report.boundary.spawned_node_process_id, coordinator.pid); + assert.strictEqual(report.boundary.node_session_requests, 0); + assert.strictEqual(report.node_report.node_status, "completed"); + assert.strictEqual(report.node_report.execution_substrate, "wasm"); + assert.strictEqual(report.node_report.task_spawn_host_import, true); + assert.strictEqual( + report.node_report.pre_node_process_status.processes.length, + 1 + ); + assert.deepStrictEqual( + report.node_report.pre_node_process_status.processes[0].connected_nodes, + [] + ); + assert.strictEqual( + report.node_report.pre_node_process_status.processes[0].main_state, + "running" + ); + assert.strictEqual( + report.node_report.pre_node_process_status.processes[0].main_wait_state, + "waiting_for_node", + "the coordinator must expose that the capless main is parked on placement before a node exists" + ); + assert.strictEqual( + report.node_report.pre_node_process_status.processes[0].main_task_instance, + report.node_report.run.task_instance + ); + assert.strictEqual(report.node_report.run.status, "main_launched"); + assert.strictEqual(report.node_report.join.type, "task_joined"); + const process = report.node_report.run.process; + assert.strictEqual(process, "vp-current"); + + const events = await send(addr, { + type: "list_task_events", + tenant: "tenant", + project: "project", + actor_user: "user", + process + }); + assert.strictEqual(events.type, "task_events"); + assert(events.events.length >= 4); + assert( + events.events + .filter((event) => event.executor === "node") + .every((event) => event.node === "node-cli-local") + ); + assert( + events.events.some( + (event) => + event.executor === "coordinator_main" && + event.node === "coordinator-main" + ) + ); + assert(events.events.every((event) => event.process === process)); + assert.deepStrictEqual( + new Set(events.events.map((event) => event.task_definition)), + new Set([ + report.node_report.run.task_definition, + "prepare_source", + "compile_linux", + "package_release", + ]) + ); + assert.strictEqual( + new Set(events.events.map((event) => event.task)).size, + events.events.length, + "every live task event must retain its unique instance identity" + ); + assert( + events.events.some( + (event) => event.task === report.node_report.run.task_instance + ) + ); + assert(events.events.some((event) => event.task.endsWith(":child:1"))); + assert(events.events.some((event) => event.task.endsWith(":child:2"))); + assert(events.events.some((event) => event.task.endsWith(":child:3"))); + assert(events.events.some((event) => event.artifact_path)); + } finally { + coordinator.kill("SIGTERM"); + } + + const { pid: autoCliPid, report: autoReport } = await runCli([ + "run", + "--local", + "--project", + project, + "--json", + ]); + assert(Number.isInteger(autoCliPid)); + assert.strictEqual(autoReport.plan.entry, "build"); + assert.deepStrictEqual(autoReport.plan.coordinator, "LocalOnly"); + assert.deepStrictEqual(autoReport.plan.session, "Anonymous"); + assert.strictEqual(autoReport.boundary.cli_process_started_node_process, true); + assert.strictEqual(autoReport.boundary.cli_process_started_coordinator_process, true); + assert.match(autoReport.boundary.coordinator_address, /^127\.0\.0\.1:\d+$/); + assert(Number.isInteger(autoReport.boundary.coordinator_process_id)); + assert(Number.isInteger(autoReport.boundary.spawned_node_process_id)); + assert.notStrictEqual(autoReport.boundary.coordinator_process_id, autoCliPid); + assert.notStrictEqual(autoReport.boundary.spawned_node_process_id, autoCliPid); + assert.notStrictEqual( + autoReport.boundary.spawned_node_process_id, + autoReport.boundary.coordinator_process_id + ); + assert.strictEqual(autoReport.boundary.node_session_requests, 0); + assert.strictEqual(autoReport.node_report.node_status, "completed"); + assert.strictEqual(autoReport.node_report.execution_substrate, "wasm"); + assert.strictEqual(autoReport.node_report.task_spawn_host_import, true); + assert.strictEqual(autoReport.node_report.run.status, "main_launched"); + assert.strictEqual(autoReport.node_report.join.type, "task_joined"); + + console.log("CLI local run smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/cli-login-smoke.js b/scripts/cli-login-smoke.js new file mode 100644 index 0000000..e442467 --- /dev/null +++ b/scripts/cli-login-smoke.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const coordinator = "https://coord.example.test"; +const defaultHostedCoordinatorEndpoint = "https://disasmer.michelpaulissen.com"; + +function disasmer(args) { + return JSON.parse( + cp.execFileSync( + "cargo", + ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + { cwd: repo, encoding: "utf8" } + ) + ); +} + +function disasmerRaw(args, env = {}) { + return cp.spawnSync( + "cargo", + ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + } + ); +} + +const browser = disasmer(["login", "--plan", "--coordinator", coordinator, "--json"]); +assert.strictEqual(browser.coordinator, coordinator); +assert(browser.human_flow.Browser, "browser login should be available for human users"); +assert.strictEqual(browser.human_flow.Browser.authorization_url, null); +assert.strictEqual(browser.human_flow.Browser.server_owns_state, true); +assert.strictEqual(browser.human_flow.Browser.server_owns_nonce, true); +assert.strictEqual(browser.human_flow.Browser.pkce_required, true); +assert.strictEqual(browser.human_flow.Browser.hosted_callback, true); +assert.strictEqual(browser.human_flow.Browser.cli_receives_provider_authorization_code, false); +assert.strictEqual(browser.human_flow.Browser.cli_submits_identity_claims, false); + +const defaultBrowser = disasmer(["login", "--plan", "--json"]); +assert.strictEqual(defaultBrowser.coordinator, defaultHostedCoordinatorEndpoint); +assert(defaultBrowser.human_flow.Browser); + +const nonInteractiveBrowser = disasmerRaw( + ["login", "--browser", "--non-interactive", "--coordinator", coordinator, "--json"], + { + DISASMER_BROWSER_OPEN_COMMAND: + "node -e 'require(\"fs\").writeFileSync(\"/tmp/disasmer-browser-should-not-open\", \"opened\")'", + } +); +assert.strictEqual(nonInteractiveBrowser.status, 20, nonInteractiveBrowser.stderr); +assert.doesNotMatch(nonInteractiveBrowser.stderr, /Opening Disasmer browser login/); +const nonInteractiveReport = JSON.parse(nonInteractiveBrowser.stdout); +assert.strictEqual(nonInteractiveReport.status, "authentication_required"); +assert.strictEqual(nonInteractiveReport.non_interactive, true); +assert.strictEqual(nonInteractiveReport.browser_opened, false); +assert.strictEqual(nonInteractiveReport.machine_error.category, "authentication"); +assert.strictEqual(nonInteractiveReport.machine_error.stable_exit_code, 20); +assert( + nonInteractiveReport.machine_error.next_actions.includes( + "rerun without --non-interactive to open the browser" + ) +); + +console.log("CLI login smoke passed"); diff --git a/scripts/cli-output-mode-smoke.js b/scripts/cli-output-mode-smoke.js new file mode 100644 index 0000000..e7eec12 --- /dev/null +++ b/scripts/cli-output-mode-smoke.js @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const project = path.join(repo, "examples/launch-build-demo"); +const isolatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-output-")); + +function disasmer(args, env = {}, cwd = repo) { + return cp.execFileSync( + "cargo", + [ + "run", + "-q", + "--manifest-path", + path.join(repo, "Cargo.toml"), + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + ...args, + ], + { + cwd, + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + } + ); +} + +function json(args, env, cwd) { + return JSON.parse(disasmer(args, env, cwd)); +} + +function assertHuman(name, output, requiredPatterns) { + assert( + !output.trimStart().startsWith("{"), + `${name} default output should be human-readable text, not JSON` + ); + for (const pattern of requiredPatterns) { + assert.match(output, pattern, `${name} human output missing ${pattern}`); + } +} + +const helpHuman = disasmer(["help"]); +assertHuman("help", helpHuman, [ + /Primary workflow:/, + /disasmer login --browser/, + /disasmer project init/, + /disasmer node attach; disasmer-node --worker/, + /Disasmer: Launch Virtual Process/, + /Hosted account creation happens in the browser login flow/, + /--json/, +]); + +const loginHuman = disasmer([ + "login", + "--plan", + "--coordinator", + "https://coord.example.test", +]); +assertHuman("login", loginHuman, [ + /Disasmer login/, + /flow: browser/, +]); + +const loginJson = json([ + "login", + "--plan", + "--coordinator", + "https://coord.example.test", + "--json", +]); +assert.strictEqual(loginJson.coordinator, "https://coord.example.test"); +assert(loginJson.human_flow.Browser); +assert.strictEqual(loginJson.human_flow.Browser.hosted_callback, true); +assert.strictEqual(loginJson.human_flow.Browser.cli_submits_identity_claims, false); + +const doctorHuman = disasmer(["doctor"]); +assertHuman("doctor", doctorHuman, [ + /Disasmer doctor/, + /coordinator reachability: not_configured/, + /dependencies:/, + /auth:/, + /node capabilities:/, + /node readiness: (ready_to_attach|local_dependencies_missing|limited_capabilities)/, + /node next:/, +]); + +const doctorJson = json(["doctor", "--json"]); +assert.strictEqual(doctorJson.coordinator_reachability.checked, false); +assert.strictEqual(doctorJson.coordinator_reachability.status, "not_configured"); +assert( + ["ready_to_attach", "local_dependencies_missing", "limited_capabilities"].includes( + doctorJson.node_readiness_summary.status + ) +); +assert.strictEqual(doctorJson.node_readiness_summary.explicit_attach_required, true); +assert.strictEqual( + doctorJson.node_readiness_summary.command_execution_capability, + true +); +assert(Array.isArray(doctorJson.node_readiness_summary.missing_local_dependencies)); +assert(doctorJson.node_readiness_summary.next_actions.length >= 2); + +const authJson = json(["auth", "status", "--json"], { + DISASMER_TOKEN: "token", + DISASMER_TOKEN_EXPIRES_AT: "2026-07-04T00:00:00Z", +}, isolatedCwd); +assert.strictEqual(authJson.session.kind, "human"); +assert.strictEqual(authJson.session.token_expiry_posture, "expires_at"); +assert.strictEqual(authJson.session.expires_at, "2026-07-04T00:00:00Z"); +assert.strictEqual(authJson.coordinator_account_status.checked, false); +assert.strictEqual(authJson.coordinator_account_status.account_status, "unknown"); +assert.strictEqual( + authJson.coordinator_account_status.private_moderation_details_exposed, + false +); + +const inspectHuman = disasmer(["bundle", "inspect", "--project", project]); +assertHuman("bundle inspect", inspectHuman, [ + /Disasmer bundle inspect/, + /bundle: sha256:/, + /environments:/, +]); + +const inspectJson = json(["bundle", "inspect", "--project", project, "--json"]); +assert.strictEqual(inspectJson.project, project); +assert.match(inspectJson.metadata.identity, /^sha256:/); +assert.match(inspectJson.metadata.wasm_code, /^sha256:/); +assert.strictEqual(inspectJson.metadata.task_metadata.default_entrypoint, "build"); +assert.deepStrictEqual(inspectJson.metadata.task_metadata.entrypoints, [ + "build", + "fail", + "restart", +]); +assert.strictEqual( + inspectJson.metadata.source_metadata.transfer_policy.coordinator_receives_source_bytes_by_default, + false +); +assert.strictEqual( + inspectJson.metadata.source_metadata.transfer_policy.default_full_repo_tarball, + false +); +assert.strictEqual(inspectJson.metadata.debug_metadata.dap_virtual_process, true); +assert.strictEqual( + inspectJson.metadata.large_input_policy.selected_inputs_are_content_digests, + true +); +assert.strictEqual(inspectJson.metadata.large_input_policy.selected_input_bytes_included, false); +assert.strictEqual(inspectJson.metadata.large_input_policy.full_repository_bytes_included, false); +assert.strictEqual( + inspectJson.metadata.large_input_policy.silent_task_argument_serialization, + false +); +assert(inspectJson.metadata.large_input_policy.supported_handle_types.includes("SourceSnapshot")); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.source_edits_can_restart_from_clean_task_boundary, + true +); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.requires_clean_checkpoint_boundary, + true +); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.compares_task_abi, + inspectJson.metadata.task_metadata.task_abi +); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.incompatible_changes_require_whole_process_restart, + true +); + +console.log("CLI output mode smoke passed"); diff --git a/scripts/code-size-guard.js b/scripts/code-size-guard.js new file mode 100644 index 0000000..ad7fae6 --- /dev/null +++ b/scripts/code-size-guard.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const maxProductionLines = 1000; + +const ignoredDirs = new Set([".git", ".cache", ".local", "target", "node_modules"]); +const rootIgnoredDirs = new Set(["experiments"]); + +function isTestRustFile(relativePath) { + const normalized = relativePath.split(path.sep).join("/"); + return ( + normalized.endsWith("/tests.rs") || + normalized.includes("/tests/") || + normalized.endsWith("_test.rs") + ); +} + +function lineCount(source) { + return source.endsWith("\n") + ? source.split("\n").length - 1 + : source.split("\n").length; +} + +function productionSource(source) { + // A trailing cfg(test) module is not compiled into the product and therefore + // is not production business logic. Keeping it next to the implementation is + // useful when it exercises private invariants; count only the source before + // that explicitly test-only suffix. + const testModule = /^#\[cfg\(test\)\]\r?\nmod\s+[A-Za-z_][A-Za-z0-9_]*\s*\{/m.exec(source); + return testModule ? source.slice(0, testModule.index) : source; +} + +function walk(directory, results = []) { + const entries = fs.readdirSync(directory, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + const relativePath = path.relative(repo, fullPath); + if (entry.isDirectory()) { + if (ignoredDirs.has(entry.name)) continue; + if (!relativePath.includes(path.sep) && rootIgnoredDirs.has(entry.name)) continue; + walk(fullPath, results); + continue; + } + if (entry.isFile() && entry.name.endsWith(".rs")) { + results.push(relativePath); + } + } + return results; +} + +const oversizedProductionFiles = []; +const oversizedTestFiles = []; + +for (const relativePath of walk(repo)) { + const source = fs.readFileSync(path.join(repo, relativePath), "utf8"); + const testOnlyFile = isTestRustFile(relativePath); + const lines = lineCount(testOnlyFile ? source : productionSource(source)); + if (lines <= maxProductionLines) continue; + + const entry = { path: relativePath, lines }; + if (testOnlyFile) { + oversizedTestFiles.push(entry); + } else { + oversizedProductionFiles.push(entry); + } +} + +assert.deepStrictEqual( + oversizedProductionFiles, + [], + `production Rust files must stay at or below ${maxProductionLines} lines; split business logic before adding more code` +); + +if (oversizedTestFiles.length > 0) { + console.log( + `Code-size guard ignored ${oversizedTestFiles.length} oversized test-only Rust file(s): ${oversizedTestFiles + .map((entry) => `${entry.path} (${entry.lines})`) + .join(", ")}` + ); +} + +console.log("Code-size guard passed"); diff --git a/scripts/coordinator-wire.js b/scripts/coordinator-wire.js new file mode 100644 index 0000000..7761736 --- /dev/null +++ b/scripts/coordinator-wire.js @@ -0,0 +1,43 @@ +let requestId = 0; + +function coordinatorWireRequest(payload, prefix = "acceptance") { + if (payload && payload.type === "coordinator_request") return payload; + if (!payload || typeof payload.type !== "string" || !payload.type.trim()) { + throw new Error("coordinator payload must have a non-empty type"); + } + requestId += 1; + return { + type: "coordinator_request", + protocol_version: 1, + request_id: `${prefix}-${process.pid}-${requestId}`, + operation: payload.type, + authentication: authenticationMetadata(payload), + payload, + }; +} + +function authenticationMetadata(payload) { + if (payload.type === "authenticated") { + return { + kind: "cli_session", + session: true, + request_operation: payload.request?.type || "unknown", + }; + } + if (payload.type === "signed_node" || payload.node_signature) { + return { kind: "node_signature", node: payload.node || null }; + } + if (payload.agent_signature) { + return { + kind: "agent_signature", + agent: payload.actor_agent || null, + fingerprint: payload.agent_public_key_fingerprint || null, + }; + } + if (payload.admin_token) { + return { kind: "admin_credential" }; + } + return { kind: "none" }; +} + +module.exports = { coordinatorWireRequest }; diff --git a/scripts/dap-client.js b/scripts/dap-client.js new file mode 100644 index 0000000..aaa8445 --- /dev/null +++ b/scripts/dap-client.js @@ -0,0 +1,132 @@ +const cp = require("child_process"); + +class DapClient { + constructor({ + cwd = process.cwd(), + env = process.env, + command = "cargo", + args = [ + "run", + "-q", + "-p", + "disasmer-dap", + "--bin", + "disasmer-debug-dap", + ], + } = {}) { + this.child = cp.spawn( + command, + args, + { cwd, env } + ); + this.seq = 1; + this.buffer = Buffer.alloc(0); + this.messages = []; + this.waiters = []; + this.stderr = ""; + + this.child.stdout.on("data", (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.parse(); + }); + this.child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString(); + }); + this.child.on("exit", () => this.flushWaiters()); + } + + send(command, args = {}) { + const seq = this.seq++; + const message = { seq, type: "request", command, arguments: args }; + const payload = Buffer.from(JSON.stringify(message)); + this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); + this.child.stdin.write(payload); + return seq; + } + + async response(seq, command) { + const message = await this.waitFor( + (item) => + item.type === "response" && + item.request_seq === seq && + item.command === command + ); + if (!message.success) { + throw new Error( + `DAP ${command} failed: ${message.message || JSON.stringify(message)}\nAdapter stderr:\n${this.stderr}` + ); + } + return message; + } + + async failure(seq, command) { + const message = await this.waitFor( + (item) => + item.type === "response" && + item.request_seq === seq && + item.command === command + ); + if (message.success) { + throw new Error(`DAP ${command} unexpectedly succeeded`); + } + return message; + } + + waitFor(predicate, timeoutMs = 120000) { + const existing = this.messages.find(predicate); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.child.kill("SIGKILL"); + const recent = this.messages + .slice(-10) + .map((message) => JSON.stringify(message)) + .join("\n"); + reject( + new Error( + `timed out waiting for DAP message\nRecent DAP messages:\n${recent}\nAdapter stderr:\n${this.stderr}` + ) + ); + }, timeoutMs); + this.waiters.push({ predicate, resolve, timer }); + }); + } + + parse() { + while (true) { + const headerEnd = this.buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = this.buffer.slice(0, headerEnd).toString(); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) throw new Error(`bad DAP header: ${header}`); + const length = Number(match[1]); + const start = headerEnd + 4; + const end = start + length; + if (this.buffer.length < end) return; + const payload = this.buffer.slice(start, end).toString(); + this.buffer = this.buffer.slice(end); + this.messages.push(JSON.parse(payload)); + this.flushWaiters(); + } + } + + flushWaiters() { + for (const waiter of [...this.waiters]) { + const message = this.messages.find(waiter.predicate); + if (!message) continue; + clearTimeout(waiter.timer); + this.waiters.splice(this.waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + } + + async close() { + if (this.child.exitCode !== null) return; + const seq = this.send("disconnect"); + await this.response(seq, "disconnect"); + this.child.stdin.end(); + } +} + +module.exports = { DapClient }; diff --git a/scripts/dap-smoke.js b/scripts/dap-smoke.js new file mode 100644 index 0000000..e48f4fb --- /dev/null +++ b/scripts/dap-smoke.js @@ -0,0 +1,363 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const { DapClient } = require("./dap-client"); + +(async () => { + const repo = path.resolve(__dirname, ".."); + const project = path.join(repo, "examples/launch-build-demo"); + const sourcePath = fs.realpathSync(path.join(project, "src/build.rs")); + const sourceLines = fs.readFileSync(sourcePath, "utf8").split(/\r?\n/); + const buildMainLine = + sourceLines.findIndex((line) => line.includes("pub async fn build_main()")) + 1; + assert(buildMainLine > 0, "flagship source must contain build_main"); + + const client = new DapClient(); + try { + const initialize = client.send("initialize", { + adapterID: "disasmer", + linesStartAt1: true, + columnsStartAt1: true, + }); + await client.response(initialize, "initialize"); + + const launch = client.send("launch", { + entry: "build", + project, + runtimeBackend: "local-services", + }); + await client.response(launch, "launch"); + await client.waitFor( + (message) => message.type === "event" && message.event === "initialized" + ); + + const breakpoints = client.send("setBreakpoints", { + source: { path: sourcePath }, + breakpoints: [{ line: buildMainLine }], + }); + const breakpointResponse = await client.response( + breakpoints, + "setBreakpoints" + ); + assert.strictEqual(breakpointResponse.body.breakpoints.length, 1); + assert.strictEqual(breakpointResponse.body.breakpoints[0].verified, true); + + const configurationDone = client.send("configurationDone"); + await client.response(configurationDone, "configurationDone"); + const stopped = await client.waitFor( + (message) => + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint" + ); + assert.strictEqual(stopped.body.allThreadsStopped, true); + assert.match(stopped.body.description, /confirmed by every active participant/i); + + const threadsRequest = client.send("threads"); + const threads = (await client.response(threadsRequest, "threads")).body + .threads; + const mainThread = threads.find((thread) => + thread.name.includes("build virtual process") + ); + assert(mainThread, "the running Wasm entrypoint must be a DAP virtual thread"); + assert.strictEqual(stopped.body.threadId, mainThread.id); + + const stackRequest = client.send("stackTrace", { + threadId: mainThread.id, + startFrame: 0, + levels: 1, + }); + const stack = (await client.response(stackRequest, "stackTrace")).body + .stackFrames; + assert.strictEqual(stack.length, 1); + assert.strictEqual(stack[0].line, buildMainLine); + assert.strictEqual(stack[0].source.path, sourcePath); + assert.match(stack[0].name, /build_main::wasm/); + assert.doesNotMatch(stack[0].name, /podman|cmd\.exe|powershell|pid|native child/i); + + const sourceRequest = client.send("source", { source: stack[0].source }); + const source = (await client.response(sourceRequest, "source")).body; + assert.match(source.content, /build_main/); + assert.match(source.mimeType, /rust/); + + const scopesRequest = client.send("scopes", { frameId: stack[0].id }); + const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; + const localsScope = scopes.find((scope) => scope.name === "Source Locals"); + const wasmScope = scopes.find((scope) => scope.name === "Wasm Frame Locals"); + const argsScope = scopes.find( + (scope) => scope.name === "Task Args and Handles" + ); + const runtimeScope = scopes.find( + (scope) => scope.name === "Disasmer Runtime" + ); + assert(localsScope && wasmScope && argsScope && runtimeScope); + + const localsRequest = client.send("variables", { + variablesReference: localsScope.variablesReference, + }); + const locals = (await client.response(localsRequest, "variables")).body + .variables; + assert( + locals.some( + (variable) => + variable.name === "unavailable-local-diagnostic" && + String(variable.value).includes("cannot be inspected") + ) + ); + + const wasmRequest = client.send("variables", { + variablesReference: wasmScope.variablesReference, + }); + const wasmLocals = (await client.response(wasmRequest, "variables")).body + .variables; + assert.deepStrictEqual( + wasmLocals.map((variable) => variable.name), + ["wasm-local-diagnostic"] + ); + assert.match(wasmLocals[0].value, /did not report inspectable Wasm frame locals/); + + const argsRequest = client.send("variables", { + variablesReference: argsScope.variablesReference, + }); + const args = (await client.response(argsRequest, "variables")).body.variables; + assert.deepStrictEqual( + args.map((variable) => variable.name), + ["runtime-boundary-diagnostic"] + ); + assert.match(args[0].value, /reported no task arguments or handles/); + + const runtimeRequest = client.send("variables", { + variablesReference: runtimeScope.variablesReference, + }); + const runtime = (await client.response(runtimeRequest, "variables")).body + .variables; + const value = (name) => runtime.find((variable) => variable.name === name)?.value; + assert.strictEqual(value("runtime_backend"), "LocalServices"); + assert.strictEqual(value("state"), "Frozen"); + assert.strictEqual(value("debug_epoch"), 1); + assert.strictEqual(value("coordinator_task_events"), 0); + assert.match( + String(value("command_status")), + /frozen through local services at executing Wasm probe/ + ); + + const step = client.send("next", { threadId: mainThread.id }); + const stepFailure = await client.failure(step, "next"); + assert.match(stepFailure.message, /source stepping is not yet available/i); + assert.match(stepFailure.message, /synthetic step/i); + + const restart = client.send("restartFrame", { frameId: stack[0].id }); + const restartFailure = await client.failure(restart, "restartFrame"); + assert.match(restartFailure.message, /checkpoint boundary|still active/i); + + const incompatibleRestart = client.send("restartFrame", { + frameId: stack[0].id, + sourceCompatibility: "incompatible", + }); + const incompatibleFailure = await client.failure( + incompatibleRestart, + "restartFrame" + ); + assert.match(incompatibleFailure.message, /incompatible source edit/i); + assert.match(incompatibleFailure.message, /whole virtual-process restart/i); + + await client.close(); + } catch (error) { + if (client.child.exitCode === null) client.child.kill("SIGKILL"); + throw error; + } + + const failMainLine = + sourceLines.findIndex((line) => line.includes("pub async fn fail_main()")) + 1; + assert(failMainLine > 0, "flagship source must contain fail_main"); + const taskTrapLine = + sourceLines.findIndex((line) => line.includes("fn task_trap(")) + 1; + assert(taskTrapLine > 0, "flagship source must contain task_trap"); + const restartClient = new DapClient(); + try { + const initialize = restartClient.send("initialize", { + adapterID: "disasmer", + linesStartAt1: true, + columnsStartAt1: true, + }); + await restartClient.response(initialize, "initialize"); + const launch = restartClient.send("launch", { + entry: "fail", + project, + runtimeBackend: "local-services", + }); + await restartClient.response(launch, "launch"); + await restartClient.waitFor( + (message) => message.type === "event" && message.event === "initialized" + ); + const breakpoints = restartClient.send("setBreakpoints", { + source: { path: sourcePath }, + breakpoints: [{ line: failMainLine }, { line: taskTrapLine }], + }); + const breakpointResponse = await restartClient.response( + breakpoints, + "setBreakpoints" + ); + assert.deepStrictEqual( + breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified), + [true, true] + ); + const configurationDone = restartClient.send("configurationDone"); + await restartClient.response(configurationDone, "configurationDone"); + const initialStop = await restartClient.waitFor( + (message) => + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint" + ); + assert.strictEqual(initialStop.body.allThreadsStopped, true); + const threadsRequest = restartClient.send("threads"); + const threads = ( + await restartClient.response(threadsRequest, "threads") + ).body.threads; + const failThread = threads.find( + (thread) => thread.id === initialStop.body.threadId + ); + assert(failThread, "failed entrypoint must remain a virtual task thread"); + const stackRequest = restartClient.send("stackTrace", { + threadId: failThread.id, + startFrame: 0, + levels: 1, + }); + const failedStack = ( + await restartClient.response(stackRequest, "stackTrace") + ).body.stackFrames; + assert.strictEqual(failedStack[0].line, failMainLine); + + const continueRequest = restartClient.send("continue", { + threadId: failThread.id, + }); + await restartClient.response(continueRequest, "continue"); + const childStop = await restartClient.waitFor( + (message) => + message.seq > initialStop.seq && + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint", + 70000 + ); + assert.strictEqual(childStop.body.allThreadsStopped, true); + + const childThreadsRequest = restartClient.send("threads"); + const childThreads = ( + await restartClient.response(childThreadsRequest, "threads") + ).body.threads; + const childThread = childThreads.find( + (thread) => thread.id === childStop.body.threadId + ); + assert(childThread, "executing child task must become a DAP virtual thread"); + assert.notStrictEqual(childThread.id, failThread.id); + assert.match(childThread.name, /task trap/i); + + const childStackRequest = restartClient.send("stackTrace", { + threadId: childThread.id, + startFrame: 0, + levels: 1, + }); + const childStack = ( + await restartClient.response(childStackRequest, "stackTrace") + ).body.stackFrames; + assert.strictEqual(childStack[0].line, taskTrapLine); + assert.match(childStack[0].name, /task_trap::wasm/); + + const childScopesRequest = restartClient.send("scopes", { + frameId: childStack[0].id, + }); + const childScopes = ( + await restartClient.response(childScopesRequest, "scopes") + ).body.scopes; + const childArgsScope = childScopes.find( + (scope) => scope.name === "Task Args and Handles" + ); + assert(childArgsScope, "child task argument scope must be present"); + const childArgsRequest = restartClient.send("variables", { + variablesReference: childArgsScope.variablesReference, + }); + const childArgs = ( + await restartClient.response(childArgsRequest, "variables") + ).body.variables; + assert( + childArgs.some( + (variable) => + variable.name === "arg_0" && + /SmallJson\(Number\(0\)\)/.test(String(variable.value)) + ), + "child task argument must come from the frozen node participant" + ); + + const parentScopesRequest = restartClient.send("scopes", { + frameId: failedStack[0].id, + }); + const parentScopes = ( + await restartClient.response(parentScopesRequest, "scopes") + ).body.scopes; + const parentArgsScope = parentScopes.find( + (scope) => scope.name === "Task Args and Handles" + ); + const parentArgsRequest = restartClient.send("variables", { + variablesReference: parentArgsScope.variablesReference, + }); + const parentArgs = ( + await restartClient.response(parentArgsRequest, "variables") + ).body.variables; + assert( + parentArgs.some( + (variable) => + /^task_handle_\d+$/.test(variable.name) && + /definition=task_trap instance=ti:.*:child:\d+ state=active/.test( + variable.value + ) && + variable.type === "runtime-handle" + ), + "parent task handle must come from its live Wasm host registry" + ); + + const continueChildRequest = restartClient.send("continue", { + threadId: childThread.id, + }); + await restartClient.response(continueChildRequest, "continue"); + await restartClient.waitFor( + (message) => + message.seq > childStop.seq && + message.type === "event" && + message.event === "terminated" + ); + + const restartRequest = restartClient.send("restartFrame", { + frameId: failedStack[0].id, + }); + await restartClient.response(restartRequest, "restartFrame"); + const restartedStop = await restartClient.waitFor( + (message) => + message.type === "event" && + message.event === "stopped" && + /Restarted main from the rebuilt bundle/.test(message.body.description) + ); + assert.strictEqual(restartedStop.body.allThreadsStopped, true); + const restartedStackRequest = restartClient.send("stackTrace", { + threadId: restartedStop.body.threadId, + startFrame: 0, + levels: 1, + }); + const restartedStack = ( + await restartClient.response(restartedStackRequest, "stackTrace") + ).body.stackFrames; + assert.strictEqual(restartedStack[0].line, failMainLine); + await restartClient.close(); + } catch (error) { + if (restartClient.child.exitCode === null) restartClient.child.kill("SIGKILL"); + throw error; + } + + console.log("DAP smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/docs-smoke.js b/scripts/docs-smoke.js new file mode 100644 index 0000000..302e836 --- /dev/null +++ b/scripts/docs-smoke.js @@ -0,0 +1,413 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const readmePath = path.join(repo, "README.md"); +const filteredPublicTree = fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json")); +if (!fs.existsSync(readmePath)) throw new Error("README.md is missing"); +const readme = fs.readFileSync(readmePath, "utf8"); +const canonicalPublicDocs = [ + "architecture.md", + "security.md", + "task-abi.md", + "artifacts.md", + "debugging.md", + "self-hosting.md", +]; +for (const file of canonicalPublicDocs) { + const documentationPath = path.join(repo, "docs", file); + assert( + fs.existsSync(documentationPath), + `canonical public documentation is missing docs/${file}` + ); + assert( + fs.readFileSync(documentationPath, "utf8").trim().length > 400, + `canonical public documentation docs/${file} is unexpectedly empty` + ); +} +if (filteredPublicTree) { + assert.match(readme, /^# Disasmer/m, "filtered public README must identify the product"); + assert.match(readme, /## Quickstart/, "filtered public README must retain the quickstart"); + assert.match( + readme, + /docs\/architecture\.md/, + "filtered public README must link canonical public documentation" + ); + assert.match( + readme, + /cargo install --path crates\/disasmer-cli --bin disasmer/, + "filtered public README must retain CLI installation" + ); + assert.doesNotMatch( + readme, + /DISASMER_PUBLISH_PUBLIC_TREE|DISASMER_FORGEJO_TOKEN|public-release-manifest\.json|public-release-dryrun-e2e\.json/, + "filtered public README must not expose release-internal dry-run mechanics" + ); + console.log("Docs smoke passed for filtered public product README"); + process.exit(0); +} +const dryrunDoc = fs.readFileSync( + path.join(repo, "public_release_dryrun.md"), + "utf8" +); +const userFacingDocs = [ + "README.md", + "public_release_dryrun.md", + "MVP.md", + "acceptance_criteria.md", + "acceptance_criteria_phase2.md", + "cli_acceptance_criteria.md", + "website_mvp_inventory.md", + "phase_3_acceptance_criteria.md", +].map((file) => [file, fs.readFileSync(path.join(repo, file), "utf8")]); +const publicAcceptance = fs.readFileSync( + path.join(repo, "scripts/acceptance-public.sh"), + "utf8" +); +const publicSplit = fs.readFileSync( + path.join(repo, "scripts/verify-public-split.sh"), + "utf8" +); +const privateAcceptance = fs.readFileSync( + path.join(repo, "scripts/acceptance-private.sh"), + "utf8" +); + +const requiredReadmePatterns = [ + ["quickstart heading", /## Quickstart/], + ["workspace build", /cargo build --workspace/], + ["CLI install", /cargo install --path crates\/disasmer-cli --bin disasmer/], + ["node install", /cargo install --path crates\/disasmer-node --bin disasmer-node/], + [ + "coordinator install", + /cargo install --path crates\/disasmer-coordinator --bin disasmer-coordinator/, + ], + ["DAP install", /cargo install --path crates\/disasmer-dap --bin disasmer-debug-dap/], + ["VS Code local extension", /code --extensionDevelopmentPath/], + ["local coordinator", /disasmer-coordinator --listen/], + ["node attach", /disasmer node attach --coordinator/], + ["automatic local run", /disasmer run --local --project examples\/launch-build-demo build/], + ["demo run", /disasmer run --local --coordinator/], + ["entrypoint selection", /disasmer run \[entry\]/], + ["implicit hosted mode", /uses the hosted coordinator/], + ["local override", /force local coordinator mode/], + ["project override", /--project[\s\S]*overrides the project\s+directory/], + ["VS Code debug", /Disasmer: Launch\s+Virtual Process/], + ["artifact download smoke", /node scripts\/artifact-download-smoke\.js/], + ["artifact export smoke", /node scripts\/artifact-export-smoke\.js/], + ["acceptance report smoke", /node scripts\/acceptance-report-smoke\.js/], + ["CLI-first acceptance gate", /scripts\/acceptance-cli-first\.sh/], + ["CLI-first acceptance before e2e", /CLI-first non-e2e gate before any final public-release e2e attempt/], + ["public private boundary smoke", /node scripts\/public-private-boundary-smoke\.js/], + ["release blocker smoke", /node scripts\/release-blocker-smoke\.js/], + ["explicit export", /attached receiver node or user-provided\s+storage integration/], + ["cleanup", /Cleanup for the local quickstart/], + ["flush docs", /`flush\(\)` publishes metadata/], + ["sync docs", /`sync\(\)` is explicit/], + ["storage integration is user code", /User-provided storage\/export integrations are ordinary project code or external\s+commands/], + ["no managed artifact store feature", /does not provide\s+or manage an explicit artifact-store feature/], + ["best-effort retention", /best-effort retained on nodes/], + [ + "secure downloads", + /Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked/, + ], + ["node trust", /Users attach their own nodes for real work/], + ["self-hosted trusted teams", /self-hosted local clouds, trusted teams, or VPN deployments/], + ["self-hosted coordinator smoke", /node scripts\/self-hosted-coordinator-smoke\.js/], + ["wasmtime node smoke", /node scripts\/wasmtime-node-smoke\.js/], + ["wasmtime command host import", /versioned `disasmer\.command_run_v1` host capability/], + ["Windows sandbox limitation", /Production-grade managed Windows sandboxing is behind an explicit backend stub/], + ["hosted community limit", /community tier does not provide arbitrary hosted native commands or hosted containers/], + ["browser login", /disasmer login --browser/], + ["public-key agents", /disasmer agent enroll --public-key/], + ["noninteractive agent CLI", /DISASMER_AGENT_PRIVATE_KEY= disasmer run --non-interactive build/], + ["agent key lifecycle", /register, list, rotate, and revoke an agent key/], + ["capability auto-detect", /auto-detects OS, architecture/], + ["capability override", /--cap /], + ["non-Git source provider", /Non-Git source providers can implement the public source-provider interface/], + ["first-run diagnostics", /## First-Run Diagnostics/], + ["missing nodes diagnostic", /Missing nodes/], + ["missing environment diagnostic", /Missing environments/], + ["quota diagnostic", /Quota limits/], + ["unavailable artifact diagnostic", /Unavailable artifacts/], + ["auth diagnostic", /Auth failures/], + ["debug freeze diagnostic", /Failed debug freezes/], + ["source-provider diagnostic", /Source-provider capability gaps/], + ["browser and VS Code report metadata", /browser\/VS Code harness metadata/], + ["Podman incomplete report", /Linux Podman backend behavior is marked `incomplete`/], + ["manual Windows validation workflow", /manual `Windows validation`\s+workflow/], + ["intermittent Forgejo Windows runner", /intermittent Windows runner/], + ["Windows validation env gate", /DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner"/], + ["Windows validation attach", /runs `disasmer node attach`/], +]; + +const requiredDryrunPatterns = [ + ["dry-run runbook status", /source-side release runbook, not public product quickstart/], + ["public dry-run hosted coordinator endpoint", /https:\/\/disasmer\.michelpaulissen\.com/], + ["public dry-run DNS record", /record is live[\s\S]*no resolver override is required|DNS record is deployed[\s\S]*no resolver override is required/], + ["public dry-run real deployment", /real externally reachable service/], + ["public dry-run selected users", /shared with selected users/], + ["public dry-run Forgejo repo", /git\.michelpaulissen\.com/], + ["public dry-run Forgejo Release assets", /Forgejo Release publishes compiled\s+assets/], + ["public dry-run filters internal root markdown", /internal root Markdown/], + ["public dry-run retains product README", /product-facing `README\.md` remains in the public repository/], + ["public dry-run filters Forgejo workflows by default", /\.forgejo\/\*\*` by\s+default/], + ["public dry-run Forgejo workflow opt in", /--include-forgejo-workflows/], + ["public dry-run selected-user quickstart", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\*\.md/], + ["public dry-run selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\*\.md/], + ["public dry-run resolver instructions env", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS=/], + ["public dry-run fallback hosts entry env", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY=" disasmer\.michelpaulissen\.com"/], + ["public dry-run deployment IP env", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP=/], + ["public dry-run prep script", /node scripts\/prepare-public-release-dryrun\.js/], + ["public dry-run publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE=1/], + ["public dry-run public repo remote", /DISASMER_PUBLIC_REPO_REMOTE=ssh:\/\/git\.michelpaulissen\.com/], + ["public dry-run Forgejo workflow", /Public release dry run assets/], + ["public dry-run manifest", /public-release-manifest\.json/], + ["public dry-run release publisher", /node scripts\/publish-public-release-dryrun\.js/], + ["public dry-run non-e2e preflight", /node scripts\/public-release-dryrun-preflight\.js/], + ["public dry-run Forgejo token", /DISASMER_FORGEJO_TOKEN=/], + ["public dry-run publisher infers repo", /publisher infers the Forgejo owner and repository name/], + ["public dry-run Forgejo repo owner override", /DISASMER_PUBLIC_REPO_OWNER=/], + ["public dry-run Forgejo repo name override", /DISASMER_PUBLIC_REPO_NAME=/], + ["public dry-run service smoke", /public-release-dryrun-service-smoke\.js/], + ["public dry-run service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:443/], + ["public dry-run hosted coordinator", /hosted coordinator[\s\S]*disasmer\.michelpaulissen\.com[\s\S]*private\/hosted-policy/], + ["public dry-run distinguishes Core", /standalone Core coordinator remains available for local\/self-hosted use/], + ["public dry-run validates both coordinators", /dry-run acceptance validates both coordinator deployments/], + ["public dry-run validates Core coordinator separately", /standalone Core coordinator is validated separately/], + ["public browser login control entry", /POST https:\/\/disasmer\.michelpaulissen\.com\/api\/v1\/control/], + ["public browser login hosted callback", /hosted[\s\S]*\/auth\/callback|\/auth\/callback[\s\S]*hosted/], + ["public browser login server authority", /hosted service creates the OIDC state, nonce, and PKCE verifier/], + ["public browser test driver", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/], + ["browser login plan diagnostic", /disasmer login --browser --plan/], + ["public dry-run deployment prep", /prepare-public-release-dryrun-deployment\.js/], + ["public dry-run systemd unit", /systemd unit[\s\S]*127\.0\.0\.1:9080|loopback[\s\S]*127\.0\.0\.1:9080/], + ["public dry-run e2e runner", /public-release-dryrun-e2e\.js/], + ["public dry-run e2e gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/], + ["public dry-run e2e service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:443/], + ["public dry-run final verifier", /public-release-dryrun-final-evidence\.js/], + ["public dry-run final gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL=1/], + ["public dry-run e2e evidence", /public-release-dryrun-e2e\.json/], + ["public dry-run e2e checks", /downloaded release assets[\s\S]*VS Code debugger behavior[\s\S]*artifact metadata/], + ["hosted Client compatibility smoke", /hosted-client-compat-smoke\.js/], + ["hosted Client compatibility purpose", /Client CLI browser login[\s\S]*scoped session[\s\S]*node enrollment[\s\S]*cross-tenant denial/], +]; + +for (const [name, pattern] of requiredReadmePatterns) { + assert.match(readme, pattern, `README missing ${name}`); +} + +for (const [name, pattern] of requiredDryrunPatterns) { + assert.match(dryrunDoc, pattern, `public release dry-run runbook missing ${name}`); +} + +assert.doesNotMatch( + readme, + /DISASMER_PUBLISH_PUBLIC_TREE|DISASMER_FORGEJO_TOKEN|public-release-manifest\.json|public-release-dryrun-e2e\.json/, + "README must stay product-facing; release-internal dry-run commands belong in public_release_dryrun.md" +); + +for (const [file, contents] of userFacingDocs) { + assert.doesNotMatch( + contents, + /community-tier/i, + `${file} should use "community tier" in user-facing prose` + ); +} + +for (const script of [publicAcceptance, publicSplit]) { + assert( + script.includes("node scripts/docs-smoke.js"), + "public acceptance gates must run docs-smoke.js" + ); + assert( + script.includes("node scripts/cli-install-smoke.js"), + "public acceptance gates must run cli-install-smoke.js" + ); + assert( + script.includes("node scripts/cli-output-mode-smoke.js"), + "public acceptance gates must run cli-output-mode-smoke.js" + ); + assert( + script.includes("node scripts/cli-login-smoke.js"), + "public acceptance gates must run cli-login-smoke.js" + ); + assert( + script.includes("node scripts/cli-error-exit-smoke.js"), + "public acceptance gates must run cli-error-exit-smoke.js" + ); + assert( + script.includes("node scripts/acceptance-report-smoke.js"), + "public acceptance gates must run acceptance-report-smoke.js" + ); + assert( + script.includes("node scripts/acceptance-doc-contract-smoke.js"), + "public acceptance gates must run acceptance-doc-contract-smoke.js" + ); + assert( + script.includes("node scripts/acceptance-environment-contract-smoke.js"), + "public acceptance gates must run acceptance-environment-contract-smoke.js" + ); + assert( + script.includes("node scripts/acceptance-evidence-contract-smoke.js"), + "public acceptance gates must run acceptance-evidence-contract-smoke.js" + ); + assert( + script.includes("node scripts/public-private-boundary-smoke.js"), + "public acceptance gates must run public-private-boundary-smoke.js" + ); + assert( + script.includes("node scripts/release-blocker-smoke.js"), + "public acceptance gates must run release-blocker-smoke.js" + ); + assert( + script.includes("node scripts/resource-metering-contract-smoke.js"), + "public acceptance gates must run resource-metering-contract-smoke.js" + ); + assert( + script.includes("node scripts/hostile-input-contract-smoke.js"), + "public acceptance gates must run hostile-input-contract-smoke.js" + ); + assert( + script.includes("node scripts/tenant-isolation-contract-smoke.js"), + "public acceptance gates must run tenant-isolation-contract-smoke.js" + ); + assert( + script.includes("node scripts/public-story-contract-smoke.js"), + "public acceptance gates must run public-story-contract-smoke.js" + ); + assert( + script.includes("node scripts/public-release-dryrun-contract-smoke.js"), + "public acceptance gates must run public-release-dryrun-contract-smoke.js" + ); + assert( + script.includes("node scripts/prepare-public-release-dryrun.js"), + "public acceptance gates must run prepare-public-release-dryrun.js" + ); + assert( + script.includes("node scripts/self-hosted-coordinator-smoke.js"), + "public acceptance gates must run self-hosted-coordinator-smoke.js" + ); + assert( + script.includes("node scripts/public-local-demo-matrix-smoke.js"), + "public acceptance gates must run public-local-demo-matrix-smoke.js" + ); + assert( + script.includes("scripts/release-source-scan.sh"), + "public acceptance gates must run release-source-scan.sh" + ); + assert( + script.includes("node scripts/flagship-demo-smoke.js"), + "public acceptance gates must run flagship-demo-smoke.js" + ); + assert( + script.includes("node scripts/wasmtime-assignment-smoke.js"), + "public acceptance gates must run the real coordinator-to-Wasm assignment smoke" + ); + assert( + script.includes("node scripts/sdk-spawn-runtime-smoke.js"), + "public acceptance gates must run sdk-spawn-runtime-smoke.js" + ); + assert( + script.includes("node scripts/node-lifecycle-contract-smoke.js"), + "public acceptance gates must run node-lifecycle-contract-smoke.js" + ); + assert( + script.includes("node scripts/artifact-export-smoke.js"), + "public acceptance gates must run artifact-export-smoke.js" + ); + assert( + script.includes("node scripts/windows-validation-contract-smoke.js"), + "public acceptance gates must run windows-validation-contract-smoke.js" + ); +} + +assert( + publicAcceptance.includes("node scripts/podman-backend-smoke.js"), + "public acceptance must run the Linux Podman backend smoke when Podman is available" +); + +assert( + publicAcceptance.includes("node scripts/wasmtime-node-smoke.js"), + "public acceptance must run the Wasmtime node smoke" +); + +assert( + privateAcceptance.includes("node scripts/resource-metering-contract-smoke.js"), + "private acceptance must run resource-metering-contract-smoke.js" +); + +assert( + privateAcceptance.includes("node scripts/hostile-input-contract-smoke.js"), + "private acceptance must run hostile-input-contract-smoke.js" +); + +assert( + privateAcceptance.includes("node scripts/tenant-isolation-contract-smoke.js"), + "private acceptance must run tenant-isolation-contract-smoke.js" +); + +assert( + privateAcceptance.includes("node scripts/acceptance-doc-contract-smoke.js"), + "private acceptance must run acceptance-doc-contract-smoke.js" +); + +assert( + privateAcceptance.includes("node scripts/acceptance-environment-contract-smoke.js"), + "private acceptance must run acceptance-environment-contract-smoke.js" +); + +assert( + privateAcceptance.includes("node scripts/acceptance-evidence-contract-smoke.js"), + "private acceptance must run acceptance-evidence-contract-smoke.js" +); + +assert( + privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"), + "private acceptance must run hosted-deployment-smoke.js" +); + +assert( + privateAcceptance.includes("node private/hosted-policy/scripts/hosted-client-compat-smoke.js"), + "private acceptance must run hosted-client-compat-smoke.js" +); +assert( + privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"), + "private acceptance must run self-hosted-coordinator-smoke.js before final dry-run evidence" +); + +assert( + privateAcceptance.includes("node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"), + "private acceptance must prepare public release dry-run deployment bundle" +); + +assert( + privateAcceptance.includes("node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"), + "private acceptance must be able to run public-release-dryrun-service-smoke.js" +); + +for (const script of [publicAcceptance, privateAcceptance]) { + assert( + script.includes("node scripts/public-release-dryrun-final-evidence.js"), + "acceptance must be able to run public-release-dryrun-final-evidence.js" + ); + assert( + script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"), + "acceptance must gate public-release-dryrun-final-evidence.js" + ); +} + +assert( + publicAcceptance.includes("node scripts/public-release-dryrun-e2e.js"), + "public acceptance must be able to run public-release-dryrun-e2e.js" +); + +assert( + publicAcceptance.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"), + "public acceptance must gate public-release-dryrun-e2e.js" +); + +console.log("Docs smoke passed"); diff --git a/scripts/flagship-demo-smoke.js b/scripts/flagship-demo-smoke.js new file mode 100644 index 0000000..c2d2f45 --- /dev/null +++ b/scripts/flagship-demo-smoke.js @@ -0,0 +1,148 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const extension = require("../vscode-extension/extension"); + +const repo = path.resolve(__dirname, ".."); +const project = path.join(repo, "examples/launch-build-demo"); +const source = fs.readFileSync(path.join(project, "src/build.rs"), "utf8"); +const forbiddenSourceAssumptions = + /\b(?:std::fs|std::process|git|podman|docker|localhost|127\.0\.0\.1)|\/home\/|\/Users\/|C:\\Users\\/i; + +const envs = extension.discoverEnvironmentNames(project); +assert.deepStrictEqual(envs, ["linux", "windows"]); +assert.deepStrictEqual(extension.diagnoseEnvReferences(source, envs), []); +assert.doesNotMatch( + source, + forbiddenSourceAssumptions, + "flagship build source must not bypass Disasmer host capabilities or rely on coordinator-side filesystem, Git, container, or machine-local assumptions" +); + +const inspection = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "bundle", + "inspect", + "--project", + project, + "--json" + ], + { cwd: repo, encoding: "utf8" } + ) +); + +assert.strictEqual(inspection.project, project); +assert.strictEqual( + inspection.source_provider_manifest.coordinator_requires_checkout_access, + false +); +assert.strictEqual( + inspection.source_provider_manifest.transfer_policy.local_source_bytes_remain_node_local, + true +); +assert.strictEqual( + inspection.source_provider_manifest.transfer_policy.coordinator_receives_source_bytes_by_default, + false +); +assert.strictEqual( + inspection.source_provider_manifest.transfer_policy.default_full_repo_tarball, + false +); +assert.deepStrictEqual( + inspection.source_provider_manifest.transfer_policy.allowed_remote_transfer.sort(), + ["ExplicitSnapshotChunks", "RequiredContent"] +); +assert.strictEqual(inspection.metadata.embeds_full_container_images, false); +assert(inspection.metadata.environments.some((env) => env.name === "linux")); +assert(inspection.metadata.environments.some((env) => env.name === "windows")); +assert(inspection.metadata.selected_inputs.some((input) => input.path === "src/build.rs")); + +const bundleDirectory = path.join(repo, "target/acceptance/flagship-bundle"); +const build = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "build", + "--project", + project, + "--output", + bundleDirectory, + "--json", + ], + { cwd: repo, encoding: "utf8" } + ) +); +assert.strictEqual(build.status, "built"); +assert.strictEqual(build.bundle_artifact.task_descriptor_count, 9); +assert.strictEqual(build.bundle_artifact.entrypoint_count, 3); +assert.deepStrictEqual(build.bundle_artifact.files.sort(), [ + "debug-metadata.json", + "entrypoints.json", + "environments.json", + "manifest.json", + "module.wasm", + "source-provider.json", + "task-descriptors.json", + "vfs-seed.json", +]); +const bundleManifest = JSON.parse( + fs.readFileSync(path.join(bundleDirectory, "manifest.json"), "utf8") +); +const bundleEntrypoints = JSON.parse( + fs.readFileSync(path.join(bundleDirectory, "entrypoints.json"), "utf8") +); +assert.deepStrictEqual( + bundleEntrypoints.map((entrypoint) => entrypoint.name).sort(), + ["build", "fail", "restart"] +); +const bundleModule = fs.readFileSync(path.join(bundleDirectory, "module.wasm")); +assert.strictEqual(bundleManifest.kind, "disasmer-bundle"); +assert.strictEqual( + bundleManifest.bundle_digest, + `sha256:${crypto.createHash("sha256").update(bundleModule).digest("hex")}` +); +assert.strictEqual(bundleManifest.embeds_full_repository, false); +assert.strictEqual( + bundleManifest.coordinator_receives_source_bytes_by_default, + false +); +const taskDescriptors = JSON.parse( + fs.readFileSync(path.join(bundleDirectory, "task-descriptors.json"), "utf8") +); +assert( + taskDescriptors.some( + (task) => + task.name === "task_add_one" && + task.argument_schema === "input : i32" && + task.result_schema === "i32" && + task.restart_compatibility_hash.startsWith("sha256:") && + task.probe_symbol === "disasmer.probe.task_add_one" + ) +); + +cp.execFileSync("cargo", ["test", "-p", "launch-build-demo"], { + cwd: repo, + stdio: "inherit" +}); + +console.log("Flagship demo smoke passed"); diff --git a/scripts/hostile-input-contract-smoke.js b/scripts/hostile-input-contract-smoke.js new file mode 100644 index 0000000..0584a8c --- /dev/null +++ b/scripts/hostile-input-contract-smoke.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function maybeRead(segments) { + const fullPath = path.join(repo, ...segments); + if (!fs.existsSync(fullPath)) return null; + return fs.readFileSync(fullPath, "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing hostile-input evidence: ${name}`); +} + +function expectGate(script, gateName) { + assert( + script.includes("node scripts/hostile-input-contract-smoke.js"), + `${gateName} must run hostile-input-contract-smoke.js` + ); +} + +const coreSource = read("crates/disasmer-core/src/source.rs"); +const coreCapabilities = read("crates/disasmer-core/src/capability.rs"); +const coordinatorService = [ + read("crates/disasmer-coordinator/src/service.rs"), + read("crates/disasmer-coordinator/src/service/routing.rs"), + read("crates/disasmer-coordinator/src/service/signed_nodes.rs"), + read("crates/disasmer-coordinator/src/service/logs.rs"), + read("crates/disasmer-coordinator/src/service/tests.rs"), +].join("\n"); +const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); +const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); +const schedulerSmoke = read("scripts/scheduler-placement-smoke.js"); +const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); + +for (const [name, pattern] of [ + ["source manifests validate shape", /pub fn validate_public_mvp\(&self\)[\s\S]*self\.validate_shape\(\)\?/], + ["source manifests reject invalid digests", /SourceManifestError::InvalidDigest/], + ["source manifests reject invalid custom providers", /SourceManifestError::InvalidProviderId/], + ["source manifests reject control characters", /DescriptionControlCharacter/], + ["source manifests reject coordinator checkout access", /CoordinatorCheckoutAccess/], + ["source manifests reject default source-byte upload", /CoordinatorReceivesSourceBytes/], +]) { + expect(coreSource, name, pattern); +} + +for (const [name, pattern] of [ + ["capability reports validate public shape", /pub fn validate_public_report\(&self\)/], + ["capability reports validate architecture labels", /InvalidArchitecture/], + ["capability reports validate OS labels", /InvalidOsLabel/], + ["capability reports validate source providers", /InvalidSourceProvider/], + ["source provider ids reject path traversal", /valid_source_provider_id/], +]) { + expect(coreCapabilities, name, pattern); +} + +for (const [name, pattern] of [ + ["coordinator task log tails are bounded", /MAX_TASK_LOG_TAIL_BYTES: usize = 256 \* 1024/], + ["coordinator validates reported stdout tails", /ReportTaskLog[\s\S]*validate_task_log_tail\("stdout_tail", &stdout_tail\)\?/], + ["coordinator validates completed task stdout tails", /TaskCompleted[\s\S]*validate_task_log_tail\("stdout_tail", &stdout_tail\)\?/], + ["coordinator rejects oversized log tail in unit coverage", /"x"\.repeat\(MAX_TASK_LOG_TAIL_BYTES \+ 1\)/], +]) { + expect(coordinatorService, name, pattern); +} + +for (const [name, pattern] of [ + ["service rejects malformed node capability report", /fn service_rejects_malformed_node_capability_report\(\)/], + ["capability report rejection leaves descriptors empty", /assert!\(service\.node_descriptors\.is_empty\(\)\)/], + ["node capability report rejects cross-scope writes", /fn service_rejects_node_capability_report_outside_enrollment_scope\(\)/], + ["task completion rejects cross-scope writes", /task completion outside node scope|outside/], +]) { + expect(coordinatorService, name, pattern); +} + +for (const [name, source, patterns] of [ + [ + "artifact download smoke", + artifactDownloadSmoke, + [ + /const crossTenant = await send/, + /const crossProject = await send/, + /const guessed = await send/, + /const crossActorOpen = await send/, + /token is invalid/, + /tenant mismatch/, + /project mismatch/, + ], + ], + [ + "operator panel smoke", + operatorPanelSmoke, + [ + /render_operator_panel/, + /submit_panel_event/, + /assert\(!JSON\.stringify\(panel\)\.includes\(" max_bytes[\s\S]*contains unsupported characters/], + ["tokens are bounded", /fn validate_token[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/], + ["control request bodies are bounded", /MAX_CONTROL_FRAME_BYTES[\s\S]*control request too large/], + ["identity protocol rejects unknown authority fields", /deny_unknown_fields/], + ]) { + expect(hostedService, name, pattern); + } + + for (const [name, pattern] of [ + ["old client identity protocol is rejected", /hosted_login_protocol_rejects_client_identity_and_provider_configuration/], + ["raw operator action is rejected", /rawOperatorDenied[\s\S]*hosted_operator_request envelope/], + ["unsigned client identity is rejected", /const forged = await sendHostedControl[\s\S]*authenticated CLI session/], + ["cross-tenant process inspection is rejected", /crossTenantTaskEventsDenied[\s\S]*scope\|denied\|unauthorized/], + ]) { + expect(hostedTests, name, pattern); + } +} + +console.log("Hostile input contract smoke passed"); diff --git a/scripts/node-attach-smoke.js b/scripts/node-attach-smoke.js new file mode 100755 index 0000000..ee4a112 --- /dev/null +++ b/scripts/node-attach-smoke.js @@ -0,0 +1,300 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); + +const repo = path.resolve(__dirname, ".."); +const identities = new Map(); + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function nodeIdentity(node) { + const existing = identities.get(node); + if (existing) return existing; + const { privateKey: privateKeyObject, publicKey } = + crypto.generateKeyPairSync("ed25519"); + const privateDer = privateKeyObject.export({ format: "der", type: "pkcs8" }); + const publicDer = publicKey.export({ + format: "der", + type: "spki", + }); + const privateSeed = Buffer.from(privateDer).subarray(-32); + const identity = { + privateKey: `ed25519:${privateSeed.toString("base64")}`, + publicKey: `ed25519:${Buffer.from(publicDer).subarray(-32).toString("base64")}`, + privateKeyObject, + }; + identities.set(node, identity); + return identity; +} + +function nodeSignatureMessage( + node, + requestKind, + payloadDigest, + nonce, + issuedAtEpochSeconds +) { + const parts = [ + "disasmer-node-request-signature:v2", + node, + requestKind, + payloadDigest, + nonce, + String(issuedAtEpochSeconds), + ]; + return Buffer.concat( + parts.flatMap((part) => [ + Buffer.from(`${Buffer.byteLength(part)}:`), + Buffer.from(part), + Buffer.from("\n"), + ]) + ); +} + +function signedNodeHeartbeat(node, identity) { + const nonce = `node-attach-heartbeat-${process.pid}-${Date.now()}`; + const issuedAt = Math.floor(Date.now() / 1000); + const payloadDigest = `sha256:${crypto + .createHash("sha256") + .update(JSON.stringify({ node, type: "node_heartbeat" })) + .digest("hex")}`; + const signature = crypto.sign( + null, + nodeSignatureMessage(node, "node_heartbeat", payloadDigest, nonce, issuedAt), + identity.privateKeyObject + ); + return { + nonce, + issued_at_epoch_seconds: issuedAt, + signature: `ed25519:${signature.toString("base64")}`, + }; +} + +function runAttach(addr, grant) { + const identity = nodeIdentity("node-attach"); + return new Promise((resolve, reject) => { + const child = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "node", + "attach", + "--coordinator", + `${addr.host}:${addr.port}`, + "--tenant", + "tenant", + "--project-id", + "project", + "--node", + "node-attach", + "--public-key", + identity.publicKey, + "--enrollment-grant", + grant, + "--cap", + "quic-direct", + "--json", + ], + { + cwd: repo, + env: { + ...process.env, + DISASMER_NODE_PRIVATE_KEY: identity.privateKey, + }, + } + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("exit", (code) => { + if (code !== 0) { + reject(new Error(`node attach failed with code ${code}\n${stderr}`)); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch (error) { + reject( + new Error(`node attach output was not JSON: ${stdout}\n${error.stack || error.message}`) + ); + } + }); + }); +} + +(async () => { + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback", + ], + { cwd: repo } + ); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const grant = await send(addr, { + type: "create_node_enrollment_grant", + tenant: "tenant", + project: "project", + actor_user: "operator", + ttl_seconds: 900 + }); + assert.strictEqual(grant.type, "node_enrollment_grant_created"); + assert.strictEqual(grant.tenant, "tenant"); + assert.strictEqual(grant.project, "project"); + assert.match(grant.grant, /^node_grant_[A-Za-z0-9_-]+$/); + assert.strictEqual(grant.scope, "node:attach"); + assert(grant.expires_at_epoch_seconds > Math.floor(Date.now() / 1000)); + assert(grant.expires_at_epoch_seconds <= Math.floor(Date.now() / 1000) + 900); + + const report = await runAttach(addr, grant.grant); + assert.strictEqual(report.plan.node, "node-attach"); + assert.strictEqual(report.plan.coordinator, `${addr.host}:${addr.port}`); + assert.strictEqual(report.plan.enrollment.grant, grant.grant); + assert.match(report.plan.enrollment.public_key_fingerprint, /^sha256:[0-9a-f]{64}$/); + assert.strictEqual( + report.plan.enrollment.exchanges_short_lived_grant_for_long_lived_node_identity, + true + ); + assert.ok(report.plan.capabilities.arch.length > 0); + assert.ok(report.plan.capabilities.source_providers.includes("filesystem")); + assert.ok(report.plan.capabilities.capabilities.includes("QuicDirect")); + assert.strictEqual(report.plan.detection.auto_detected, true); + assert.strictEqual(report.plan.detection.arch, report.plan.capabilities.arch); + assert.deepStrictEqual(report.plan.detection.manual_capability_overrides, ["quic-direct"]); + assert( + report.plan.detection.recognized_capability_overrides.includes("QuicDirect") + ); + assert.strictEqual( + report.plan.detection.os_arch_capabilities_require_manual_flags, + false + ); + assert.strictEqual(report.plan.detection.command_backend, "native-command"); + assert.strictEqual(report.plan.detection.command_backend_available, true); + assert( + report.plan.detection.source_provider_backends.some( + (provider) => provider.provider === "filesystem" && provider.detected + ) + ); + assert( + report.grant_disclosures.length > 0, + "node attach should disclose capability grants before reporting capabilities" + ); + assert( + report.grant_disclosures.every( + (disclosure) => disclosure.coordinator_policy_limited === true + ), + "node attach should mark all capability grants as coordinator-policy-limited" + ); + assert( + report.grant_disclosures.some( + (disclosure) => disclosure.grant === "native_command_execution" + ), + "node attach should disclose native command execution when detected" + ); + assert( + report.grant_disclosures.some( + (disclosure) => disclosure.grant === "source_access" + ), + "node attach should disclose source access when detected" + ); + assert.strictEqual(report.boundary.cli_contacted_coordinator, true); + assert.strictEqual(report.boundary.used_enrollment_exchange, true); + assert.strictEqual(report.boundary.coordinator_session_requests, 3); + assert.strictEqual(report.coordinator_response.type, "node_enrollment_exchanged"); + assert.strictEqual(report.coordinator_response.node, "node-attach"); + assert.strictEqual(report.coordinator_response.credential.node, "node-attach"); + assert.strictEqual(report.coordinator_response.credential.scope, "node:attach"); + assert.strictEqual(report.coordinator_response.credential.credential_kind, "NodeCredential"); + assert.match( + report.coordinator_response.credential.capability_policy_digest, + /^sha256:[0-9a-f]{64}$/ + ); + assert.strictEqual(report.heartbeat_response.type, "node_heartbeat"); + assert.strictEqual(report.capability_response.type, "node_capabilities_recorded"); + + const heartbeat = await send(addr, { + type: "node_heartbeat", + node: "node-attach", + node_signature: signedNodeHeartbeat("node-attach", nodeIdentity("node-attach")), + }); + assert.strictEqual(heartbeat.type, "node_heartbeat"); + assert.strictEqual(heartbeat.node, "node-attach"); + + } finally { + coordinator.kill("SIGTERM"); + } + + console.log("Node attach smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/node-lifecycle-contract-smoke.js b/scripts/node-lifecycle-contract-smoke.js new file mode 100755 index 0000000..95acd9d --- /dev/null +++ b/scripts/node-lifecycle-contract-smoke.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing node lifecycle evidence: ${name}`); +} + +const nodeMain = read("crates/disasmer-node/src/daemon.rs"); +const nodeIdentity = read("crates/disasmer-node/src/node_identity.rs"); +const cliNode = read("crates/disasmer-cli/src/node.rs"); +const nodeTaskReports = read("crates/disasmer-node/src/task_reports.rs"); +const nodeDebugAgent = read("crates/disasmer-node/src/debug_agent.rs"); +const nodeLib = read("crates/disasmer-node/src/lib.rs"); +const sharedWasmtimeRuntime = `${read("crates/disasmer-wasm-runtime/src/lib.rs")}\n${read("crates/disasmer-wasm-runtime/src/task_host_linker.rs")}`; +const nodeRuntimeSurface = `${nodeLib}\n${sharedWasmtimeRuntime}`; +const nodeLifecycleSurface = `${nodeMain}\n${nodeIdentity}\n${nodeTaskReports}\n${nodeDebugAgent}`; +const nodeAssignmentRunner = `${read("crates/disasmer-node/src/assignment_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/control_watcher.rs")}\n${read("crates/disasmer-node/src/assignment_runner/process_runner.rs")}\n${read("crates/disasmer-node/src/assignment_runner/validation.rs")}`; +const coordinatorCore = read("crates/disasmer-coordinator/src/lib.rs"); +const coordinatorService = `${read("crates/disasmer-coordinator/src/service.rs")}\n${read("crates/disasmer-coordinator/src/service/routing.rs")}`; +const coordinatorServiceTests = read("crates/disasmer-coordinator/src/service/tests.rs"); +const coordinatorServiceSurface = `${coordinatorService}\n${coordinatorServiceTests}`; +const wasmtimeAssignmentSmoke = read("scripts/wasmtime-assignment-smoke.js"); +const wasmtimeSmoke = read("scripts/wasmtime-node-smoke.js"); +const debugCore = read("crates/disasmer-core/src/debug.rs"); +const readme = read("README.md"); + +assert.strictEqual( + (nodeMain.match(/CoordinatorSession::connect/g) || []).length, + 1, + "node runtime should open one coordinator session in the local process-boundary runtime" +); + +for (const [name, pattern] of [ + ["enrollment exchange over session", /"type": "exchange_node_enrollment_grant"/], + ["persisted node identity is reused locally", /"type": "node_identity_reused"/], + ["heartbeat over session", /"type": "node_heartbeat"/], + ["node-originated requests use signed envelope", /"type": "signed_node"/], + ["capability report over session", /"type": "report_node_capabilities"/], + ["task assignment polling over session", /"type": "poll_task_assignment"/], + ["process start over session", /"type": "start_process"/], + ["reconnect over session", /"type": "reconnect_node"/], + ["debug command polling over session", /"type": "poll_debug_command"/], + ["log event over session", /"type": "report_task_log"/], + ["VFS metadata over session", /"type": "report_vfs_metadata"/], + ["task control polling over session", /"type": "poll_task_control"/], + ["completion over session", /"type": "task_completed"/], + ["cancellation uses same session", /poll_task_cancellation\(session, args, &task, node_private_key\)/], + ["request count is reported", /session\.requests\(\)/], +]) { + expect(nodeLifecycleSurface, name, pattern); +} + +expect(cliNode, "user-authorized node attach over Client session", /"type": "attach_node"/); +assert.doesNotMatch( + nodeIdentity, + /"type": "attach_node"/, + "a persisted node must authenticate with its signed identity instead of replaying Client attach" +); + +for (const [name, pattern] of [ + ["worker uses enrollment exchange", /workerReady\.node_status, "ready"/], + ["worker receives the task instance selected by the SDK runtime", /nodeRun\.task_assignment_response\.task,[\s\S]*sdkRun\.task_spec\.task_instance/], + ["worker verifies task ABI", /task_assignment_response\.task_spec\.dispatch\.abi/], + ["worker records task completion", /nodeRun\.coordinator_response\.type, "task_recorded"/], + ["worker exposes task result", /events\.events\[0\]\.result, \{ SmallJson: 42 \}/], + ["Wasm task observes cooperative cancellation", /task_definition: "cooperative_cancellation_probe"[\s\S]*type: "cancel_process"/], + ["cooperative cancellation returns under task control", /cancellationNodeRun\.terminal_state, "completed"/], + ["running command abort is exercised", /task_definition: "abort_probe"[\s\S]*type: "abort_process"/], + ["running command abort reaches terminal state", /abortedNodeRun\.terminal_state, "cancelled"/], + ["running native command receives a real Debug Epoch freeze", /waitForNativeSleep[\s\S]*type: "create_debug_epoch"[\s\S]*fully_frozen/], + ["native Linux process is observed stopped and resumed", /assert\.match\(procState\(nativeSleepPid\)[\s\S]*stopped[\s\S]*resume_debug_epoch[\s\S]*fully_resumed[\s\S]*assert\.doesNotMatch\(procState\(nativeSleepPid\)/], + ["parent and child Wasm tasks are frozen as one process", /task_definition: "debug_parent_probe"[\s\S]*debugChildInstance = "debug_parent_probe-1:child:1"[\s\S]*all-stop across active parent and child Wasm participants[\s\S]*affected_tasks\.length, 2[\s\S]*acknowledgements\.length, 2[\s\S]*\[debugChildInstance, "debug_parent_probe-1"\]/], + ["parent and child Wasm tasks both acknowledge resume", /multiParticipantResume[\s\S]*fully_resumed[\s\S]*acknowledgements\.length, 2/], + ["abort releases process slot", /afterAbort\.processes, \[\]/], +]) { + expect(wasmtimeAssignmentSmoke, name, pattern); +} + +for (const [name, pattern] of [ + ["cooperative cancellation and abort are distinct", /cancel_requested: false,[\s\S]*abort_requested: true/], + ["controlled runner polls abort while command runs", /fn abort_requested[\s\S]*poll_task_control/], + ["controlled runner creates a process group", /process\.process_group\(0\)/], + ["controlled runner freezes the native process group", /libc::kill\(process_group, libc::SIGSTOP\)/], + ["controlled runner resumes the native process group", /libc::kill\(process_group, libc::SIGCONT\)/], + ["controlled runner kills the process group", /libc::kill\(process_group, libc::SIGKILL\)/], + ["Wasm code can poll cooperative cancellation", /task_control_v1/], + ["matched Wasm probes remain at a quiescent boundary", /TaskHostOperation::DebugProbe[\s\S]*enter_quiescent_host_boundary[\s\S]*leave_quiescent_host_boundary/], + ["debug snapshots use the live Wasm task handle registry", /debug_handle_snapshot[\s\S]*task_handle_\{handle_id\}[\s\S]*state=active/], + ["native command status comes from the controlled runner", /set_command_status[\s\S]*frozen native command pid[\s\S]*native command exited with status/], +]) { + expect(`${coordinatorServiceSurface}\n${nodeLifecycleSurface}\n${sharedWasmtimeRuntime}\n${nodeAssignmentRunner}`, name, pattern); +} + +for (const [name, pattern] of [ + ["coordinator rejects stale process ownership", /fn node_reconnect_rejects_stale_process_epoch_after_restart\(\)/], + ["reconnect preserves enrolled node identity", /reconnect_node\(&NodeId::from\("node"\), None\)/], + ["stale process epoch is rejected", /CoordinatorError::StaleProcessEpoch/], +]) { + expect(coordinatorCore, name, pattern); +} + +for (const [name, pattern] of [ + ["coordinator delivers cancellation to connected node", /fn service_delivers_cancellation_to_connected_node_and_records_terminal_state\(\)/], + ["node polls task control", /CoordinatorRequest::PollTaskControl/], + ["cancelled terminal state is recorded", /TaskTerminalState::Cancelled/], +]) { + expect(coordinatorServiceSurface, name, pattern); +} + +for (const [name, pattern] of [ + ["native lifecycle test exists", /fn linux_task_lifecycle_supports_cancel_and_all_stop_freeze_resume\(\)/], + ["native freeze succeeds when supported", /lifecycle\.freeze_for_debug_epoch\(\)\.unwrap\(\)/], + ["native resume succeeds", /lifecycle\.resume_after_debug_epoch\(\)/], + ["native cancel reaches lifecycle", /lifecycle\.cancel\(\)/], + ["unsupported freeze errors", /BackendError::DebugFreezeUnsupported/], + ["wasmtime runtime exposes freeze resume probe", /pub fn freeze_resume_i32_export_probe/], + ["wasmtime runtime captures Wasm frame locals", /debug_i32_export_snapshot[\s\S]*local_values/], + ["wasmtime runtime creates Wasm debug participant", /kind: DebugParticipantKind::WasmTask/], + ["wasmtime debug participant carries local values", /local_values: snapshot\.local_values\.clone\(\)/], + ["wasmtime runtime resumes after freeze", /epoch\.continue_all\(\)/], +]) { + expect(nodeRuntimeSurface, name, pattern); +} + +for (const [name, pattern] of [ + ["wasmtime smoke runs debug freeze resume mode", /--debug-freeze-resume/], + ["wasmtime smoke verifies frozen state", /debugReport\.frozen_state, "Frozen"/], + ["wasmtime smoke verifies resumed state", /debugReport\.resumed_state, "Running"/], + ["wasmtime smoke verifies frame local values", /debugReport\.local_values[\s\S]*wasm_local_0/], + ["wasmtime smoke proves node runtime reached wasm task", /node_runtime_reached_wasm_task/], + ["wasmtime smoke proves node captured locals", /node_runtime_captured_wasm_locals/], +]) { + expect(wasmtimeSmoke, name, pattern); +} + +for (const [name, pattern] of [ + ["debug model freezes wasm and command participants", /fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks\(\)/], + ["debug model rejects unsupported freeze", /fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop\(\)/], + ["debug model resumes frozen participants", /fn continue_resumes_every_frozen_participant\(\)/], + ["debug model includes captured locals", /local_values/], + ["wasm participants are modeled", /DebugParticipantKind::WasmTask/], + ["controlled native command participants are modeled", /DebugParticipantKind::ControlledNativeCommand/], +]) { + expect(debugCore, name, pattern); +} + +for (const [name, pattern] of [ + ["docs describe long-lived coordinator and worker processes", /real-flagship-harness\.js[\s\S]*separate long-lived processes/], + ["docs distinguish cooperative cancellation from forced abort", /cooperative cancellation observed by task code and forced abort of uncooperative Wasm\/native work/], + ["docs describe failed freeze diagnostic", /Failed debug freezes/], +]) { + expect(readme, name, pattern); +} + +console.log("Node lifecycle contract smoke passed"); diff --git a/scripts/node-signing.js b/scripts/node-signing.js new file mode 100644 index 0000000..afd2cdb --- /dev/null +++ b/scripts/node-signing.js @@ -0,0 +1,177 @@ +const crypto = require("crypto"); +const identities = new Map(); + +function nodeIdentity(identityPurpose, node) { + const identityKey = `${identityPurpose}:${node}`; + const existing = identities.get(identityKey); + if (existing) return existing; + const { privateKey: privateKeyObject, publicKey } = + crypto.generateKeyPairSync("ed25519"); + const privateDer = privateKeyObject.export({ format: "der", type: "pkcs8" }); + const publicDer = publicKey.export({ + format: "der", + type: "spki", + }); + const privateSeed = Buffer.from(privateDer).subarray(-32); + const identity = { + privateKey: `ed25519:${privateSeed.toString("base64")}`, + publicKey: `ed25519:${Buffer.from(publicDer).subarray(-32).toString("base64")}`, + privateKeyObject, + }; + identities.set(identityKey, identity); + return identity; +} + +function nodeIdentityFromPrivateKey(privateKey) { + if (typeof privateKey !== "string" || !privateKey.startsWith("ed25519:")) { + throw new Error("node private key must use ed25519: encoding"); + } + const seed = Buffer.from(privateKey.slice("ed25519:".length), "base64"); + if (seed.length !== 32) throw new Error("node private key must contain 32 bytes"); + const privateKeyObject = crypto.createPrivateKey({ + key: Buffer.concat([ + Buffer.from("302e020100300506032b657004220420", "hex"), + seed, + ]), + format: "der", + type: "pkcs8", + }); + const publicKeyObject = crypto.createPublicKey(privateKeyObject); + const publicDer = publicKeyObject.export({ format: "der", type: "spki" }); + return { + privateKey, + publicKey: `ed25519:${Buffer.from(publicDer).subarray(-32).toString("base64")}`, + privateKeyObject, + }; +} + +function canonicalSignedRequest(value, topLevel = true) { + if (Array.isArray(value)) { + return value.map((entry) => canonicalSignedRequest(entry, false)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter( + ([key, entry]) => + entry !== null && + (!topLevel || !["agent_signature", "node_signature"].includes(key)) + ) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, canonicalSignedRequest(entry, false)]) + ); + } + return value; +} + +function withWireDefaults(request) { + const value = { ...request }; + if (value.type === "report_node_capabilities") { + value.dependency_cache_digests ??= []; + } else if (value.type === "launch_task" || value.type === "launch_child_task") { + value.wait_for_node ??= false; + } else if (value.type === "start_process") { + value.restart ??= false; + } else if (value.type === "report_debug_state") { + value.stack_frames ??= []; + value.local_values ??= []; + value.task_args ??= []; + value.handles ??= []; + value.recent_output ??= []; + } else if (value.type === "report_task_log") { + value.stdout_tail ??= ""; + value.stderr_tail ??= ""; + } else if (value.type === "task_completed") { + value.stdout_tail ??= ""; + value.stderr_tail ??= ""; + value.stdout_truncated ??= false; + value.stderr_truncated ??= false; + } + return value; +} + +function signedRequestPayloadDigest(request) { + return `sha256:${crypto + .createHash("sha256") + .update(JSON.stringify(canonicalSignedRequest(withWireDefaults(request)))) + .digest("hex")}`; +} + +function nodeSignatureMessage( + node, + requestKind, + payloadDigest, + nonce, + issuedAtEpochSeconds +) { + const parts = [ + "disasmer-node-request-signature:v2", + node, + requestKind, + payloadDigest, + nonce, + String(issuedAtEpochSeconds), + ]; + return Buffer.concat( + parts.flatMap((part) => [ + Buffer.from(`${Buffer.byteLength(part)}:`), + Buffer.from(part), + Buffer.from("\n"), + ]) + ); +} + +function signedNodeProof(node, identity, requestKind, request, options = {}) { + const nonce = + options.nonce || + `${requestKind}-${process.pid}-${Date.now()}-${crypto + .randomBytes(8) + .toString("hex")}`; + const issuedAt = + options.issuedAtEpochSeconds ?? Math.floor(Date.now() / 1000); + const signature = crypto.sign( + null, + nodeSignatureMessage( + node, + requestKind, + signedRequestPayloadDigest(request), + nonce, + issuedAt + ), + identity.privateKeyObject + ); + return { + nonce, + issued_at_epoch_seconds: issuedAt, + signature: `ed25519:${signature.toString("base64")}`, + }; +} + +function signedNodeHeartbeat(node, identity, options = {}) { + const request = { type: "node_heartbeat", node }; + return signedNodeProof(node, identity, "node_heartbeat", request, options); +} + +function signedNodeRequest(node, identity, requestKind, request, options = {}) { + return { + type: "signed_node", + node, + node_signature: signedNodeProof( + node, + identity, + requestKind, + request, + options + ), + request, + }; +} + +module.exports = { + nodeIdentity, + nodeIdentityFromPrivateKey, + signedNodeProof, + signedRequestPayloadDigest, + signedNodeHeartbeat, + signedNodeRequest, +}; diff --git a/scripts/operator-panel-smoke.js b/scripts/operator-panel-smoke.js new file mode 100755 index 0000000..5d248e5 --- /dev/null +++ b/scripts/operator-panel-smoke.js @@ -0,0 +1,360 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const { nodeIdentity } = require("./node-signing"); +const { + ensureRootlessPodman, + repo, + runFlagshipWorker, + send, + startFlagship, + waitForTaskEvent, + waitForJsonLine, +} = require("./real-flagship-harness"); + +const panelNode = "panel-node"; +const panelNodeIdentity = nodeIdentity("operator-panel-smoke", panelNode); + +function widget(panel, id) { + const item = panel.widgets[id]; + assert(item, `missing panel widget ${id}`); + return item; +} + +const delay = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function waitForBreakpointHit(addr, process) { + for (let attempt = 0; attempt < 2400; attempt += 1) { + const status = await send(addr, { + type: "inspect_debug_breakpoints", + tenant: "tenant", + project: "project", + actor_user: "user", + process, + }); + assert.strictEqual(status.type, "debug_breakpoints", JSON.stringify(status)); + if (status.hit_epoch != null) return status; + await delay(25); + } + throw new Error(`timed out waiting for package breakpoint in ${process}`); +} + +async function waitForDebugEpochFrozen(addr, process, epoch) { + for (let attempt = 0; attempt < 2400; attempt += 1) { + const status = await send(addr, { + type: "inspect_debug_epoch", + tenant: "tenant", + project: "project", + actor_user: "user", + process, + epoch, + }); + assert.strictEqual(status.type, "debug_epoch_status", JSON.stringify(status)); + if (status.failed) { + throw new Error(status.failure_messages.join("; ")); + } + if (status.fully_frozen) return status; + await delay(25); + } + throw new Error(`timed out waiting for debug epoch ${epoch} to freeze`); +} + +(async () => { + ensureRootlessPodman(); + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback" + ], + { cwd: repo } + ); + let coordinatorStderr = ""; + let worker; + coordinator.stderr.on("data", (chunk) => { + coordinatorStderr += chunk.toString(); + }); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + const projectCreated = await send(addr, { + type: "create_project", + tenant: "tenant", + actor_user: "user", + project: "project", + name: "Operator panel smoke", + }); + assert.strictEqual(projectCreated.type, "project_created"); + + worker = await runFlagshipWorker(addr, panelNode, panelNodeIdentity); + const workerReady = await worker.ready; + assert.strictEqual(workerReady.node_status, "ready"); + const workerCompletion = waitForJsonLine(worker.child); + const flagship = startFlagship(addr); + const configuredBreakpoints = await send(addr, { + type: "set_debug_breakpoints", + tenant: "tenant", + project: "project", + actor_user: "user", + process: flagship.process, + probe_symbols: ["disasmer.probe.package_release"], + }); + assert.strictEqual(configuredBreakpoints.type, "debug_breakpoints"); + const breakpointHit = await waitForBreakpointHit(addr, flagship.process); + assert.strictEqual( + breakpointHit.hit_probe_symbol, + "disasmer.probe.package_release" + ); + const frozenEpoch = await waitForDebugEpochFrozen( + addr, + flagship.process, + breakpointHit.hit_epoch + ); + assert(frozenEpoch.acknowledgements.length >= 2); + const compileEvent = await waitForTaskEvent( + addr, + flagship.process, + (event) => event.task_definition === "compile_linux", + "compile_linux before the package breakpoint" + ); + const report = await workerCompletion; + assert.strictEqual(report.node_status, "completed"); + assert.strictEqual(report.coordinator_response.type, "task_recorded"); + const process = flagship.process; + + const rendered = await send(addr, { + type: "render_operator_panel", + tenant: "tenant", + project: "project", + process, + actor_user: "user", + max_download_bytes: 1024 * 1024, + stopped: false + }); + assert.strictEqual(rendered.type, "operator_panel"); + const panel = rendered.panel; + assert.strictEqual(panel.tenant, "tenant"); + assert.strictEqual(panel.project, "project"); + assert.strictEqual(panel.process, process); + assert.strictEqual(panel.program_ui_events_enabled, true); + + assert.deepStrictEqual(widget(panel, "process-status").kind, { + Text: { value: "running" } + }); + const taskProgress = widget(panel, "task-progress").kind.Progress; + assert(taskProgress.current >= 2); + assert.strictEqual(taskProgress.current, taskProgress.total); + assert.match( + widget(panel, "task-summary").kind.Text.value, + new RegExp(`compile_linux \\[${compileEvent.task}\\]:Some\\(0\\):panel-node`) + ); + assert.match(widget(panel, "recent-logs").kind.Text.value, /stdout=\d+ stderr=\d+/); + const downloadWidget = widget(panel, "download-artifact").kind; + const artifact = downloadWidget.ArtifactDownload.artifact; + assert( + artifact === compileEvent.artifact_path.slice("/vfs/artifacts/".length), + "panel download must point at a real flagship artifact" + ); + assert(!JSON.stringify(downloadWidget).includes("url_path")); + assert(!JSON.stringify(downloadWidget).includes("scoped_token_digest")); + assert.deepStrictEqual(widget(panel, "debug-process").kind, { + Button: { action: "debug-process" } + }); + assert.deepStrictEqual(widget(panel, "cancel-process").kind, { + Button: { action: "cancel-process" } + }); + assert.deepStrictEqual(widget(panel, "restart-selected-task").kind, { + Button: { action: "restart-task" } + }); + assert(panel.control_plane_actions.includes("DebugProcess")); + assert(panel.control_plane_actions.includes("CancelProcess")); + assert( + panel.control_plane_actions.some( + (action) => + action.RestartTask && + action.RestartTask === compileEvent.task + ), + "panel restart must target the real flagship task instance" + ); + assert( + panel.control_plane_actions.some( + (action) => action.DownloadArtifact === artifact + ) + ); + assert(!JSON.stringify(panel).includes(" action.DownloadArtifact === artifact + ) + ); + + const frozenEvent = await send(addr, { + type: "submit_panel_event", + tenant: "tenant", + project: "project", + process, + widget_id: "debug-process", + kind: "ButtonClicked", + max_events: 10 + }); + assert.strictEqual(frozenEvent.type, "error"); + assert.match(frozenEvent.message, /program UI events are disabled/i); + + const crossTenant = await send(addr, { + type: "render_operator_panel", + tenant: "other", + project: "project", + process, + actor_user: "user", + max_download_bytes: 1024 * 1024, + stopped: false + }); + assert.strictEqual(crossTenant.type, "error"); + assert.match( + crossTenant.message, + /scope|tenant|project|requires an active virtual process/i + ); + assert(!crossTenant.message.includes(process)); + + const resumed = await send(addr, { + type: "resume_debug_epoch", + tenant: "tenant", + project: "project", + actor_user: "user", + process, + epoch: breakpointHit.hit_epoch, + }); + assert.strictEqual(resumed.type, "debug_epoch"); + const cleanup = await send(addr, { + type: "abort_process", + tenant: "tenant", + project: "project", + actor_user: "user", + process, + }); + assert.strictEqual(cleanup.type, "process_aborted"); + } catch (error) { + if (coordinatorStderr) { + error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`; + } + throw error; + } finally { + worker?.child.kill("SIGTERM"); + coordinator.kill("SIGTERM"); + } + + console.log("Operator panel smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/phase3-ledger.js b/scripts/phase3-ledger.js new file mode 100644 index 0000000..1d7bc36 --- /dev/null +++ b/scripts/phase3-ledger.js @@ -0,0 +1,89 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const CRITERION_HEADING = + /^## \*\*(Passed|Partial|Open):\*\* (P3-[A-Z]+-\d{3}):/gm; + +function readPhase3Ledger(repo) { + return fs.readFileSync(path.join(repo, "phase_3_acceptance_criteria.md"), "utf8"); +} + +function criterionStatuses(source) { + return [...source.matchAll(CRITERION_HEADING)].map((match) => ({ + status: match[1], + id: match[2], + })); +} + +function statusCounts(criteria) { + return Object.fromEntries( + ["Passed", "Partial", "Open"].map((status) => [ + status, + criteria.filter((criterion) => criterion.status === status).length, + ]) + ); +} + +function assertCompleteCriterionSet(criteria) { + assert.strictEqual(criteria.length, 191, "Phase 3 ledger must contain 191 criteria"); + assert.strictEqual( + new Set(criteria.map((criterion) => criterion.id)).size, + 191, + "Phase 3 criterion ids must be unique" + ); +} + +function assertPreFinalLedger(source) { + const criteria = criterionStatuses(source); + assertCompleteCriterionSet(criteria); + const counts = statusCounts(criteria); + assert.deepStrictEqual( + counts, + { Passed: 0, Partial: 181, Open: 10 }, + "the public-release E2E requires the independently reset 181 Partial / 10 Open ledger" + ); + const expectedOpen = Array.from( + { length: 10 }, + (_, index) => `P3-GATE-${String(index + 1).padStart(3, "0")}` + ); + assert.deepStrictEqual( + criteria + .filter((criterion) => criterion.status === "Open") + .map((criterion) => criterion.id), + expectedOpen, + "only the ten final P3-GATE criteria may be Open in the reset ledger" + ); + return counts; +} + +function assertFinalLedger(source) { + const criteria = criterionStatuses(source); + assertCompleteCriterionSet(criteria); + const counts = statusCounts(criteria); + assert.deepStrictEqual( + counts, + { Passed: 191, Partial: 0, Open: 0 }, + "final release evidence requires all 191 Phase 3 criteria to be Passed" + ); + return counts; +} + +function assertPreFinalOrFinalLedger(source) { + const criteria = criterionStatuses(source); + assertCompleteCriterionSet(criteria); + const counts = statusCounts(criteria); + if (counts.Open === 10) { + return assertPreFinalLedger(source); + } + return assertFinalLedger(source); +} + +module.exports = { + assertFinalLedger, + assertPreFinalLedger, + assertPreFinalOrFinalLedger, + criterionStatuses, + readPhase3Ledger, + statusCounts, +}; diff --git a/scripts/podman-backend-smoke.js b/scripts/podman-backend-smoke.js new file mode 100755 index 0000000..f946042 --- /dev/null +++ b/scripts/podman-backend-smoke.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const path = require("path"); +const { configurePodmanTestEnvironment } = require("./podman-test-env"); + +const repo = path.resolve(__dirname, ".."); +const baseImage = "docker.io/library/alpine:3.20"; + +// Nix's standalone Podman package may not install the distribution-level +// containers/image policy normally found under /etc. Use an isolated test HOME +// without replacing a policy supplied by the host. +configurePodmanTestEnvironment(repo); + +function run(command, args, options = {}) { + return cp.execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: options.stdio || ["ignore", "pipe", "pipe"], + }); +} + +function incomplete(reason) { + const error = new Error(`Linux Podman backend incomplete: ${reason}`); + error.code = "DISASMER_PODMAN_INCOMPLETE"; + throw error; +} + +function ensurePodmanBaseImage() { + try { + run("podman", ["--version"]); + } catch (error) { + incomplete(`podman command is unavailable (${error.message})`); + } + + let rootless; + try { + rootless = run("podman", ["info", "--format", "{{.Host.Security.Rootless}}"]).trim(); + } catch (error) { + incomplete(`podman info did not report rootless status (${error.message})`); + } + if (rootless !== "true") { + incomplete(`podman is not running in rootless mode (reported ${JSON.stringify(rootless)})`); + } + + try { + run("podman", ["image", "exists", baseImage]); + } catch (_) { + try { + run("podman", ["pull", baseImage], { stdio: "inherit" }); + } catch (error) { + incomplete(`unable to make ${baseImage} available (${error.message})`); + } + } +} + +try { + ensurePodmanBaseImage(); + + const stdout = run("cargo", [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-podman-smoke" + ]); + const report = JSON.parse(stdout.trim().split("\n").at(-1)); + + assert.strictEqual(report.podman_status, "completed"); + assert.strictEqual(report.status_code, 0); + assert.strictEqual(report.stdout, "podman-ok:node-local source\n"); + assert.strictEqual(report.large_bytes_uploaded, false); + assert.strictEqual(report.uses_full_repo_tarball, false); + assert.strictEqual(report.coordinator_routed_file_reads, false); + assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/podman-smoke.txt"); + + console.log("Podman backend smoke passed"); +} catch (error) { + if (error.code === "DISASMER_PODMAN_INCOMPLETE") { + console.error(error.message); + process.exit(2); + } + throw error; +} diff --git a/scripts/podman-test-env.js b/scripts/podman-test-env.js new file mode 100644 index 0000000..cf7c627 --- /dev/null +++ b/scripts/podman-test-env.js @@ -0,0 +1,41 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +function configurePodmanTestEnvironment(repo) { + const originalHome = os.homedir(); + if ( + fs.existsSync(path.join(originalHome, ".config/containers/policy.json")) || + fs.existsSync("/etc/containers/policy.json") + ) { + return; + } + + const isolatedHome = path.join(repo, "scripts/containers-home"); + const policy = path.join(isolatedHome, ".config/containers/policy.json"); + fs.mkdirSync(path.dirname(policy), { recursive: true }); + if (!fs.existsSync(policy)) { + fs.writeFileSync( + policy, + `${JSON.stringify( + { default: [{ type: "insecureAcceptAnything" }] }, + null, + 2 + )}\n` + ); + } + + process.env.CARGO_HOME ||= path.join(originalHome, ".cargo"); + process.env.RUSTUP_HOME ||= path.join(originalHome, ".rustup"); + process.env.HOME = isolatedHome; + process.env.XDG_DATA_HOME = path.join( + os.tmpdir(), + `disasmer-containers-data-${process.getuid?.() ?? "user"}` + ); + process.env.XDG_CACHE_HOME = path.join( + os.tmpdir(), + `disasmer-containers-cache-${process.getuid?.() ?? "user"}` + ); +} + +module.exports = { configurePodmanTestEnvironment }; diff --git a/scripts/prepare-public-release-dryrun.js b/scripts/prepare-public-release-dryrun.js new file mode 100755 index 0000000..1513760 --- /dev/null +++ b/scripts/prepare-public-release-dryrun.js @@ -0,0 +1,755 @@ +#!/usr/bin/env node + +const crypto = require("crypto"); +const cp = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const outputRoot = path.resolve( + process.env.DISASMER_PUBLIC_RELEASE_DIR || + path.join(repo, "target/public-release-dryrun") +); +const publicTree = path.join(outputRoot, "public-tree"); +const assetsDir = path.join(outputRoot, "assets"); +const stagingDir = path.join(outputRoot, "staging"); +const publicBuildTarget = path.join(repo, "target"); +const defaultHostedCoordinatorEndpoint = "https://disasmer.michelpaulissen.com"; +const forgejoHost = "git.michelpaulissen.com"; +const args = new Set(process.argv.slice(2)); +const includeForgejoWorkflows = + args.has("--include-forgejo-workflows") || + /^(1|true|yes)$/i.test(process.env.DISASMER_INCLUDE_FORGEJO_WORKFLOWS || ""); +const filteredTopLevel = ["private", "experiments", ".git", "target"]; +const filteredDirectoryNames = [".disasmer"]; +const archiveIgnoredPathFallbacks = [ + "target", + ".disasmer", + "vscode-extension/node_modules", + "scripts/containers-home", +]; +const publicRepoBranch = process.env.DISASMER_PUBLIC_REPO_BRANCH || "main"; +const publicBinaries = [ + "disasmer", + "disasmer-coordinator", + "disasmer-node", + "disasmer-debug-dap", +]; + +function commandOutput(command, args, options = {}) { + try { + const execOptions = { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + ...options, + }; + execOptions.env = commandEnv(command, options.env); + return cp + .execFileSync(command, args, execOptions) + .trim(); + } catch (_) { + return null; + } +} + +function run(command, args, options = {}) { + const execOptions = { + cwd: repo, + stdio: "inherit", + ...options, + }; + execOptions.env = commandEnv(command, options.env); + cp.execFileSync(command, args, execOptions); +} + +function nonInteractiveGitEnv(extra = {}) { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false", + SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false", + GIT_SSH_COMMAND: + process.env.GIT_SSH_COMMAND || + "ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0", + ...extra, + }; +} + +function commandEnv(command, env) { + if (command !== "git") { + return env; + } + return nonInteractiveGitEnv(env); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function filteredOutPatterns() { + return [ + "private/**", + "experiments/**", + ".git", + "target", + "git-ignored source paths", + "root/*.md except README.md and SECURITY.md", + "**/.disasmer/**", + ...(includeForgejoWorkflows ? [] : [".forgejo/**"]), + ]; +} + +function gitIgnoredSourcePaths() { + const output = commandOutput("git", [ + "ls-files", + "--others", + "--ignored", + "--exclude-standard", + "--directory", + "-z", + ]); + if (output === null) { + return archiveIgnoredPathFallbacks; + } + return [ + ...new Set([ + ...archiveIgnoredPathFallbacks, + ...output + .split("\0") + .filter(Boolean) + .map((relativePath) => + relativePath.replaceAll("\\", "/").replace(/\/$/, "") + ), + ]), + ]; +} + +function isGitIgnored(relativePath, ignoredSourcePaths) { + const normalized = relativePath.replaceAll(path.sep, "/"); + return ignoredSourcePaths.some( + (ignored) => normalized === ignored || normalized.startsWith(`${ignored}/`) + ); +} + +function isFilteredRootMarkdown(relativePath, entry) { + return ( + !relativePath.includes(path.sep) && + (entry.isFile() || entry.isSymbolicLink()) && + path.extname(entry.name).toLowerCase() === ".md" && + !["README.md", "SECURITY.md"].includes(entry.name) + ); +} + +function shouldFilter(entry, relativePath) { + const parts = relativePath.split(path.sep).filter(Boolean); + const topLevel = parts[0]; + if (filteredTopLevel.includes(topLevel)) return true; + if (parts.some((part) => filteredDirectoryNames.includes(part))) return true; + if (topLevel === ".forgejo" && !includeForgejoWorkflows) return true; + if (isFilteredRootMarkdown(relativePath, entry)) return true; + return false; +} + +function copyFilteredTree(src, dest, ignoredSourcePaths, relative = "") { + ensureDir(dest); + const entries = fs + .readdirSync(src, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of entries) { + const childRelative = relative ? path.join(relative, entry.name) : entry.name; + if ( + shouldFilter(entry, childRelative) || + isGitIgnored(childRelative, ignoredSourcePaths) + ) { + continue; + } + + const from = path.join(src, entry.name); + const to = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyFilteredTree(from, to, ignoredSourcePaths, childRelative); + } else if (entry.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(from), to); + } else if (entry.isFile()) { + fs.copyFileSync(from, to); + fs.chmodSync(to, fs.statSync(from).mode & 0o777); + } + } +} + +function assertFilteredTree(ignoredSourcePaths) { + for (const excluded of ["private", "experiments"]) { + if (fs.existsSync(path.join(publicTree, excluded))) { + throw new Error(`${excluded}/ leaked into the dry-run public tree`); + } + } + for (const file of walkFiles(publicTree)) { + if (file.split(path.sep).includes(".disasmer")) { + throw new Error(`generated .disasmer view state leaked into the dry-run public tree: ${file}`); + } + } + if (!includeForgejoWorkflows && fs.existsSync(path.join(publicTree, ".forgejo"))) { + throw new Error(".forgejo/ leaked into the host-neutral dry-run public tree"); + } + if (!fs.existsSync(path.join(publicTree, "README.md"))) { + throw new Error("product README.md is missing from the dry-run public tree"); + } + for (const ignored of ignoredSourcePaths) { + if (fs.existsSync(path.join(publicTree, ...ignored.split("/")))) { + throw new Error(`Git-ignored source path leaked into the dry-run public tree: ${ignored}`); + } + } + for (const entry of fs.readdirSync(publicTree, { withFileTypes: true })) { + if (isFilteredRootMarkdown(entry.name, entry)) { + throw new Error(`internal root Markdown file leaked into the dry-run public tree: ${entry.name}`); + } + } +} + +function walkFiles(root, relative = "") { + const dir = path.join(root, relative); + const entries = fs + .readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + for (const entry of entries) { + const childRelative = relative ? path.join(relative, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...walkFiles(root, childRelative)); + } else if (entry.isFile()) { + files.push(childRelative); + } else if (entry.isSymbolicLink()) { + files.push(childRelative); + } + } + return files; +} + +function hashTree(root) { + const hash = crypto.createHash("sha256"); + for (const file of walkFiles(root)) { + const absolute = path.join(root, file); + const stat = fs.lstatSync(absolute); + hash.update(file.replaceAll(path.sep, "/")); + hash.update("\0"); + hash.update(String(stat.mode & 0o777)); + hash.update("\0"); + if (stat.isSymbolicLink()) { + hash.update("symlink"); + hash.update("\0"); + hash.update(fs.readlinkSync(absolute)); + } else { + hash.update(fs.readFileSync(absolute)); + } + hash.update("\0"); + } + return `sha256:${hash.digest("hex")}`; +} + +function sha256File(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function tarGz(output, cwd, inputs) { + run("tar", ["-czf", output, "-C", cwd, ...inputs]); +} + +function platformName() { + return `${os.platform()}-${os.arch()}`; +} + +function binaryName(name) { + return process.platform === "win32" ? `${name}.exe` : name; +} + +function buildPublicBinaries() { + run("cargo", ["build", "--workspace", "--bins", "--release", "--jobs", "2"], { + cwd: publicTree, + env: { ...process.env, CARGO_TARGET_DIR: publicBuildTarget }, + }); +} + +function stageBinaryAssets(releaseName) { + const stageRoot = path.join(stagingDir, "binaries"); + const binDir = path.join(stageRoot, "bin"); + fs.rmSync(stageRoot, { recursive: true, force: true }); + ensureDir(binDir); + + for (const binary of publicBinaries) { + const fileName = binaryName(binary); + const built = path.join(publicBuildTarget, "release", fileName); + if (!fs.existsSync(built)) { + throw new Error(`expected release binary ${built}`); + } + const staged = path.join(binDir, fileName); + fs.copyFileSync(built, staged); + fs.chmodSync(staged, 0o755); + } + + const archive = path.join( + assetsDir, + `disasmer-public-binaries-${releaseName}-${platformName()}.tar.gz` + ); + tarGz(archive, stageRoot, ["."]); + return archive; +} + +function publicBinaryDigests() { + return Object.fromEntries( + publicBinaries.map((binary) => { + const fileName = binaryName(binary); + const file = path.join(publicBuildTarget, "release", fileName); + if (!fs.existsSync(file)) throw new Error(`missing built release binary ${file}`); + return [fileName, `sha256:${sha256File(file)}`]; + }) + ); +} + +function stageEvidenceAsset( + releaseName, + sourceCommit, + publicTreeIdentity, + binaryDigests +) { + const stage = path.join(stagingDir, "evidence"); + fs.rmSync(stage, { recursive: true, force: true }); + ensureDir(stage); + const evidenceRoot = path.join(repo, "target/acceptance"); + const included = []; + for (const name of ["public-environment.json", "wasmtime-assignment.json"]) { + const source = path.join(evidenceRoot, name); + if (!fs.existsSync(source)) continue; + fs.copyFileSync(source, path.join(stage, name)); + included.push(name); + } + const binding = { + kind: "disasmer-release-evidence-binding", + source_commit: sourceCommit, + public_tree_identity: publicTreeIdentity, + binary_digests: binaryDigests, + configuration_generation: + process.env.DISASMER_DEPLOYMENT_SYSTEM_GENERATION || null, + included_evidence: included, + }; + fs.writeFileSync( + path.join(stage, "EVIDENCE_BINDING.json"), + `${JSON.stringify(binding, null, 2)}\n` + ); + const archive = path.join( + assetsDir, + `disasmer-public-evidence-${releaseName}.tar.gz` + ); + tarGz(archive, stage, ["."]); + return archive; +} + +function stageSourceAsset(releaseName) { + const archive = path.join(assetsDir, `disasmer-public-source-${releaseName}.tar.gz`); + tarGz(archive, publicTree, ["."]); + return archive; +} + +function stageExtensionAsset() { + const packageJson = JSON.parse( + fs.readFileSync(path.join(publicTree, "vscode-extension/package.json"), "utf8") + ); + const archive = path.join( + assetsDir, + `${packageJson.name}-${packageJson.version}.vsix` + ); + run( + "npx", + [ + "--yes", + "@vscode/vsce", + "package", + "--allow-missing-repository", + "--skip-license", + "--out", + archive, + ], + { cwd: path.join(publicTree, "vscode-extension") } + ); + return archive; +} + +function resolverInstructions() { + if (process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS) { + return process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS.trim(); + } + if (process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY) { + return [ + "Add the controlled hosts entry supplied for this dry run:", + "", + "```", + process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY.trim(), + "```", + ].join("\n"); + } + if (process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP) { + return [ + "Add this controlled hosts entry for the dry run:", + "", + "```", + `${process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP} disasmer.michelpaulissen.com`, + "```", + ].join("\n"); + } + return [ + "`disasmer.michelpaulissen.com` should resolve through public DNS. If it", + "does not resolve yet, wait for DNS propagation or use the fallback hosts", + "entry supplied with the invitation.", + ].join("\n"); +} + +function writeGettingStartedAsset(releaseName, publicTreeIdentity, resolution) { + const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-${releaseName}.md`); + fs.writeFileSync( + file, + `# Disasmer Public Dry Run + +This dry run is a real Disasmer deployment for selected external users. If +public DNS has not propagated yet, use the fallback resolution instructions +below. + +The default hosted coordinator is the deployment at +\`https://disasmer.michelpaulissen.com\`. The word \`public\` in this dry run +refers to the public Forgejo repository, release downloads, selected-user +network access, and public client protocol compatibility. It does not mean the +server is the standalone open-source Core coordinator. + +## DNS + +${resolution} + +## Install + +1. Download the binary archive for your platform from the Forgejo Release at + \`git.michelpaulissen.com\`. +2. Verify it against \`SHA256SUMS\`. +3. Extract the archive and put the \`bin/\` directory on your \`PATH\`. +4. Download \`disasmer-vscode-*.vsix\` if you want the debugger and Disasmer + side views, then install it: + +\`\`\`bash +code --install-extension disasmer-vscode-*.vsix +\`\`\` + +## Connect + +Use the default hosted coordinator endpoint: + +\`\`\`bash +disasmer login --browser +disasmer bundle inspect --project examples/launch-build-demo +\`\`\` + +\`disasmer login --browser\` opens the server-provided Authentik authorization +URL and polls the hosted transaction. The provider callback terminates on the +hosted service; provider codes and tokens do not pass through the CLI. + +Enroll a node identity with the grant supplied by the invitation: + +\`\`\`bash +disasmer node attach --coordinator https://disasmer.michelpaulissen.com --enrollment-grant --public-key +\`\`\` + +That command proves the identity/enrollment path and then exits. To actually run +work for the flagship workflow, leave a worker process running in another +terminal. Use a fresh enrollment grant, or skip the attach command above and use +the worker command directly: + +\`\`\`bash +disasmer-node --coordinator https://disasmer.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready +\`\`\` + +Then run the flagship workflow from the filtered public repository while that +worker is still running: + +\`\`\`bash +disasmer run --project examples/launch-build-demo build +\`\`\` + +The dry-run public tree identity is \`${publicTreeIdentity}\`. +The release name is \`${releaseName}\`. +`, + "utf8" + ); + return file; +} + +function writeInviteAsset(releaseName, publicTreeIdentity, resolution) { + const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_INVITE-${releaseName}.md`); + const publicRepo = + process.env.DISASMER_PUBLIC_REPO_URL || + "https://git.michelpaulissen.com//"; + const releaseUrl = + process.env.DISASMER_FORGEJO_RELEASE_URL || + "the Forgejo Release attached to the public repository"; + fs.writeFileSync( + file, + `# Disasmer Public Dry Run Invite + +This invite is for selected external users, such as friends helping test the +MVP release experience. The deployment is real and externally reachable, but it +is intentionally not broadly advertised yet. + +The hosted coordinator behind this invite is the deployment at +\`https://disasmer.michelpaulissen.com\`. The public part is the Forgejo +repository, release downloads, network-reachable dry run, and public client +protocol used by the binaries; the hosted server is not the standalone Core +coordinator. + +If public DNS has not propagated yet, make sure \`disasmer.michelpaulissen.com\` +resolves to the deployment host before running the CLI. + +## Links + +- Public repository: ${publicRepo} +- Release downloads: ${releaseUrl} +- Default hosted coordinator endpoint: ${defaultHostedCoordinatorEndpoint} +- Public tree identity: ${publicTreeIdentity} +- Release name: ${releaseName} + +## DNS + +${resolution} + +## First run + +1. Download the binary archive for your platform and \`SHA256SUMS\` from the + Forgejo Release. +2. Extract the archive and put \`bin/\` on your \`PATH\`. +3. Optionally install \`disasmer-vscode-*.vsix\` with + \`code --install-extension disasmer-vscode-*.vsix\`. +4. Run \`disasmer login --browser\`; it opens the server-provided Authentik + authorization URL and polls the hosted transaction for a scoped CLI session. +5. Enroll a node identity with the enrollment grant supplied out of band. +6. Start a long-lived worker with a fresh enrollment grant, or use the worker + command directly instead of step 5: + \`disasmer-node --coordinator https://disasmer.michelpaulissen.com --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready\`. +7. Run \`disasmer run --project examples/launch-build-demo build\` from the + public repository checkout while the worker process is still running. +`, + "utf8" + ); + return file; +} + +function writeSha256Sums(assets) { + const sumsPath = path.join(assetsDir, "SHA256SUMS"); + const lines = assets + .map((asset) => `${sha256File(asset)} ${path.basename(asset)}`) + .sort() + .join("\n"); + fs.writeFileSync(sumsPath, `${lines}\n`); + return sumsPath; +} + +function boolEnv(name) { + return /^(1|true|yes)$/i.test(process.env[name] || ""); +} + +function writePublicTreeProvenance(sourceCommit, releaseName) { + const provenance = { + kind: "disasmer-filtered-public-tree", + source_commit: sourceCommit, + release_name: releaseName, + filtered_out: filteredOutPatterns(), + public_export: { + host_neutral: !includeForgejoWorkflows, + include_forgejo_workflows: includeForgejoWorkflows, + }, + forgejo_host: forgejoHost, + default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint, + }; + fs.writeFileSync( + path.join(publicTree, "DISASMER_PUBLIC_TREE.json"), + `${JSON.stringify(provenance, null, 2)}\n` + ); +} + +function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) { + const remote = + process.env.DISASMER_PUBLIC_REPO_REMOTE || + process.env.DISASMER_PUBLIC_REPO_URL || + null; + const enabled = boolEnv("DISASMER_PUBLISH_PUBLIC_TREE"); + const result = { + enabled, + remote, + branch: publicRepoBranch, + commit: null, + pushed: false, + }; + + if (!enabled) { + return result; + } + if (!remote) { + throw new Error( + "DISASMER_PUBLISH_PUBLIC_TREE requires DISASMER_PUBLIC_REPO_REMOTE or DISASMER_PUBLIC_REPO_URL" + ); + } + if (!remote.includes(forgejoHost)) { + throw new Error(`public repo remote must point at ${forgejoHost}: ${remote}`); + } + + run("git", ["init"], { cwd: publicTree }); + run("git", ["checkout", "-B", publicRepoBranch], { cwd: publicTree }); + run("git", ["config", "user.name", "Disasmer release dry run"], { + cwd: publicTree, + }); + run("git", ["config", "user.email", "release-dryrun@disasmer.invalid"], { + cwd: publicTree, + }); + run("git", ["add", "."], { cwd: publicTree }); + run( + "git", + [ + "commit", + "-m", + `Public dry run ${releaseName}`, + "-m", + `Source commit: ${sourceCommit}`, + "-m", + `Public tree identity: ${publicTreeIdentity}`, + ], + { cwd: publicTree } + ); + result.commit = commandOutput("git", ["rev-parse", "HEAD"], { cwd: publicTree }); + run("git", ["remote", "add", "public", remote], { cwd: publicTree }); + const pushArgs = ["push", "public", `HEAD:${publicRepoBranch}`]; + if (boolEnv("DISASMER_PUBLIC_REPO_PUSH_FORCE_WITH_LEASE")) { + run( + "git", + [ + "fetch", + "public", + `refs/heads/${publicRepoBranch}:refs/remotes/public/${publicRepoBranch}`, + ], + { cwd: publicTree } + ); + pushArgs.splice(1, 0, "--force-with-lease"); + } + run("git", pushArgs, { cwd: publicTree }); + result.pushed = true; + return result; +} + +function main() { + const sourceCommit = + process.env.DISASMER_ACCEPTANCE_COMMIT || + commandOutput("git", ["rev-parse", "HEAD"]) || + "unknown"; + const shortCommit = sourceCommit === "unknown" ? "unknown" : sourceCommit.slice(0, 12); + const releaseName = process.env.DISASMER_PUBLIC_RELEASE_NAME || `dryrun-${shortCommit}`; + const sourceStatus = commandOutput("git", ["status", "--short"]); + const sourceTreeClean = sourceStatus === null ? null : sourceStatus === ""; + + fs.rmSync(outputRoot, { recursive: true, force: true }); + ensureDir(publicTree); + ensureDir(assetsDir); + ensureDir(stagingDir); + + const ignoredSourcePaths = gitIgnoredSourcePaths(); + copyFilteredTree(repo, publicTree, ignoredSourcePaths); + assertFilteredTree(ignoredSourcePaths); + writePublicTreeProvenance(sourceCommit, releaseName); + const publicTreeIdentity = hashTree(publicTree); + const sourceArchive = stageSourceAsset(releaseName); + + run("node", ["scripts/public-release-dryrun-contract-smoke.js"], { + cwd: publicTree, + }); + const publicTreePublish = publishPublicTree( + releaseName, + sourceCommit, + publicTreeIdentity + ); + buildPublicBinaries(); + const binaryArchive = stageBinaryAssets(releaseName); + const binaryDigests = publicBinaryDigests(); + const evidenceArchive = stageEvidenceAsset( + releaseName, + sourceCommit, + publicTreeIdentity, + binaryDigests + ); + const extensionArchive = stageExtensionAsset(); + const resolution = resolverInstructions(); + const gettingStarted = writeGettingStartedAsset( + releaseName, + publicTreeIdentity, + resolution + ); + const invite = writeInviteAsset(releaseName, publicTreeIdentity, resolution); + const assets = [ + sourceArchive, + binaryArchive, + evidenceArchive, + extensionArchive, + gettingStarted, + invite, + ]; + const sha256Sums = writeSha256Sums(assets); + + const manifest = { + kind: "disasmer-public-release-dryrun", + release_name: releaseName, + source_commit: sourceCommit, + source_tree_clean: sourceTreeClean, + public_tree_identity: publicTreeIdentity, + public_tree: publicTree, + filtered_out: filteredOutPatterns(), + public_export: { + host_neutral: !includeForgejoWorkflows, + include_forgejo_workflows: includeForgejoWorkflows, + }, + forgejo_host: forgejoHost, + public_repo_url: process.env.DISASMER_PUBLIC_REPO_URL || publicTreePublish.remote, + public_repo_remote: process.env.DISASMER_PUBLIC_REPO_REMOTE || null, + public_tree_publish: publicTreePublish, + forgejo_release_url: process.env.DISASMER_FORGEJO_RELEASE_URL || null, + default_hosted_coordinator_endpoint: defaultHostedCoordinatorEndpoint, + dns_publication_state: + process.env.DISASMER_DNS_PUBLICATION_STATE || "published", + resolver_override: + process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns", + platform: platformName(), + binary_digests: binaryDigests, + configuration_generation: + process.env.DISASMER_DEPLOYMENT_SYSTEM_GENERATION || null, + tool_versions: { + node: process.version, + rustc: commandOutput("rustc", ["--version"]) || null, + cargo: commandOutput("cargo", ["--version"]) || null, + tar: commandOutput("tar", ["--version"]) || null, + }, + commands: [ + "node scripts/public-release-dryrun-contract-smoke.js", + ...(publicTreePublish.enabled + ? [`git push public HEAD:${publicRepoBranch}`] + : []), + "cargo build --workspace --bins --release --jobs 2", + ], + assets: [...assets, sha256Sums].map((asset) => ({ + file: asset, + name: path.basename(asset), + sha256: sha256File(asset), + })), + notes: [ + "Upload the assets to the Forgejo Release for the filtered public repository.", + "The real service deployment and full e2e dry run remain separate acceptance evidence.", + ], + }; + + const manifestPath = path.join(outputRoot, "public-release-manifest.json"); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(JSON.stringify({ manifest: manifestPath, assets: assetsDir }, null, 2)); +} + +main(); diff --git a/scripts/public-browser-login-contract-smoke.js b/scripts/public-browser-login-contract-smoke.js new file mode 100644 index 0000000..5cd589d --- /dev/null +++ b/scripts/public-browser-login-contract-smoke.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const infraRepo = path.resolve(repo, "..", "michelpaulissen.com"); + +function read(relativePath, base = repo) { + return fs.readFileSync(path.join(base, relativePath), "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing public browser login evidence: ${name}`); +} + +const cliSource = [ + read("crates/disasmer-cli/src/main.rs"), + read("crates/disasmer-cli/src/dispatch.rs"), + read("crates/disasmer-cli/src/auth.rs"), +].join("\n"); +const cliSmoke = read("scripts/cli-browser-login-flow-smoke.js"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const phase2 = read("acceptance_criteria_phase2.md"); +const base = read("acceptance_criteria.md"); + +for (const [name, source] of [ + ["base acceptance", base], + ["phase 2 acceptance", phase2], +]) { + expect(source, `${name} released binary browser criterion`, /Public released binaries[\s\S]*(?:human |server-owned )browser\/account flow/); +} + +expect(cliSource, "diagnostic plan flag", /plan: bool/); +expect(cliSource, "interactive browser branch", /else if !args\.plan[\s\S]*execute_interactive_browser_login/); +expect(cliSource, "browser command override", /DISASMER_BROWSER_OPEN_COMMAND/); +expect(cliSource, "server-owned login start", /"type": "begin_oidc_browser_login"[\s\S]*requested_project/); +expect(cliSource, "opaque login polling", /"type": "poll_oidc_browser_login"[\s\S]*transaction_id[\s\S]*polling_secret/); +expect(cliSource, "server-owned state nonce and PKCE", /server_owns_state: true[\s\S]*server_owns_nonce: true[\s\S]*pkce_required: true/); +expect(cliSource, "CLI receives no provider code or claims", /cli_receives_provider_authorization_code: false[\s\S]*cli_submits_identity_claims: false/); +assert.doesNotMatch(cliSource, /TcpListener::bind|authorization_code.*issuer_url.*client_id/s); +expect(cliSmoke, "smoke uses fake browser opener", /DISASMER_BROWSER_OPEN_COMMAND/); +expect(cliSmoke, "smoke verifies server-owned browser transaction", /server-owned browser transaction/); +expect(cliSmoke, "smoke rejects client OIDC authority", /assert\.deepStrictEqual\(request,[\s\S]*begin_oidc_browser_login[\s\S]*requested_project/); +expect(cliSmoke, "smoke verifies coordinator completion", /scoped_cli_session_received/); +expect(publicAcceptance, "public acceptance runs browser flow smoke", /node scripts\/cli-browser-login-flow-smoke\.js/); + +if (fs.existsSync(infraRepo)) { + const stack = read("modules/stack.nix", infraRepo); + const hypervisor = read("hosts/hypervisor/default.nix", infraRepo); + const oauth = read("modules/oauth-bootstrap.nix", infraRepo); + const site = read("disasmer-site/index.html", infraRepo); + + expect(stack, "Disasmer public host", /publicHost = "disasmer\.michelpaulissen\.com"/); + expect(stack, "Disasmer loopback API port", /apiPort = 9080/); + expect(hypervisor, "nginx Disasmer vhost", /\$\{stack\.hosts\.disasmer\.publicHost\}/); + expect(hypervisor, "nginx Disasmer site root", /root = \.\.\/\.\.\/disasmer-site/); + expect(hypervisor, "nginx control API proxy", /locations\."= \/api\/v1\/control"[\s\S]*proxyPass/); + expect(hypervisor, "nginx hosted callback proxy", /locations\."= \/auth\/callback"[\s\S]*proxyPass/); + assert.doesNotMatch(hypervisor, /locations\."= \/auth\/browser\/start"/); + expect(hypervisor, "hosted service uses configured API port", /stack\.hosts\.disasmer\.apiPort/); + expect(oauth, "Disasmer Authentik provider", /name: disasmer-provider/); + expect(oauth, "Disasmer public OIDC client", /client_type: public/); + expect(oauth, "Disasmer client id", /client_id: \${disasmerClientId}/); + expect(oauth, "Disasmer hosted callback", /https:\/\/disasmer\.michelpaulissen\.com\/auth\/callback/); + expect(oauth, "Disasmer Authentik application", /slug: disasmer/); + expect(site, "site describes CLI login", /disasmer login --browser/); + expect(site, "site describes hosted callback authority", /hosted service processes the callback/); + expect(site, "site explains attached-node execution", /placed on a node you attach and explicitly authorize/); + expect(site, "site labels pre-release status", /pre-release deployment under active verification/); + assert.doesNotMatch(site, /local callback|provider codes and tokens are returned to the CLI/i); +} else { + console.warn("Skipping VPS config checks because ../michelpaulissen.com is not present"); +} + +console.log("Public browser login contract smoke passed"); diff --git a/scripts/public-local-demo-matrix-smoke.js b/scripts/public-local-demo-matrix-smoke.js new file mode 100755 index 0000000..65bbea8 --- /dev/null +++ b/scripts/public-local-demo-matrix-smoke.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing public local demo evidence: ${name}`); +} + +const publicAcceptance = read("scripts/acceptance-public.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const cliInstall = read("scripts/cli-install-smoke.js"); +const flagship = read("scripts/flagship-demo-smoke.js"); +const cliLocalRun = read("scripts/cli-local-run-smoke.js"); +const nodeAttach = read("scripts/node-attach-smoke.js"); +const vscodeExtension = read("scripts/vscode-extension-smoke.js"); +const vscodeF5 = read("scripts/vscode-f5-smoke.js"); +const artifactDownload = read("scripts/artifact-download-smoke.js"); +const artifactExport = read("scripts/artifact-export-smoke.js"); + +for (const script of [publicAcceptance, publicSplit]) { + for (const smoke of [ + "scripts/cli-install-smoke.js", + "scripts/flagship-demo-smoke.js", + "scripts/cli-local-run-smoke.js", + "scripts/node-attach-smoke.js", + "scripts/vscode-extension-smoke.js", + "scripts/vscode-f5-smoke.js", + "scripts/artifact-download-smoke.js", + "scripts/artifact-export-smoke.js", + ]) { + assert(script.includes(`node ${smoke}`), `public demo gate must run ${smoke}`); + } +} + +expect(cliInstall, "CLI install from project path", /cargo[\s\S]*install[\s\S]*crates\/disasmer-cli/); +expect(cliInstall, "CLI install smoke targets flagship project", /const project = path\.join\(repo, "examples\/launch-build-demo"\)/); +expect(cliInstall, "installed CLI inspects flagship project", /installedBin[\s\S]*\["bundle", "inspect", "--project", project, "--json"\]/); + +expect(flagship, "flagship project source is Rust workflow", /examples\/launch-build-demo[\s\S]*src\/build\.rs/); +expect(flagship, "flagship source avoids local machine assumptions", /forbiddenSourceAssumptions/); +expect(flagship, "flagship cargo test runs", /cargo[\s\S]*test[\s\S]*launch-build-demo/); + +expect(cliLocalRun, "local run starts node process", /cli_process_started_node_process[\s\S]*true/); +expect(cliLocalRun, "local run records real task events", /events\.events\.length >= 4[\s\S]*prepare_source[\s\S]*compile_linux[\s\S]*package_release/); +expect(cliLocalRun, "local run records artifact metadata", /events\.events\.some\(\(event\) => event\.artifact_path\)/); + +expect(nodeAttach, "Linux node attach creates enrollment grant", /create_node_enrollment_grant/); +expect(nodeAttach, "Linux node attach uses enrollment exchange", /used_enrollment_exchange[\s\S]*true/); +expect(nodeAttach, "attached Linux node proves signed enrollment", /used_enrollment_exchange[\s\S]*signedNodeHeartbeat/); + +expect(vscodeExtension, "extension contributes debugger", /contributes\.debuggers[\s\S]*type === "disasmer"/); +expect(vscodeF5, "F5 uses local-services backend", /runtimeBackend[\s\S]*local-services/); +expect(vscodeF5, "F5 exposes virtual thread", /thread\.name\.includes\("build virtual process"\)[\s\S]*virtual thread/); +expect(vscodeF5, "F5 does not fabricate a terminal event at the entry probe", /coordinator_task_events[\s\S]*value === 0[\s\S]*must not fabricate a terminal task event/); + +expect(artifactDownload, "artifact download creates scoped link", /create_artifact_download_link/); +expect(artifactDownload, "artifact download opens stream", /open_artifact_download_stream/); +expect(artifactExport, "artifact export targets receiver node", /export_artifact_to_node[\s\S]*node-export-receiver/); +expect(artifactExport, "artifact export rejects coordinator bulk relay", /coordinator_bulk_relay_allowed[\s\S]*false/); + +console.log("Public local demo matrix smoke passed"); diff --git a/scripts/public-private-boundary-smoke.js b/scripts/public-private-boundary-smoke.js new file mode 100644 index 0000000..d211e00 --- /dev/null +++ b/scripts/public-private-boundary-smoke.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function walk(relativePath, files = []) { + const fullPath = path.join(repo, relativePath); + if (!fs.existsSync(fullPath)) return files; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + const base = path.basename(relativePath); + if (["target", "node_modules", ".git", "containers-home"].includes(base)) { + return files; + } + for (const entry of fs.readdirSync(fullPath)) { + walk(path.join(relativePath, entry), files); + } + return files; + } + files.push(relativePath); + return files; +} + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function copyPublicTree(sourceRoot, destinationRoot, relativePath = ".") { + const skippedDirectories = new Set([ + ".git", + "target", + "node_modules", + "containers-home", + "private", + "experiments", + ]); + const parts = relativePath.split(path.sep).filter(Boolean); + if (parts.some((part) => skippedDirectories.has(part))) { + return; + } + + const source = path.join(sourceRoot, relativePath); + const destination = path.join(destinationRoot, relativePath); + const stat = fs.statSync(source); + if (stat.isDirectory()) { + fs.mkdirSync(destination, { recursive: true }); + for (const entry of fs.readdirSync(source)) { + copyPublicTree(sourceRoot, destinationRoot, path.join(relativePath, entry)); + } + return; + } + + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); +} + +function assertPublicSplitTreeIsCoherent() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-split-")); + try { + copyPublicTree(repo, tmp); + + for (const forbidden of ["private", "experiments"]) { + assert( + !fs.existsSync(path.join(tmp, forbidden)), + `${forbidden} must be absent from the public split tree` + ); + } + + const workspaceToml = fs.readFileSync(path.join(tmp, "Cargo.toml"), "utf8"); + const membersBlock = workspaceToml.match(/members\s*=\s*\[([\s\S]*?)\]/); + assert(membersBlock, "public workspace must define members"); + const members = [...membersBlock[1].matchAll(/"([^"]+)"/g)].map( + (match) => match[1] + ); + assert(members.length > 0, "public workspace must list members"); + for (const member of members) { + assert( + !member.startsWith("private/") && !member.startsWith("experiments/"), + `public workspace member must not point at filtered source: ${member}` + ); + assert( + fs.existsSync(path.join(tmp, member, "Cargo.toml")), + `public workspace member is missing after split: ${member}` + ); + } + + for (const required of [ + "scripts/acceptance-public.sh", + "scripts/verify-public-split.sh", + "scripts/public-private-boundary-smoke.js", + "scripts/public-local-demo-matrix-smoke.js", + "vscode-extension/package.json", + "examples/launch-build-demo/Cargo.toml", + ]) { + assert( + fs.existsSync(path.join(tmp, required)), + `public split tree is missing ${required}` + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +function publicSourceFiles() { + return [ + ...walk("crates"), + ...walk("examples"), + ...walk("scripts"), + ...walk("vscode-extension"), + "Cargo.toml", + ].filter((file) => { + if (file === "scripts/acceptance-private.sh") return false; + if (file === "scripts/acceptance-evidence-contract-smoke.js") return false; + if (file === "scripts/acceptance-environment-contract-smoke.js") return false; + if (file === "scripts/docs-smoke.js") return false; + if (file === "scripts/public-private-boundary-smoke.js") return false; + if (file === "scripts/release-blocker-smoke.js") return false; + return /\.(rs|toml|js|json|sh)$/.test(file); + }); +} + +const forbiddenPublicPatterns = [ + /\bdisasmer[_-]hosted[_-]policy\b/, + /private\/hosted-policy/, + /path\s*=\s*["'][^"']*private\//, + /include!\s*\([^)]*private\//, + /mod\s+private_hosted/, +]; + +for (const file of publicSourceFiles()) { + const content = read(file); + for (const pattern of forbiddenPublicPatterns) { + assert( + !pattern.test(content), + `public source ${file} must not reference private hosted code via ${pattern}` + ); + } +} + +const workspace = read("Cargo.toml"); +assert( + !/members\s*=\s*\[[\s\S]*private\//.test(workspace), + "public workspace members must not include private hosted crates" +); +assertPublicSplitTreeIsCoherent(); + +const privateHostedRoot = path.join(repo, "private", "hosted-policy"); +if (fs.existsSync(privateHostedRoot)) { + const privateCargo = read("private/hosted-policy/Cargo.toml"); + assert.match(privateCargo, /name\s*=\s*"disasmer-hosted-policy"/); + assert.match(privateCargo, /disasmer-core\s*=\s*\{/); + assert.match(privateCargo, /disasmer-coordinator\s*=\s*\{/); + + const privateLib = read("private/hosted-policy/src/lib.rs"); + for (const required of [ + "AuthentikOidcConfig", + "CommunityTierPolicy", + "AdminControls", + "preflight_zero_capability_hosted_wasm", + "impl CapabilityPolicy for CommunityTierPolicy", + "AuthContext", + ]) { + assert( + privateLib.includes(required), + `private hosted policy is missing ${required}` + ); + } + + const privateFiles = walk("private").filter((file) => !file.includes("/target/")); + const privateNodeRuntimeFiles = privateFiles.filter((file) => + /(^|\/)(disasmer-node|node-runtime)(\/|$)/.test(file) + ); + assert.deepStrictEqual( + privateNodeRuntimeFiles, + [], + "hosted mode must not carry a forked private node runtime" + ); +} + +console.log("Public/private boundary smoke passed"); diff --git a/scripts/public-release-dryrun-contract-smoke.js b/scripts/public-release-dryrun-contract-smoke.js new file mode 100644 index 0000000..4b5d26c --- /dev/null +++ b/scripts/public-release-dryrun-contract-smoke.js @@ -0,0 +1,321 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const serviceEndpoint = "https://disasmer.michelpaulissen.com"; +const serviceHost = "disasmer.michelpaulissen.com"; +const forgejoHost = "git.michelpaulissen.com"; + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function maybeRead(relativePath) { + const file = path.join(repo, relativePath); + return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; +} + +function expect(source, name, pattern) { + assert(source !== null && source !== undefined, `missing source for ${name}`); + assert.match(source, pattern, `missing public release dry-run evidence: ${name}`); +} + +const phase2 = maybeRead("acceptance_criteria_phase2.md"); +const readme = maybeRead("README.md"); +const cliSource = [ + read("crates/disasmer-cli/src/main.rs"), + read("crates/disasmer-cli/src/config.rs"), + read("crates/disasmer-cli/src/run.rs"), + read("crates/disasmer-cli/src/client.rs"), + read("crates/disasmer-cli/src/tests.rs"), +].join("\n"); +const controlSource = read("crates/disasmer-control/src/lib.rs"); +const cliLoginSmoke = read("scripts/cli-login-smoke.js"); +const vscodePackage = JSON.parse(read("vscode-extension/package.json")); +const vscodeExtension = read("vscode-extension/extension.js"); +const vscodeSmoke = read("scripts/vscode-extension-smoke.js"); +const prepScript = read("scripts/prepare-public-release-dryrun.js"); +const publishScript = read("scripts/publish-public-release-dryrun.js"); +const preflightScript = read("scripts/public-release-dryrun-preflight.js"); +const e2eScript = read("scripts/public-release-dryrun-e2e.js"); +const finalEvidenceScript = read("scripts/public-release-dryrun-final-evidence.js"); +const workflow = maybeRead(".forgejo/workflows/public-release-dryrun.yml"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); + +if (readme) { + expect(readme, "README", new RegExp(serviceHost.replaceAll(".", "\\."))); +} + +if (phase2) { + const name = "phase 2 criteria"; + expect(phase2, name, new RegExp(serviceHost.replaceAll(".", "\\."))); + expect(phase2, `${name} DNS publication state`, /DNS record|public DNS|dns[-_]publication/i); + expect(phase2, `${name} resolver fallback`, /no resolver override|required resolver override|hosts entry|controlled resolution|fallback/i); + expect(phase2, `${name} Forgejo host`, new RegExp(forgejoHost.replaceAll(".", "\\."))); + expect(phase2, `${name} Forgejo Release`, /Forgejo Release/); + expect(phase2, `${name} compiled assets`, /compiled\s+(release\s+)?assets|compiled release assets/); + expect(phase2, `${name} filtered tree`, /private\/\*\*[\s\S]*experiments\/\*\*/); + expect(phase2, `${name} root Markdown export filter`, /root `\*\.md`|root Markdown/); + expect(phase2, `${name} host-neutral workflow filter`, /\.forgejo[\s\S]*--include-forgejo-workflows/); + expect(phase2, `${name} GitHub release out of scope`, /GitHub[\s\S]*outside this dry run|public GitHub-release[\s\S]*out of scope/); +} + +expect(cliSource, "CLI default hosted coordinator constant", new RegExp(`DEFAULT_HOSTED_COORDINATOR_ENDPOINT: &str =[\\s\\S]*"${serviceEndpoint.replaceAll(".", "\\.")}"`)); +expect(cliSource, "login uses default hosted coordinator", /default_value_t = default_hosted_coordinator_endpoint\(\)/); +expect(cliSource, "hosted run records coordinator endpoint", /hosted_coordinator_endpoint[\s\S]*Some\(default_hosted_coordinator_endpoint\(\)\)/); +expect(controlSource, "hosted URL uses the shared HTTP control transport", /ControlTransport::Https[\s\S]*agent[\s\S]*\.post\(url\)/); +expect(controlSource, "hosted control transport uses the canonical API path", /CONTROL_API_PATH: &str = "\/api\/v1\/control"/); +expect(controlSource, "plain HTTP is restricted to loopback", /endpoint\.starts_with\("http:\/\/"\) && !endpoint_is_loopback\(endpoint\)/); +expect(cliSource, "default hosted coordinator resolves to the HTTPS control API", /hosted_coordinator_remains_a_real_https_control_endpoint[\s\S]*https:\/\/disasmer\.michelpaulissen\.com\/api\/v1\/control/); +assert.doesNotMatch(cliSource, /coord\.disasmer\.invalid/); + +expect(cliLoginSmoke, "CLI login smoke covers default hosted coordinator", new RegExp(`defaultHostedCoordinatorEndpoint = "${serviceEndpoint.replaceAll(".", "\\.")}"`)); +assert.strictEqual( + vscodePackage.contributes.debuggers[0].configurationAttributes.launch.properties.coordinatorEndpoint.default, + undefined +); +expect(vscodeSmoke, "VS Code smoke covers session-inferred coordinator endpoint", /coordinatorEndpoint\.default, undefined/); + +for (const binary of [ + "disasmer", + "disasmer-coordinator", + "disasmer-node", + "disasmer-debug-dap", +]) { + expect(prepScript, `release prep includes ${binary}`, new RegExp(`"${binary}"`)); +} + +for (const [name, pattern] of [ + ["release prep filters private and experiments", /filteredTopLevel = \["private", "experiments", "\.git", "target"\]/], + ["release prep enumerates Git-ignored source paths", /function gitIgnoredSourcePaths\(\)[\s\S]*"ls-files"[\s\S]*"--ignored"[\s\S]*"--exclude-standard"[\s\S]*"--directory"/], + ["release prep has archive-safe ignored-path fallbacks without Git metadata", /archiveIgnoredPathFallbacks = \[[\s\S]*"target"[\s\S]*"\.disasmer"[\s\S]*"vscode-extension\/node_modules"[\s\S]*"scripts\/containers-home"[\s\S]*if \(output === null\) \{[\s\S]*return archiveIgnoredPathFallbacks/], + ["release prep filters Git-ignored source paths", /isGitIgnored\(childRelative, ignoredSourcePaths\)/], + ["release prep verifies Git-ignored paths are absent", /Git-ignored source path leaked into the dry-run public tree/], + ["release prep filters generated view state", /filteredDirectoryNames = \["\.disasmer"\]/], + ["release prep filters internal root Markdown", /isFilteredRootMarkdown[\s\S]*path\.extname\(entry\.name\)\.toLowerCase\(\) === "\.md"[\s\S]*README\.md[\s\S]*SECURITY\.md/], + ["release prep retains product README", /product README\.md is missing from the dry-run public tree/], + ["release prep filters .forgejo by default", /topLevel === "\.forgejo" && !includeForgejoWorkflows/], + ["release prep has Forgejo workflow opt-in argument", /--include-forgejo-workflows/], + ["release prep builds public release bins", /cargo"[\s\S]*"build"[\s\S]*"--workspace"[\s\S]*"--bins"[\s\S]*"--release"/], + ["release prep writes binary archive", /disasmer-public-binaries-\$\{releaseName\}-\$\{platformName\(\)\}\.tar\.gz/], + ["release prep writes source archive", /disasmer-public-source-\$\{releaseName\}\.tar\.gz/], + ["release prep writes extension VSIX", /\$\{packageJson\.name\}-\$\{packageJson\.version\}\.vsix[\s\S]*@vscode\/vsce[\s\S]*package/], + ["release prep packages extension non-interactively", /@vscode\/vsce[\s\S]*package[\s\S]*--skip-license/], + ["release prep writes selected-user guide", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\$\{releaseName\}\.md/], + ["release prep writes selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\$\{releaseName\}\.md/], + ["release prep accepts resolver instructions", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS/], + ["release prep accepts hosts entry", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY/], + ["release prep accepts deployment IP", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP/], + ["selected-user guide documents DNS or fallback", /public DNS[\s\S]*(fallback|hosts entry)|DNS record[\s\S]*controlled resolution/], + ["selected-user guide names hosted coordinator", /default hosted coordinator is the deployment/], + ["selected-user guide rejects standalone Core coordinator", /standalone open-source Core coordinator/], + ["selected-user invite documents friends dry run", /friends helping test[\s\S]*not broadly advertised/], + ["selected-user invite distinguishes Core", /hosted server is not the standalone Core\s+coordinator/], + ["selected-user guide documents default login", /disasmer login --browser/], + ["selected-user guide documents VSIX install", /code --install-extension disasmer-vscode-\*\.vsix/], + ["selected-user guide documents node attach", /disasmer node attach --coordinator https:\/\/disasmer\.michelpaulissen\.com/], + ["release prep packages generated evidence", /stageEvidenceAsset[\s\S]*EVIDENCE_BINDING\.json[\s\S]*disasmer-public-evidence-/], + ["release prep binds binary digests", /binary_digests: binaryDigests/], + ["release prep writes checksums", /SHA256SUMS/], + ["release prep writes manifest", /public-release-manifest\.json/], + ["release prep writes public tree provenance", /DISASMER_PUBLIC_TREE\.json/], + ["release prep records public tree identity", /public_tree_identity: publicTreeIdentity/], + ["release prep records DNS state", /dns_publication_state:/], + ["release prep records resolver override", /resolver_override:/], + ["release prep records Forgejo URLs", /public_repo_url:[\s\S]*public_repo_remote:[\s\S]*forgejo_release_url:/], + ["release prep requires publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE/], + ["release prep requires Forgejo remote", /remote\.includes\(forgejoHost\)/], + ["release prep can push filtered tree", /git"[\s\S]*"push"[\s\S]*"public"[\s\S]*`HEAD:\$\{publicRepoBranch\}`/], + ["release prep records publish result", /public_tree_publish: publicTreePublish/], + ["release prep publishes tree before building target", /const publicTreePublish = publishPublicTree[\s\S]*buildPublicBinaries\(\)/], +]) { + expect(prepScript, name, pattern); +} + +for (const [name, pattern] of [ + ["release publisher requires Forgejo token", /DISASMER_FORGEJO_TOKEN/], + ["release publisher infers Forgejo repo identity", /function resolveRepoIdentity\(manifest\)[\s\S]*parseForgejoRepoIdentity/], + ["release publisher uses manifest public repo URL", /manifest\.public_repo_url[\s\S]*manifest\.public_repo_remote/], + ["release publisher uses token auth", /Authorization: `token \$\{token\}`/], + ["release publisher lists releases", /\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases\?limit=100/], + ["release publisher creates release", /POST[\s\S]*\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases/], + ["release publisher reloads release details", /function loadRelease\(releaseId\)[\s\S]*\/releases\/\$\{releaseId\}/], + ["release publisher uploads assets", /releases\/\$\{release\.id\}\/assets\?name=\$\{encodeURIComponent\(asset\.name\)\}/], + ["release publisher uses attachment multipart field", /multipartFile\("attachment", asset\.file\)/], + ["release publisher skips already attached assets", /existingAssetByName\(release, asset\.name\)/], + ["release publisher rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/], + ["release publisher checks manifest kind", /manifest\.kind !== "disasmer-public-release-dryrun"/], + ["release publisher requires public tree push", /public tree must be pushed before publishing the Forgejo Release/], + ["release publisher accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], + ["release publisher targets external public tree commit", /function publicTreeCommit\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_COMMIT/], + ["release publisher writes evidence report", /public-release-dryrun-forgejo-release\.json/], + ["release publisher records reused assets", /reused_assets:/], +]) { + expect(publishScript, name, pattern); +} + +for (const [name, pattern] of [ + ["preflight rejects stale release manifests", /manifest\.source_commit[\s\S]*currentSourceCommit/], + ["preflight disables interactive remote prompts", /function nonInteractiveEnv[\s\S]*GIT_TERMINAL_PROMPT[\s\S]*GIT_ASKPASS[\s\S]*SSH_ASKPASS[\s\S]*GIT_SSH_COMMAND[\s\S]*BatchMode=yes[\s\S]*NumberOfPasswordPrompts=0[\s\S]*git", \["ls-remote"[\s\S]*timeout/], + ["preflight accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], + ["preflight verifies public branch commit", /remoteMain[\s\S]*publicTreeCommit/], + ["preflight verifies local asset checksums", /parseSha256Sums[\s\S]*checksum mismatch/], + ["preflight records public tree push source", /public_tree_push_source/], + ["preflight records pending external gates", /external_gates:[\s\S]*forgejo_release_publication[\s\S]*public_release_e2e/], + ["preflight writes evidence report", /public-release-dryrun-preflight\.json/], +]) { + expect(preflightScript, name, pattern); +} + +for (const [name, pattern] of [ + ["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/], + ["e2e runner rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/], + ["e2e runner disables interactive git prompts", /function nonInteractiveGitEnv[\s\S]*GIT_TERMINAL_PROMPT[\s\S]*GIT_ASKPASS[\s\S]*SSH_ASKPASS[\s\S]*GIT_SSH_COMMAND[\s\S]*BatchMode=yes[\s\S]*NumberOfPasswordPrompts=0[\s\S]*function commandEnv/], + ["e2e runner accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], + ["e2e runner downloads Forgejo Release assets", /downloadReleaseAssets/], + ["e2e runner verifies SHA256SUMS", /verifyChecksums/], + ["e2e runner clones public repo", /"git", \["clone", "--depth", "1"/], + ["e2e runner rejects private tree leaks", /private[\s\S]*experiments[\s\S]*target/], + ["e2e runner rejects internal root Markdown leaks", /internal root Markdown file leaked into public repo/], + ["e2e runner requires product README", /public checkout must include product README\.md/], + ["e2e runner rejects .forgejo leaks by default", /\.forgejo\/ leaked into public repo/], + ["e2e runner rejects generated view state", /generated \.disasmer view state leaked into public repo/], + ["e2e runner checks public tree identity", /hashTree\(checkout\)[\s\S]*manifest\.public_tree_identity/], + ["e2e runner extracts public binaries", /tar"[\s\S]*"-xzf"/], + ["e2e runner loads public coordinator binary", /executable\(installDir, "disasmer-coordinator"\)/], + ["e2e runner checks default hosted coordinator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/], + ["e2e runner requires an external browser driver", /DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND/], + ["e2e runner uses the CLI browser opener boundary", /DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand/], + ["e2e runner derives scope from the hosted session", /const tenant = loginSession\.tenant[\s\S]*const project = loginSession\.project[\s\S]*const user = loginSession\.user/], + ["e2e runner uses public CLI node attach", /"node"[\s\S]*"attach"[\s\S]*serviceEndpoint/], + ["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/], + ["e2e runner launches the released product through CLI", /"run"[\s\S]*"build"[\s\S]*runReport\.status[\s\S]*main_launched/], + ["standalone Core proof launches a real Wasm TaskSpec", /type: "launch_task"[\s\S]*task_spec:[\s\S]*kind: "coordinator_node_wasm"[\s\S]*bundle_digest: manifest\.bundle_digest/], + ["e2e runner verifies assignment polling", /worker_assignment_poll_verified/], + ["e2e runner validates standalone Core coordinator", /validateStandaloneCoreCoordinator/], + ["e2e runner uses canonical standalone Core wire envelopes", /function sendCore\(addr, message\)[\s\S]*coordinatorWireRequest\(message, "public-e2e-core"\)/], + ["e2e runner records public coordinator validation", /core_coordinator_validated/], + ["e2e runner validates released live DAP", /validateReleasedLiveDap/], + ["e2e runner verifies attach-mode Debug Epoch transition", /validateReleasedLiveDap[\s\S]*resumed through coordinator[\s\S]*requested freeze/], + ["e2e runner verifies attached coordinator task completion", /validateReleasedLiveDap[\s\S]*variable\.name === "state" && variable\.value === "Completed"[\s\S]*coordinator task event from node/], + ["e2e runner attaches live DAP to Client-authorized process", /attached_to_existing_client_process/], + ["e2e runner verifies task events", /type: "list_task_events"/], + ["e2e runner verifies download link", /type: "create_artifact_download_link"/], + ["e2e runner verifies VS Code debugger", /scripts\/vscode-f5-smoke\.js/], + ["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/], +]) { + expect(e2eScript, name, pattern); +} + +for (const [name, pattern] of [ + ["final evidence reads manifest", /public-release-manifest\.json/], + ["final evidence rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/], + ["final evidence verifies filtered export", /function assertFilteredOut\(manifest\)/], + ["final evidence reads Forgejo Release report", /public-release-dryrun-forgejo-release\.json/], + ["final evidence reads deployment manifest", /deployment-manifest\.json/], + ["final evidence reads service smoke report", /public-release-dryrun-service\.json/], + ["final evidence reads hosted Client compatibility report", /hosted-client-compat\.json/], + ["final evidence reads Core coordinator compatibility report", /core-coordinator-compat\.json/], + ["final evidence requires public e2e report", /public-release-dryrun-e2e\.json/], + ["final evidence verifies Forgejo host", /forgejoHost = "git\.michelpaulissen\.com"/], + ["final evidence verifies default hosted coordinator", /serviceEndpoint = "https:\/\/disasmer\.michelpaulissen\.com"/], + ["final evidence accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], + ["final evidence records resolved public repo", /resolvedPublicRepoRemote[\s\S]*public_repository_url: resolvedPublicRepoRemote/], + ["final evidence requires private hosted coordinator", /coordinator_implementation[\s\S]*"hosted-policy-coordinator"/], + ["final evidence requires service private coordinator marker", /service\.coordinator_implementation[\s\S]*"hosted-policy-coordinator"/], + ["final evidence requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/], + ["final evidence requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/], + ["final evidence requires current Client compatibility source", /compat\.source_commit[\s\S]*manifest\.source_commit/], + ["final evidence requires current Client compatibility release", /compat\.release_name[\s\S]*manifest\.release_name/], + ["final evidence requires current public coordinator source", /coreCoordinator\.source_commit[\s\S]*manifest\.source_commit/], + ["final evidence requires current public coordinator release", /coreCoordinator\.release_name[\s\S]*manifest\.release_name/], + ["final evidence requires server-generated service enrollment", /"server_generated_enrollment_grant"/], + ["final evidence requires service probe cleanup", /"process_abort"/], + ["final evidence requires process main launch", /e2e\.launch_task_response[\s\S]*"main_launched"/], + ["final evidence requires assignment polling", /worker_assignment_poll_verified/], + ["final evidence requires public coordinator validation", /core_coordinator_validated/], + ["final evidence requires standalone Core coordinator", /standalone-core-coordinator/], + ["final evidence requires released live DAP", /released_live_dap_verified/], + ["final evidence requires pushed public tree", /publicTreeAlreadyPushed\(manifest\)/], + ["final evidence requires release assets", /downloaded_release_assets/], + ["final evidence requires VS Code debugger", /vscode_debugger_verified/], + ["final evidence writes final report", /public-release-dryrun-final\.json/], +]) { + expect(finalEvidenceScript, name, pattern); +} + +if (workflow) { + for (const [name, pattern] of [ + ["manual Forgejo workflow", /workflow_dispatch:/], + ["Linux Forgejo asset job", /linux-assets:[\s\S]*runs-on: docker/], + ["Windows Forgejo asset job", /windows-assets:[\s\S]*runs-on: windows/], + ["Windows runner caveat", /intermittently online/], + ["workflow runs release prep", /node scripts\/prepare-public-release-dryrun\.js/], + ["workflow uploads assets", /actions\/upload-artifact@v4[\s\S]*target\/public-release-dryrun\/assets\/\*/], + ]) { + expect(workflow, name, pattern); + } +} + +expect( + prepScript, + "public tree prep disables interactive git prompts", + /function nonInteractiveGitEnv[\s\S]*GIT_TERMINAL_PROMPT[\s\S]*GIT_ASKPASS[\s\S]*SSH_ASKPASS[\s\S]*GIT_SSH_COMMAND[\s\S]*BatchMode=yes[\s\S]*NumberOfPasswordPrompts=0[\s\S]*function commandEnv/ +); + +for (const [scriptName, script] of [ + ["public acceptance", publicAcceptance], + ["public split", publicSplit], +]) { + assert( + script.includes("node scripts/public-release-dryrun-contract-smoke.js"), + `${scriptName} must run public-release-dryrun-contract-smoke.js` + ); + assert( + script.includes("node scripts/prepare-public-release-dryrun.js"), + `${scriptName} must run prepare-public-release-dryrun.js` + ); + assert( + script.includes("node scripts/publish-public-release-dryrun.js"), + `${scriptName} must be able to publish the Forgejo Release` + ); + assert( + script.includes("DISASMER_FORGEJO_TOKEN"), + `${scriptName} must gate Forgejo Release publishing on a token` + ); + assert( + script.includes('if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]'), + `${scriptName} must not require owner/repo env when the manifest can infer the Forgejo repository` + ); +} + +for (const [scriptName, script] of [ + ["public acceptance", publicAcceptance], + ["private acceptance", privateAcceptance], +]) { + if (scriptName === "public acceptance") { + assert( + script.includes("node scripts/public-release-dryrun-e2e.js"), + "public acceptance must be able to run public-release-dryrun-e2e.js" + ); + assert( + script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"), + "public acceptance must gate public-release-dryrun-e2e.js" + ); + } + assert( + script.includes("node scripts/public-release-dryrun-final-evidence.js"), + `${scriptName} must be able to run public-release-dryrun-final-evidence.js` + ); + assert( + script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"), + `${scriptName} must gate final public release dry-run evidence` + ); +} + +console.log("Public release dry-run contract smoke passed"); diff --git a/scripts/public-release-dryrun-e2e.js b/scripts/public-release-dryrun-e2e.js new file mode 100755 index 0000000..4eb57ed --- /dev/null +++ b/scripts/public-release-dryrun-e2e.js @@ -0,0 +1,1422 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const http = require("http"); +const https = require("https"); +const net = require("net"); +const os = require("os"); +const path = require("path"); +const { configurePodmanTestEnvironment } = require("./podman-test-env"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { nodeIdentity } = require("./node-signing"); +const { + assertPreFinalLedger, + readPhase3Ledger, +} = require("./phase3-ledger"); + +const repo = path.resolve(__dirname, ".."); +configurePodmanTestEnvironment(repo); +if ( + !process.env.DISASMER_PODMAN_NIX_SHELL && + cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status !== 0 && + cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0 +) { + cp.execFileSync( + "nix", + ["shell", "nixpkgs#podman", "--command", "node", __filename], + { + cwd: repo, + env: { ...process.env, DISASMER_PODMAN_NIX_SHELL: "1" }, + stdio: "inherit", + } + ); + process.exit(0); +} +const releaseRoot = path.resolve( + process.env.DISASMER_PUBLIC_RELEASE_DIR || + path.join(repo, "target/public-release-dryrun") +); +const acceptanceRoot = path.join(repo, "target/acceptance"); +const manifestPath = + process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || + path.join(releaseRoot, "public-release-manifest.json"); +const forgejoReportPath = + process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT || + path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"); +const reportPath = + process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT || + path.join(acceptanceRoot, "public-release-dryrun-e2e.json"); +const serviceEndpoint = "https://disasmer.michelpaulissen.com"; +const serviceHost = "disasmer.michelpaulissen.com"; +const serviceAddr = + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || `${serviceHost}:443`; +const forgejoHost = "git.michelpaulissen.com"; +const enabled = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1"; +const browserOpenCommand = + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND; +const dnsPublicationState = + process.env.DISASMER_DNS_PUBLICATION_STATE || "published"; +const resolverOverride = + process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns"; + +function requireEnabled() { + if (!enabled) { + throw new Error( + "DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1 is required because this runs the real public dry-run e2e" + ); + } + if (!browserOpenCommand) { + throw new Error( + "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND is required to complete the server-owned hosted browser login" + ); + } +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function run(command, args, options = {}) { + const commandLine = [command, ...args].join(" "); + commands.push(commandLine); + const execOptions = { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 15 * 60 * 1000, + ...options, + }; + execOptions.env = commandEnv(command, options.env); + return cp.execFileSync(command, args, execOptions); +} + +function runJson(command, args, options = {}) { + const output = run(command, args, options); + try { + return JSON.parse(output); + } catch (_) { + const line = output + .trim() + .split("\n") + .filter(Boolean) + .at(-1); + return JSON.parse(line); + } +} + +function commandOutput(command, args, options = {}) { + try { + const execOptions = { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + ...options, + }; + execOptions.env = commandEnv(command, options.env); + return cp.execFileSync(command, args, execOptions).trim(); + } catch (_) { + return null; + } +} + +function nonInteractiveGitEnv(extra = {}) { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false", + SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false", + GIT_SSH_COMMAND: + process.env.GIT_SSH_COMMAND || + "ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0", + ...extra, + }; +} + +function commandEnv(command, env) { + if (command !== "git") { + return env; + } + return nonInteractiveGitEnv(env); +} + +function expectedSourceCommit() { + return ( + process.env.DISASMER_ACCEPTANCE_COMMIT || + commandOutput("git", ["rev-parse", "HEAD"]) || + "unknown" + ); +} + +function publicTreeAlreadyPushed(manifest) { + return ( + (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || + process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" + ); +} + +function sha256File(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function parseAddr(value) { + const match = /^([^:]+):(\d+)$/.exec(value); + if (!match) { + throw new Error(`service address must be host:port, got ${value}`); + } + return { host: match[1], port: Number(match[2]) }; +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const body = Buffer.from(JSON.stringify(message)); + const request = https.request({ + hostname: addr.host, + port: addr.port, + servername: serviceHost, + path: "/api/v1/control", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": body.length, + }, + timeout: 30000, + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const responseBody = Buffer.concat(chunks).toString("utf8"); + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`hosted control HTTP ${response.statusCode}: ${responseBody}`)); + return; + } + try { + resolve(JSON.parse(responseBody)); + } catch (error) { + reject(error); + } + }); + }); + request.on("timeout", () => request.destroy(new Error(`timed out waiting for ${message.type}`))); + request.on("error", reject); + request.end(body); + }); +} + +function sendCore(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write( + `${JSON.stringify(coordinatorWireRequest(message, "public-e2e-core"))}\n` + ); + }); + let buffer = ""; + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`timed out waiting for standalone Core ${message.type}`)); + }, 30000); + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + clearTimeout(timer); + socket.destroy(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +let hostedClientRequestId = 0; +function authenticatedHostedClientRequest(sessionSecret, message) { + hostedClientRequestId += 1; + return { + type: "coordinator_request", + protocol_version: 1, + request_id: `public-e2e-client-${hostedClientRequestId}`, + operation: "authenticated", + authentication: { + kind: "cli_session", + session: true, + request_operation: message.type, + }, + payload: { + type: "authenticated", + session_secret: sessionSecret, + request: message, + }, + }; +} + +function waitForJsonLine(child, label = "process", timeoutMs = 240000) { + return new Promise((resolve, reject) => { + let buffer = ""; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for JSON line from ${label}`)); + }, timeoutMs); + function cleanup() { + clearTimeout(timer); + child.stdout.off("data", onData); + child.off("exit", onExit); + } + function onData(chunk) { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + cleanup(); + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + cleanup(); + reject(error); + } + } + function onExit(code) { + cleanup(); + reject(new Error(`${label} exited before JSON line with code ${code}`)); + } + child.stdout.on("data", onData); + child.once("exit", onExit); + }); +} + +function stopChild(child) { + if (!child || child.exitCode !== null) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 5000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.kill("SIGTERM"); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class DapClient { + constructor(command, options = {}) { + this.child = cp.spawn(command, [], { + cwd: options.cwd, + detached: process.platform !== "win32", + }); + this.seq = 1; + this.buffer = Buffer.alloc(0); + this.messages = []; + this.waiters = []; + this.stderr = ""; + + this.child.stdout.on("data", (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.parse(); + }); + this.child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString(); + }); + this.child.on("exit", () => this.flushWaiters()); + } + + send(command, args = {}) { + const seq = this.seq++; + const message = { seq, type: "request", command, arguments: args }; + const payload = Buffer.from(JSON.stringify(message)); + this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); + this.child.stdin.write(payload); + return seq; + } + + async response(seq, command) { + const message = await this.waitFor( + (item) => + item.type === "response" && + item.request_seq === seq && + item.command === command + ); + if (!message.success) { + throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(message)}`); + } + return message; + } + + terminate() { + if (this.child.exitCode !== null) return; + if (process.platform === "win32") { + this.child.kill("SIGKILL"); + return; + } + try { + process.kill(-this.child.pid, "SIGKILL"); + } catch (_) { + this.child.kill("SIGKILL"); + } + } + + waitFor(predicate, timeoutMs = 240000) { + const existing = this.messages.find(predicate); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.terminate(); + reject(new Error(`timed out waiting for DAP message\n${this.stderr}`)); + }, timeoutMs); + this.waiters.push({ predicate, resolve, timer }); + }); + } + + parse() { + while (true) { + const headerEnd = this.buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = this.buffer.slice(0, headerEnd).toString(); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) throw new Error(`bad DAP header: ${header}`); + const length = Number(match[1]); + const start = headerEnd + 4; + const end = start + length; + if (this.buffer.length < end) return; + const payload = this.buffer.slice(start, end).toString(); + this.buffer = this.buffer.slice(end); + this.messages.push(JSON.parse(payload)); + this.flushWaiters(); + } + } + + flushWaiters() { + for (const waiter of [...this.waiters]) { + const message = this.messages.find(waiter.predicate); + if (!message) continue; + clearTimeout(waiter.timer); + this.waiters.splice(this.waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + } + + async close() { + if (this.child.exitCode !== null) return; + const seq = this.send("disconnect"); + await this.response(seq, "disconnect"); + this.child.stdin.end(); + } +} + +function download(url, dest, redirects = 0) { + return new Promise((resolve, reject) => { + const client = url.startsWith("http://") ? http : https; + const request = client.get(url, (response) => { + if ( + [301, 302, 303, 307, 308].includes(response.statusCode) && + response.headers.location && + redirects < 5 + ) { + response.resume(); + download(new URL(response.headers.location, url).toString(), dest, redirects + 1) + .then(resolve) + .catch(reject); + return; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + response.resume(); + reject(new Error(`download ${url} failed with ${response.statusCode}`)); + return; + } + ensureDir(path.dirname(dest)); + const file = fs.createWriteStream(dest); + response.pipe(file); + file.on("finish", () => file.close(resolve)); + file.on("error", reject); + }); + request.on("error", reject); + }); +} + +function releaseAssetDownloads(forgejoReport) { + const assets = [ + ...(forgejoReport.uploaded_assets || []), + ...(forgejoReport.reused_assets || []), + ]; + const byName = new Map(); + for (const asset of assets) { + if (asset.name && asset.browser_download_url) { + byName.set(asset.name, asset.browser_download_url); + } + } + return byName; +} + +async function downloadReleaseAssets(manifest, forgejoReport, assetsDir) { + const downloads = releaseAssetDownloads(forgejoReport); + const downloaded = []; + for (const asset of manifest.assets) { + const dest = path.join(assetsDir, asset.name); + const url = downloads.get(asset.name); + if (url) { + await download(url, dest); + } else if (process.env.DISASMER_PUBLIC_RELEASE_USE_LOCAL_ASSETS === "1") { + fs.copyFileSync(asset.file, dest); + } else { + throw new Error(`Forgejo Release report has no download URL for ${asset.name}`); + } + downloaded.push(dest); + } + return downloaded; +} + +function verifyChecksums(assetsDir) { + const sumsPath = path.join(assetsDir, "SHA256SUMS"); + assert(fs.existsSync(sumsPath), "SHA256SUMS must be downloaded from the release"); + for (const line of fs.readFileSync(sumsPath, "utf8").split("\n")) { + if (!line.trim()) continue; + const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line.trim()); + assert(match, `invalid SHA256SUMS line: ${line}`); + const [, expected, name] = match; + const file = path.join(assetsDir, name); + assert(fs.existsSync(file), `checksum references missing asset ${name}`); + assert.strictEqual(sha256File(file), expected, `checksum mismatch for ${name}`); + } +} + +function binaryArchive(manifest) { + const platform = `${os.platform()}-${os.arch()}`; + const asset = manifest.assets.find( + (candidate) => + candidate.name.startsWith("disasmer-public-binaries-") && + candidate.name.includes(platform) + ); + if (!asset) { + throw new Error(`release manifest has no binary archive for ${platform}`); + } + return asset.name; +} + +function walkFiles(root, relative = "") { + const dir = path.join(root, relative); + const entries = fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => relative || entry.name !== ".git") + .sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + for (const entry of entries) { + const childRelative = relative ? path.join(relative, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...walkFiles(root, childRelative)); + } else if (entry.isFile() || entry.isSymbolicLink()) { + files.push(childRelative); + } + } + return files; +} + +function hashTree(root) { + const hash = crypto.createHash("sha256"); + for (const file of walkFiles(root)) { + const absolute = path.join(root, file); + const stat = fs.lstatSync(absolute); + hash.update(file.replaceAll(path.sep, "/")); + hash.update("\0"); + hash.update(String(stat.mode & 0o777)); + hash.update("\0"); + if (stat.isSymbolicLink()) { + hash.update("symlink"); + hash.update("\0"); + hash.update(fs.readlinkSync(absolute)); + } else { + hash.update(fs.readFileSync(absolute)); + } + hash.update("\0"); + } + return `sha256:${hash.digest("hex")}`; +} + +function assertFilteredPublicCheckout(checkout, manifest) { + for (const forbidden of ["private", "experiments", "target"]) { + assert(!fs.existsSync(path.join(checkout, forbidden)), `${forbidden}/ leaked into public repo`); + } + for (const file of walkFiles(checkout)) { + assert( + !file.split(path.sep).includes(".disasmer"), + `generated .disasmer view state leaked into public repo: ${file}` + ); + } + for (const entry of fs.readdirSync(checkout, { withFileTypes: true })) { + if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") { + assert( + ["README.md", "SECURITY.md"].includes(entry.name), + `internal root Markdown file leaked into public repo: ${entry.name}` + ); + } + } + const readmePath = path.join(checkout, "README.md"); + assert(fs.existsSync(readmePath), "public checkout must include product README.md"); + for (const required of ["SECURITY.md", "LICENSE-APACHE", "LICENSE-MIT"]) { + assert( + fs.existsSync(path.join(checkout, required)), + `public checkout must include ${required}` + ); + } + const readme = fs.readFileSync(readmePath, "utf8"); + assert.match(readme, /# Disasmer/); + assert.match(readme, /disasmer (login|project|run|node)/); + const includeForgejoWorkflows = + manifest.public_export && manifest.public_export.include_forgejo_workflows; + if (!includeForgejoWorkflows) { + assert(!fs.existsSync(path.join(checkout, ".forgejo")), ".forgejo/ leaked into public repo"); + } + const provenancePath = path.join(checkout, "DISASMER_PUBLIC_TREE.json"); + assert(fs.existsSync(provenancePath), "public checkout must include DISASMER_PUBLIC_TREE.json"); + const provenance = readJson(provenancePath); + assert.strictEqual(provenance.source_commit, manifest.source_commit); + assert.strictEqual(provenance.release_name, manifest.release_name); + for (const expected of [ + "private/**", + "experiments/**", + ".git", + "target", + "root/*.md except README.md and SECURITY.md", + "**/.disasmer/**", + ]) { + assert(provenance.filtered_out.includes(expected), `public provenance missing ${expected}`); + } + if (!includeForgejoWorkflows) { + assert( + provenance.filtered_out.includes(".forgejo/**"), + "public provenance must record .forgejo filtering" + ); + } + assert.strictEqual(hashTree(checkout), manifest.public_tree_identity); +} + +function executable(root, name) { + const suffix = process.platform === "win32" ? ".exe" : ""; + const file = path.join(root, "bin", `${name}${suffix}`); + assert(fs.existsSync(file), `missing release binary ${file}`); + return file; +} + +const commands = []; + +async function validateStandaloneCoreCoordinator( + disasmerCoordinator, + disasmerNode, + checkout, + bundleArtifact +) { + let coordinator; + let worker; + let workerStderr = ""; + const suffix = String(Date.now()); + const tenant = `public-coordinator-${suffix}`; + const project = `self-hosted-${suffix}`; + const node = `public-node-${suffix}`; + const processId = `vp-public-coordinator-${suffix}`; + const task = "prepare_source"; + const artifactPath = "/vfs/artifacts/public-coordinator-output.txt"; + const sessionSecret = `public-core-session-${suffix}`; + const nodeKey = nodeIdentity("public-release-core-node", node); + const bundleDirectory = path.resolve(checkout, bundleArtifact.directory); + const manifest = readJson(path.join(bundleDirectory, "manifest.json")); + const taskDescriptors = readJson( + path.join(bundleDirectory, manifest.task_descriptors) + ); + const entrypoint = taskDescriptors.find((candidate) => candidate.name === task); + assert(entrypoint, "public bundle omitted the source task"); + const taskDefinition = entrypoint.stable_id; + const taskInstance = `ti:${processId}:task`; + const wasmModuleBase64 = fs + .readFileSync(path.join(bundleDirectory, "module.wasm")) + .toString("base64"); + + try { + const coordinatorArgs = ["--listen", "127.0.0.1:0"]; + commands.push([disasmerCoordinator, ...coordinatorArgs].join(" ")); + coordinator = cp.spawn(disasmerCoordinator, coordinatorArgs, { + cwd: checkout, + env: { + ...process.env, + DISASMER_SELF_HOSTED_SESSION_SECRET: sessionSecret, + DISASMER_SELF_HOSTED_TENANT: tenant, + DISASMER_SELF_HOSTED_PROJECT: project, + DISASMER_SELF_HOSTED_USER: "developer", + }, + }); + const ready = await waitForJsonLine(coordinator, "standalone Core coordinator"); + const addr = parseAddr(ready.listen); + assert.strictEqual(ready.client_authority, "strict"); + assert.strictEqual(ready.self_hosted_session_bootstrapped, true); + assert.strictEqual((await sendCore(addr, { type: "ping" })).type, "pong"); + + const forged = await sendCore(addr, { + type: "create_project", + tenant, + actor_user: "developer", + project, + }); + assert.strictEqual(forged.type, "error"); + + const wrongSession = await sendCore( + addr, + authenticatedHostedClientRequest("wrong-session", { + type: "list_projects", + }) + ); + assert.strictEqual(wrongSession.type, "error"); + + const created = await sendCore( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "create_project", + project, + name: "Public Coordinator Dry Run", + }) + ); + assert( + ["project", "project_created"].includes(created.type), + `unexpected standalone Core coordinator project response: ${created.type}` + ); + + const grant = await sendCore( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "create_node_enrollment_grant", + ttl_seconds: 900, + }) + ); + assert.strictEqual(grant.type, "node_enrollment_grant_created"); + + const workerArgs = [ + "--coordinator", + ready.listen, + "--tenant", + tenant, + "--project-id", + project, + "--node", + node, + "--project-root", + checkout, + "--public-key", + nodeKey.publicKey, + "--enrollment-grant", + grant.grant, + "--worker", + "--assignment-poll-ms", + "50", + "--emit-ready", + ]; + commands.push([disasmerNode, ...workerArgs].join(" ")); + worker = cp.spawn(disasmerNode, workerArgs, { + cwd: checkout, + env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: nodeKey.privateKey }, + }); + worker.stderr.on("data", (chunk) => { + workerStderr += chunk.toString(); + }); + const workerReady = await waitForJsonLine(worker, "standalone Core coordinator worker"); + assert.strictEqual(workerReady.node_status, "ready"); + assert.strictEqual(workerReady.mode, "worker"); + assert.strictEqual(workerReady.node, node); + + const started = await sendCore( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "start_process", + process: processId, + }) + ); + assert.strictEqual(started.type, "process_started"); + + const launch = await sendCore( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "launch_task", + task_spec: { + tenant, + project, + process: processId, + task_definition: taskDefinition, + task_instance: taskInstance, + dispatch: { + kind: "coordinator_node_wasm", + export: entrypoint.export, + abi: "task_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: entrypoint.required_capabilities, + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [], + vfs_epoch: started.epoch, + bundle_digest: manifest.bundle_digest, + }, + wait_for_node: false, + artifact_path: artifactPath, + wasm_module_base64: wasmModuleBase64, + }) + ); + assert.strictEqual(launch.type, "task_launched"); + assert.strictEqual(launch.placement.node, node); + assert.strictEqual(launch.assignment.node, node); + assert.strictEqual(launch.assignment.process, processId); + + const nodeRun = await waitForJsonLine(worker, "standalone Core coordinator worker completion"); + assert.strictEqual(nodeRun.node_status, "completed"); + assert.strictEqual(nodeRun.registration_response.type, "node_enrollment_exchanged"); + assert.strictEqual(nodeRun.task_assignment_response.node, node); + assert.strictEqual(nodeRun.task_assignment_response.process, processId); + assert.strictEqual(nodeRun.task_assignment_response.task, taskInstance); + assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded"); + + const events = await sendCore( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "list_task_events", + process: processId, + }) + ); + assert.strictEqual(events.type, "task_events"); + assert(events.events.length >= 3, "standalone Core did not record the real task tree"); + assert(events.events.some((event) => event.node === node)); + assert(events.events.some((event) => event.task === "compile_linux")); + + return { + coordinator_implementation: "standalone-core-coordinator", + client_authority: ready.client_authority, + authenticated_session: true, + forged_body_authority_denied: forged.type, + wrong_session_denied: wrongSession.type, + coordinator_ready: Boolean(ready.listen), + project_response: created.type, + process_response: started.type, + launch_task_response: launch.type, + assignment_response: "task_assignment", + worker_status: nodeRun.node_status, + task_events: events.events.length, + node, + process: processId, + }; + } finally { + if (workerStderr.trim()) { + console.error(workerStderr); + } + await stopChild(worker); + await stopChild(coordinator); + } +} + +async function variables(client, variablesReference) { + const request = client.send("variables", { variablesReference }); + return (await client.response(request, "variables")).body.variables; +} + +function scopeReference(scopes, name) { + const scope = scopes.find((candidate) => candidate.name === name); + assert(scope, `missing DAP scope ${name}`); + return scope.variablesReference; +} + +async function validateReleasedLiveDap( + disasmerDap, + checkout, + scope, + processId +) { + const projectRoot = path.join(checkout, "examples/launch-build-demo"); + const sourcePath = path.join(projectRoot, "src/build.rs"); + const sourceLines = fs.readFileSync(sourcePath, "utf8").split(/\r?\n/); + const sourceLine = (needle) => { + const index = sourceLines.findIndex((line) => line.includes(needle)); + assert(index >= 0, `released flagship source must contain ${needle}`); + return index + 1; + }; + const preTaskMainLine = sourceLine("pub source: SourceSnapshot"); + const compileLinuxLine = sourceLine("pub fn compile_linux()"); + + const client = new DapClient(disasmerDap, { cwd: checkout }); + try { + const initialize = client.send("initialize", { + adapterID: "disasmer", + linesStartAt1: true, + columnsStartAt1: true, + }); + await client.response(initialize, "initialize"); + + const launch = client.send("attach", { + entry: "build", + project: projectRoot, + runtimeBackend: "live-services", + coordinatorEndpoint: serviceEndpoint, + processId, + }); + await client.response(launch, "attach"); + await client.waitFor( + (message) => message.type === "event" && message.event === "initialized" + ); + + const breakpoints = client.send("setBreakpoints", { + source: { path: sourcePath }, + breakpoints: [{ line: preTaskMainLine }, { line: compileLinuxLine }], + }); + const breakpointResponse = await client.response(breakpoints, "setBreakpoints"); + assert.deepStrictEqual( + breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified), + [true, true] + ); + + const configurationDone = client.send("configurationDone"); + await client.response(configurationDone, "configurationDone"); + const stopped = await client.waitFor( + (message) => message.type === "event" && message.event === "stopped" + ); + assert.strictEqual(stopped.body.reason, "breakpoint"); + assert.strictEqual(stopped.body.threadId, 1); + assert.strictEqual(stopped.body.allThreadsStopped, true); + + const stackRequest = client.send("stackTrace", { + threadId: 1, + startFrame: 0, + levels: 1, + }); + const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames; + assert.strictEqual(stack[0].line, preTaskMainLine); + assert.strictEqual(stack[0].source.path, sourcePath); + + const scopesRequest = client.send("scopes", { frameId: stack[0].id }); + const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; + const runtime = await variables(client, scopeReference(scopes, "Disasmer Runtime")); + const output = await variables(client, scopeReference(scopes, "Recent Output")); + assert( + runtime.some( + (variable) => variable.name === "runtime_backend" && variable.value === "LiveServices" + ) + ); + assert( + runtime.some( + (variable) => variable.name === "coordinator_task_events" && variable.value === 1 + ) + ); + assert( + runtime.some( + (variable) => + variable.name === "command_status" && + String(variable.value).includes("attached to existing virtual process") + ) + ); + assert( + output.some((variable) => + String(variable.value).includes("coordinator returned 1 task event") + ) + ); + + const continueRequest = client.send("continue", { threadId: 1 }); + await client.response(continueRequest, "continue"); + await client.waitFor( + (message) => message.type === "event" && message.event === "continued" + ); + const taskStopped = await client.waitFor( + (message) => + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint" && + message.body.threadId === 2 + ); + assert.strictEqual(taskStopped.body.allThreadsStopped, true); + + const taskStackRequest = client.send("stackTrace", { + threadId: 2, + startFrame: 0, + levels: 1, + }); + const taskStack = (await client.response(taskStackRequest, "stackTrace")).body.stackFrames; + assert.strictEqual(taskStack[0].line, compileLinuxLine); + + const taskScopesRequest = client.send("scopes", { frameId: taskStack[0].id }); + const taskScopes = (await client.response(taskScopesRequest, "scopes")).body.scopes; + const taskRuntime = await variables(client, scopeReference(taskScopes, "Disasmer Runtime")); + const taskOutput = await variables(client, scopeReference(taskScopes, "Recent Output")); + assert( + taskRuntime.some( + (variable) => variable.name === "coordinator_task_events" && variable.value === 1 + ) + ); + assert( + taskRuntime.some( + (variable) => + variable.name === "command_status" && + String(variable.value).includes("resumed through coordinator") && + String(variable.value).includes("requested freeze") + ) + ); + assert( + taskRuntime.some( + (variable) => variable.name === "state" && variable.value === "Completed" + ) + ); + assert(taskRuntime.some((variable) => variable.name === "stdout_tail")); + assert(taskRuntime.some((variable) => variable.name === "stderr_tail")); + assert(taskOutput.some((variable) => variable.name === "stdout_tail")); + assert(taskOutput.some((variable) => variable.name === "stderr_tail")); + assert( + taskOutput.some((variable) => + String(variable.value).includes("coordinator task event from node") + ) + ); + + return { + coordinator_implementation: "hosted-policy-coordinator", + runtime_backend: "live-services", + attached_to_existing_client_process: true, + authenticated_with_cli_session: true, + process: processId, + main_thread_breakpoint: preTaskMainLine, + task_thread_breakpoint: compileLinuxLine, + task_events: 1, + task_runtime_status: "debug epoch resumed and refrozen through live services", + variables_verified: true, + }; + } finally { + await client.close().catch(() => {}); + } +} + +async function main() { + requireEnabled(); + assertPreFinalLedger(readPhase3Ledger(repo)); + const manifest = readJson(manifestPath); + const forgejoReport = readJson(forgejoReportPath); + assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); + assert.strictEqual( + manifest.source_commit, + expectedSourceCommit(), + "public release e2e must use release assets prepared from the current acceptance commit" + ); + assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint); + assert.strictEqual(manifest.source_tree_clean, true); + assert.strictEqual(publicTreeAlreadyPushed(manifest), true); + assert.strictEqual(forgejoReport.release_name, manifest.release_name); + + const addr = parseAddr(serviceAddr); + assert.strictEqual(addr.host, serviceHost); + assert.strictEqual(addr.port, 443); + + const workRoot = path.resolve( + process.env.DISASMER_PUBLIC_RELEASE_E2E_WORKDIR || + fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-dryrun-e2e-")) + ); + const assetsDir = path.join(workRoot, "assets"); + const installDir = path.join(workRoot, "install"); + const checkout = path.join(workRoot, "public-repo"); + ensureDir(workRoot); + fs.rmSync(assetsDir, { recursive: true, force: true }); + fs.rmSync(installDir, { recursive: true, force: true }); + fs.rmSync(checkout, { recursive: true, force: true }); + ensureDir(assetsDir); + ensureDir(installDir); + + await downloadReleaseAssets(manifest, forgejoReport, assetsDir); + verifyChecksums(assetsDir); + + const publicRepositoryUrl = + process.env.DISASMER_PUBLIC_REPO_URL || + manifest.public_repo_url || + manifest.public_repo_remote; + assert(publicRepositoryUrl, "public repository URL is required"); + assert(publicRepositoryUrl.includes(forgejoHost)); + run("git", ["clone", "--depth", "1", publicRepositoryUrl, checkout]); + assertFilteredPublicCheckout(checkout, manifest); + + run("tar", ["-xzf", path.join(assetsDir, binaryArchive(manifest)), "-C", installDir]); + const disasmer = executable(installDir, "disasmer"); + const disasmerCoordinator = executable(installDir, "disasmer-coordinator"); + const disasmerNode = executable(installDir, "disasmer-node"); + const disasmerDap = executable(installDir, "disasmer-debug-dap"); + for (const binary of [disasmer, disasmerCoordinator, disasmerNode, disasmerDap]) { + const name = path.basename(binary); + const expected = manifest.binary_digests && manifest.binary_digests[name]; + assert(expected, `manifest omitted digest for ${name}`); + assert.strictEqual(`sha256:${sha256File(binary)}`, expected); + } + + const defaultLoginPlan = runJson(disasmer, ["login", "--plan", "--json"], { + cwd: checkout, + }); + assert.strictEqual(defaultLoginPlan.coordinator, serviceEndpoint); + + const suffix = String(Date.now()); + const requestedProject = `project-${suffix}`; + const cliNode = `cli-node-${suffix}`; + const runtimeNode = `runtime-node-${suffix}`; + let processId; + let task = "build"; + let mainTask; + let artifactPath; + let artifact; + const projectRoot = path.join(checkout, "examples/launch-build-demo"); + const buildReport = runJson( + disasmer, + ["build", "--project", projectRoot, "--json"], + { cwd: checkout } + ); + assert.strictEqual(buildReport.status, "built"); + assert(buildReport.bundle_artifact); + + const login = runJson( + disasmer, + [ + "login", + "--browser", + "--json", + "--project-id", + requestedProject, + ], + { + cwd: projectRoot, + env: { + ...process.env, + DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand, + }, + } + ); + assert.strictEqual(login.plan.coordinator, serviceEndpoint); + assert.strictEqual(login.boundary.cli_contacted_coordinator, true); + assert.strictEqual(login.boundary.scoped_cli_session_received, true); + assert.strictEqual(login.boundary.provider_tokens_sent_to_nodes, false); + const loginSession = login.coordinator_response.session; + assert(loginSession, "hosted browser login omitted its scoped session"); + const tenant = loginSession.tenant; + const project = loginSession.project; + const user = loginSession.user; + for (const [name, value] of Object.entries({ tenant, project, user })) { + assert.strictEqual(typeof value, "string", `hosted session omitted ${name}`); + assert.notStrictEqual(value, "", `hosted session returned an empty ${name}`); + } + const storedSession = readJson(path.join(projectRoot, ".disasmer/session.json")); + const sessionSecret = storedSession.session_secret; + assert.strictEqual(typeof sessionSecret, "string"); + assert.strictEqual(storedSession.tenant, tenant); + assert.strictEqual(storedSession.project, project); + assert.strictEqual(storedSession.user, user); + + const created = await send( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "create_project", + project, + name: "Public Release Dry Run E2E", + }) + ); + assert.strictEqual(created.type, "project_created"); + + const cliGrant = await send( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "create_node_enrollment_grant", + ttl_seconds: 900, + }) + ); + assert.strictEqual(cliGrant.type, "node_enrollment_grant_created"); + const cliNodeIdentity = nodeIdentity("public-release-cli-node", cliNode); + + const attach = runJson( + disasmer, + [ + "node", + "attach", + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--node", + cliNode, + "--public-key", + cliNodeIdentity.publicKey, + "--enrollment-grant", + cliGrant.grant, + "--json", + ], + { + cwd: checkout, + env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: cliNodeIdentity.privateKey }, + } + ); + assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged"); + assert.strictEqual(attach.heartbeat_response.type, "node_heartbeat"); + + let worker; + let workerStderr = ""; + let launch; + let nodeRun; + let events; + try { + const runtimeGrant = await send( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "create_node_enrollment_grant", + ttl_seconds: 900, + }) + ); + assert.strictEqual(runtimeGrant.type, "node_enrollment_grant_created"); + const runtimeNodeIdentity = nodeIdentity("public-release-runtime-node", runtimeNode); + + const workerArgs = [ + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--node", + runtimeNode, + "--project-root", + projectRoot, + "--public-key", + runtimeNodeIdentity.publicKey, + "--enrollment-grant", + runtimeGrant.grant, + "--worker", + "--assignment-poll-ms", + "500", + "--emit-ready", + ]; + commands.push([disasmerNode, ...workerArgs].join(" ")); + worker = cp.spawn(disasmerNode, workerArgs, { + cwd: checkout, + env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: runtimeNodeIdentity.privateKey }, + }); + worker.stderr.on("data", (chunk) => { + workerStderr += chunk.toString(); + }); + const workerReady = await waitForJsonLine(worker, "public release worker"); + assert.strictEqual(workerReady.node_status, "ready"); + assert.strictEqual(workerReady.mode, "worker"); + assert.strictEqual(workerReady.node, runtimeNode); + + const runReport = runJson( + disasmer, + [ + "run", + "build", + "--coordinator", + serviceEndpoint, + "--project", + projectRoot, + "--json", + ], + { cwd: checkout } + ); + assert.strictEqual(runReport.status, "main_launched"); + processId = runReport.process; + launch = runReport.task_launch; + mainTask = launch.task_instance; + assert.strictEqual(launch.type, "main_launched"); + assert.strictEqual(launch.process, processId); + assert.strictEqual(launch.task_instance, runReport.task_instance); + nodeRun = await waitForJsonLine(worker, "public release worker completion"); + task = nodeRun.task_assignment_response.task; + assert( + task.startsWith(`${mainTask}:child:`), + `flagship worker received unexpected task ${task}` + ); + const flagshipDeadline = Date.now() + 240000; + while (true) { + const progress = await send( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "list_task_events", + process: processId, + }) + ); + if ( + progress.events.some( + (event) => + event.task_definition === "package_release" && + event.terminal_state === "completed" + ) + ) { + break; + } + const failed = progress.events.find( + (event) => event.terminal_state === "failed" + ); + assert(!failed, failed?.stderr_tail || "public flagship task failed"); + assert(Date.now() < flagshipDeadline, "timed out waiting for public flagship"); + await sleep(500); + } + } catch (error) { + await stopChild(worker); + throw error; + } + if (workerStderr.trim() && (!nodeRun || nodeRun.node_status !== "completed")) { + console.error(workerStderr); + } + assert.strictEqual(nodeRun.node_status, "completed"); + assert.strictEqual(nodeRun.registration_response.type, "node_enrollment_exchanged"); + assert.strictEqual(nodeRun.capability_response.type, "node_capabilities_recorded"); + assert.strictEqual(nodeRun.task_assignment_response.node, runtimeNode); + assert.strictEqual(nodeRun.task_assignment_response.process, processId); + assert.strictEqual(nodeRun.task_assignment_response.task, task); + assert.strictEqual(nodeRun.debug_command_response.type, "debug_command"); + assert.strictEqual(nodeRun.log_event_response.type, "task_log_recorded"); + assert.strictEqual(nodeRun.vfs_metadata_response.type, "vfs_metadata_recorded"); + assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded"); + + events = await send( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "list_task_events", + process: processId, + }) + ); + assert.strictEqual(events.type, "task_events"); + assert(events.events.length >= 3, "real flagship run must publish its task tree"); + const artifactEvent = events.events.find( + (event) => + event.task_definition === "package_release" && + event.artifact_path && + event.artifact_digest + ); + assert(artifactEvent, "real flagship run did not publish artifact metadata"); + artifactPath = artifactEvent.artifact_path; + artifact = artifactPath.slice("/vfs/artifacts/".length); + + const link = await send( + addr, + authenticatedHostedClientRequest(sessionSecret, { + type: "create_artifact_download_link", + artifact, + max_bytes: 1024 * 1024, + ttl_seconds: 300, + }) + ); + await stopChild(worker); + worker = null; + assert.strictEqual(link.type, "artifact_download_link"); + assert.match(link.link.scoped_token_digest, /^sha256:[a-f0-9]{64}$/); + + const coreCoordinator = await validateStandaloneCoreCoordinator( + disasmerCoordinator, + disasmerNode, + checkout, + buildReport.bundle_artifact + ); + const releasedLiveDap = await validateReleasedLiveDap( + disasmerDap, + checkout, + { tenant, project, user }, + processId + ); + + run("node", ["scripts/vscode-f5-smoke.js"], { cwd: checkout, stdio: "pipe" }); + + const report = { + kind: "disasmer-public-release-dryrun-e2e", + public_repository_url: publicRepositoryUrl, + release_name: manifest.release_name, + source_commit: manifest.source_commit, + public_tree_identity: manifest.public_tree_identity, + default_hosted_coordinator_endpoint: serviceEndpoint, + service_addr: serviceAddr, + dns_publication_state: dnsPublicationState, + resolver_override: resolverOverride, + downloaded_release_assets: true, + verified_checksums: true, + clean_public_checkout: true, + public_repo_build_or_install: true, + default_operator_selected: true, + browser_or_cli_login: true, + attached_user_node: true, + launch_task_verified: true, + worker_assignment_poll_verified: true, + worker_assignment_poll_protocol: "poll_task_assignment", + core_coordinator_validated: true, + core_coordinator_implementation: coreCoordinator.coordinator_implementation, + core_coordinator_client_authority: coreCoordinator.client_authority, + core_coordinator_authenticated_session: coreCoordinator.authenticated_session, + core_coordinator_forged_body_authority_denied: + coreCoordinator.forged_body_authority_denied, + core_coordinator_wrong_session_denied: coreCoordinator.wrong_session_denied, + core_coordinator_launch_task_response: coreCoordinator.launch_task_response, + core_coordinator_assignment_response: coreCoordinator.assignment_response, + core_coordinator_task_events: coreCoordinator.task_events, + released_live_dap_verified: true, + released_live_dap_coordinator_implementation: releasedLiveDap.coordinator_implementation, + released_live_dap_attached_to_client_process: + releasedLiveDap.attached_to_existing_client_process, + released_live_dap_authenticated_with_cli_session: + releasedLiveDap.authenticated_with_cli_session, + released_live_dap_task_events: releasedLiveDap.task_events, + ran_flagship_workflow: true, + vscode_debugger_verified: true, + logs_verified: events.events.some( + (event) => event.stdout_bytes > 0 || event.stderr_bytes > 0 + ), + artifact_metadata_verified: Boolean( + artifactEvent.artifact_path && artifactEvent.artifact_digest + ), + artifact_download_or_export_verified: true, + tenant, + project, + process: processId, + node: runtimeNode, + launch_task_response: launch.type, + worker_assignment_process: nodeRun.task_assignment_response.process, + artifact, + commands, + tool_versions: { + node: process.version, + git: commandOutput("git", ["--version"]), + tar: commandOutput("tar", ["--version"]), + rustc: commandOutput("rustc", ["--version"], { cwd: checkout }), + cargo: commandOutput("cargo", ["--version"], { cwd: checkout }), + }, + evidence: { + login: login.coordinator_response.type, + attach: attach.coordinator_response.type, + node_run: nodeRun.coordinator_response.type, + task_events: events.events.length, + download_link: link.type, + core_coordinator: coreCoordinator, + released_live_dap: releasedLiveDap, + }, + acceptance_result: "passed", + }; + for (const [key, value] of Object.entries(report)) { + if (key.endsWith("_verified") || key.endsWith("_validated") || key.endsWith("_selected") || key.endsWith("_checkout") || key.endsWith("_assets") || key === "public_repo_build_or_install" || key === "browser_or_cli_login" || key === "attached_user_node" || key === "ran_flagship_workflow" || key === "verified_checksums") { + assert.strictEqual(value, true, `${key} must be true`); + } + } + + ensureDir(path.dirname(reportPath)); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`Public release dry-run e2e passed: ${reportPath}`); +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/public-release-dryrun-final-evidence.js b/scripts/public-release-dryrun-final-evidence.js new file mode 100755 index 0000000..5cb2073 --- /dev/null +++ b/scripts/public-release-dryrun-final-evidence.js @@ -0,0 +1,515 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const { + assertFinalLedger, + readPhase3Ledger, +} = require("./phase3-ledger"); + +const repo = path.resolve(__dirname, ".."); +const releaseRoot = path.resolve( + process.env.DISASMER_PUBLIC_RELEASE_DIR || + path.join(repo, "target/public-release-dryrun") +); +const acceptanceRoot = path.join(repo, "target/acceptance"); +const serviceEndpoint = "https://disasmer.michelpaulissen.com"; +const serviceHost = "disasmer.michelpaulissen.com"; +const serviceAddr = `${serviceHost}:443`; +const forgejoHost = "git.michelpaulissen.com"; + +const inputs = { + manifest: process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || + path.join(releaseRoot, "public-release-manifest.json"), + forgejoRelease: process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT || + path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"), + deployment: process.env.DISASMER_PUBLIC_RELEASE_DEPLOYMENT_MANIFEST || + path.join(releaseRoot, "deployment/stage/deployment-manifest.json"), + service: process.env.DISASMER_PUBLIC_RELEASE_SERVICE_REPORT || + path.join(acceptanceRoot, "public-release-dryrun-service.json"), + hostedClientCompat: process.env.DISASMER_HOSTED_CLIENT_COMPAT_REPORT || + path.join(acceptanceRoot, "hosted-client-compat.json"), + coreCoordinatorCompat: process.env.DISASMER_CORE_COORDINATOR_COMPAT_REPORT || + path.join(acceptanceRoot, "core-coordinator-compat.json"), + e2e: process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT || + path.join(acceptanceRoot, "public-release-dryrun-e2e.json"), +}; + +assertFinalLedger(readPhase3Ledger(repo)); + +function readJson(name, file) { + assert(fs.existsSync(file), `missing ${name} evidence: ${file}`); + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function assertIncludes(value, expected, message) { + assert( + typeof value === "string" && value.includes(expected), + `${message}: expected ${JSON.stringify(value)} to include ${expected}` + ); +} + +function assetNames(manifest) { + assert(Array.isArray(manifest.assets), "manifest assets must be an array"); + return new Set(manifest.assets.map((asset) => asset.name)); +} + +function uploadedAssetNames(report) { + return new Set( + [...(report.uploaded_assets || []), ...(report.reused_assets || [])].map( + (asset) => asset.name + ) + ); +} + +function assertEvidenceBooleans(report, fields) { + for (const field of fields) { + assert.strictEqual(report[field], true, `e2e report must set ${field}=true`); + } +} + +function compactToolVersions(...reports) { + const versions = {}; + for (const [name, report] of reports) { + if (report && report.tool_versions) { + versions[name] = report.tool_versions; + } + } + return versions; +} + +function assertDnsState(value, name) { + assert( + ["not-published", "published"].includes(value), + `${name} has unexpected DNS publication state: ${value}` + ); +} + +function assertFilteredOut(manifest) { + assert(Array.isArray(manifest.filtered_out), "manifest filtered_out must be an array"); + for (const expected of [ + "private/**", + "experiments/**", + ".git", + "target", + "root/*.md except README.md and SECURITY.md", + "**/.disasmer/**", + ]) { + assert( + manifest.filtered_out.includes(expected), + `manifest filtered_out must include ${expected}` + ); + } + const includeForgejoWorkflows = + manifest.public_export && manifest.public_export.include_forgejo_workflows; + if (!includeForgejoWorkflows) { + assert( + manifest.filtered_out.includes(".forgejo/**"), + "manifest filtered_out must include .forgejo/** for the host-neutral export" + ); + } +} + +function publicTreeAlreadyPushed(manifest) { + return ( + (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || + process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" + ); +} + +function publicRepoRemote(manifest) { + return ( + manifest.public_repo_url || + manifest.public_repo_remote || + process.env.DISASMER_PUBLIC_REPO_REMOTE || + process.env.DISASMER_PUBLIC_REPO_URL || + "" + ); +} + +function commandOutput(command, args) { + try { + return require("child_process") + .execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + .trim(); + } catch (_) { + return null; + } +} + +function expectedSourceCommit() { + return ( + process.env.DISASMER_ACCEPTANCE_COMMIT || + commandOutput("git", ["rev-parse", "HEAD"]) || + "unknown" + ); +} + +const manifest = readJson("public release manifest", inputs.manifest); +assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); +assert.strictEqual( + manifest.source_tree_clean, + true, + "final release evidence cannot be produced from a dirty source tree" +); +for (const binary of [ + "disasmer", + "disasmer-coordinator", + "disasmer-node", + "disasmer-debug-dap", +]) { + const platformName = process.platform === "win32" ? `${binary}.exe` : binary; + assert.match( + manifest.binary_digests && manifest.binary_digests[platformName], + /^sha256:[a-f0-9]{64}$/, + `manifest omitted release binary digest for ${platformName}` + ); +} +assert.strictEqual( + manifest.source_commit, + expectedSourceCommit(), + "final public release evidence must be generated from the current acceptance commit" +); +assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint); +assert.strictEqual(manifest.forgejo_host, forgejoHost); +assertDnsState(manifest.dns_publication_state, "manifest"); +assert(manifest.resolver_override, "manifest must record resolver override"); +assertFilteredOut(manifest); +assert.strictEqual( + publicTreeAlreadyPushed(manifest), + true, + "filtered public tree must be pushed to Forgejo" +); +const resolvedPublicRepoRemote = publicRepoRemote(manifest); +assertIncludes( + resolvedPublicRepoRemote, + forgejoHost, + "manifest public repository must point at Forgejo" +); + +const manifestAssets = assetNames(manifest); +for (const pattern of [ + /^disasmer-public-source-/, + /^disasmer-public-binaries-/, + /^disasmer-public-evidence-/, + /^disasmer-vscode-/, + /^DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-/, + /^DISASMER_PUBLIC_DRYRUN_INVITE-/, + /^SHA256SUMS$/, +]) { + assert( + [...manifestAssets].some((name) => pattern.test(name)), + `manifest is missing asset matching ${pattern}` + ); +} + +const forgejoRelease = readJson("Forgejo Release report", inputs.forgejoRelease); +assert.strictEqual( + forgejoRelease.kind, + "disasmer-public-release-dryrun-forgejo-release" +); +assertIncludes(forgejoRelease.forgejo_url, forgejoHost, "Forgejo Release host"); +assert.strictEqual(forgejoRelease.release_name, manifest.release_name); +assert.strictEqual( + forgejoRelease.default_hosted_coordinator_endpoint, + manifest.default_hosted_coordinator_endpoint +); +assert.strictEqual( + forgejoRelease.public_tree_identity, + manifest.public_tree_identity +); +assert.strictEqual(forgejoRelease.source_commit, manifest.source_commit); +const releaseAssets = uploadedAssetNames(forgejoRelease); +for (const asset of manifestAssets) { + assert(releaseAssets.has(asset), `Forgejo Release is missing asset ${asset}`); +} + +const deployment = readJson("deployment manifest", inputs.deployment); +assert.strictEqual(deployment.kind, "disasmer-public-release-dryrun-deployment"); +assert.strictEqual(deployment.default_hosted_coordinator_endpoint, serviceEndpoint); +assert.strictEqual(deployment.coordinator_implementation, "hosted-policy-coordinator"); +assert.match( + deployment.public_release_meaning || "", + /public repository[\s\S]*public release assets[\s\S]*public client protocol/ +); +assert.strictEqual(deployment.service_host, serviceHost); +assert.strictEqual(deployment.service_addr, serviceAddr); +assertDnsState(deployment.dns_publication_state, "deployment"); +assert(deployment.resolver_override, "deployment manifest must record resolver override"); +assert(deployment.deployed_service_commit, "deployment must record deployed service commit"); + +const service = readJson("live service smoke report", inputs.service); +assert.strictEqual(service.kind, "disasmer-public-release-dryrun-service"); +assert.strictEqual(service.source_commit, manifest.source_commit); +assert.strictEqual(service.release_name, manifest.release_name); +assert.strictEqual(service.endpoint, serviceEndpoint); +assert.strictEqual(service.service_addr, serviceAddr); +assert.strictEqual(service.coordinator_implementation, "hosted-policy-coordinator"); +assertDnsState(service.dns_publication_state, "service smoke"); +assert(service.resolver_override, "service smoke must record resolver override"); +assert.strictEqual( + service.public_tree_identity || manifest.public_tree_identity, + manifest.public_tree_identity +); +assert.strictEqual( + service.deployed_service_commit || deployment.deployed_service_commit, + deployment.deployed_service_commit +); +assert(service.deployment_config_commit, "service smoke must record deployment config commit"); +assert( + service.deployment_system_generation, + "service smoke must record the activated NixOS system generation" +); +assert(service.service_unit, "service smoke must record the active service unit fragment"); +for (const key of [ + "ping", + "login", + "projects", + "forged_unsigned_project", + "session_scope", + "server_generated_enrollment_grant", + "node_credential", + "signed_node_heartbeat", + "signed_node_capabilities", + "scoped_node_listing", + "process_start", + "debug_attach", + "process_abort", +]) { + assert(service.evidence && service.evidence[key], `service smoke missing ${key}`); +} +assert.strictEqual(service.evidence.forged_unsigned_project, "error"); +assert.strictEqual(service.evidence.session_scope, "derived_from_server_identity"); +if (service.acceptance_result) { + assert.strictEqual(service.acceptance_result, "passed"); +} + +const compat = readJson("hosted Client compatibility report", inputs.hostedClientCompat); +assert.strictEqual(compat.kind, "disasmer-hosted-client-compatibility"); +assert.strictEqual(compat.source_commit, manifest.source_commit); +assert.strictEqual(compat.release_name, manifest.release_name); +assert.strictEqual( + compat.public_cli_attach.coordinator_response, + "node_enrollment_exchanged" +); +assert.strictEqual(compat.public_cli_attach.heartbeat_response, "node_heartbeat"); +assert.strictEqual(compat.server_owned_browser_login.response, "oidc_browser_session"); +assert(compat.server_owned_browser_login.coordinator_requests >= 2); +assert.strictEqual( + compat.server_owned_browser_login.provider_tokens_exposed_to_cli, + false +); +assert.strictEqual(compat.server_owned_browser_login.session_scope_derived, true); +assert.strictEqual(compat.authority_denials.forged_unsigned_project, "error"); +assert.strictEqual(compat.authority_denials.cross_tenant_task_events, "error"); +assert.strictEqual(compat.session_lifecycle.logout_revoked, true); +assert.strictEqual(compat.session_lifecycle.revoked_reuse, "error"); +assert.strictEqual(compat.session_lifecycle.expired_reuse, "error"); + +const coreCoordinator = readJson( + "Core coordinator compatibility report", + inputs.coreCoordinatorCompat +); +assert.strictEqual(coreCoordinator.kind, "disasmer-core-coordinator-compatibility"); +assert.strictEqual(coreCoordinator.source_commit, manifest.source_commit); +assert.strictEqual(coreCoordinator.release_name, manifest.release_name); +assert.strictEqual( + coreCoordinator.coordinator_implementation, + "standalone-core-coordinator" +); +assert.strictEqual(coreCoordinator.client_authority, "strict"); +assert.strictEqual(coreCoordinator.authenticated_session, true); +assert.strictEqual(coreCoordinator.forged_body_authority_denied, "error"); +assert.strictEqual(coreCoordinator.wrong_session_denied, "error"); +assert.strictEqual(coreCoordinator.self_hosted_cli.connected, "connected"); +assert.strictEqual(coreCoordinator.self_hosted_cli.secret_read_from_stdin, true); +assert.strictEqual(coreCoordinator.self_hosted_cli.secret_exposed_in_report, false); +assert.strictEqual(coreCoordinator.self_hosted_cli.authenticated_status, "auth_status"); +assert.strictEqual( + coreCoordinator.self_hosted_admin.nonce_bound_proof_succeeded, + "admin_status" +); +assert.strictEqual(coreCoordinator.self_hosted_admin.replay_denied, "error"); +if (process.platform !== "win32") { + assert.strictEqual(coreCoordinator.self_hosted_cli.session_file_mode, "600"); +} +assert.strictEqual(coreCoordinator.task_placement, "task_placement"); +assert.strictEqual(coreCoordinator.task_completion, "task_recorded"); +assert(coreCoordinator.task_events >= 1, "Core coordinator compat must record task events"); +assert.strictEqual(coreCoordinator.artifact_export_plan, "artifact_export_plan"); + +const e2e = readJson("public repository e2e report", inputs.e2e); +assert.strictEqual(e2e.kind, "disasmer-public-release-dryrun-e2e"); +assert.strictEqual(e2e.default_hosted_coordinator_endpoint, serviceEndpoint); +assert.strictEqual(e2e.service_addr, serviceAddr); +assert.strictEqual(e2e.launch_task_verified, true); +assert.strictEqual(e2e.worker_assignment_poll_verified, true); +assert.strictEqual(e2e.worker_assignment_poll_protocol, "poll_task_assignment"); +assert.strictEqual(e2e.launch_task_response, "main_launched"); +assert.strictEqual(e2e.worker_assignment_process, e2e.process); +assert.strictEqual(e2e.core_coordinator_validated, true); +assert.strictEqual( + e2e.core_coordinator_implementation, + "standalone-core-coordinator" +); +assert.strictEqual(e2e.core_coordinator_client_authority, "strict"); +assert.strictEqual(e2e.core_coordinator_authenticated_session, true); +assert.strictEqual(e2e.core_coordinator_forged_body_authority_denied, "error"); +assert.strictEqual(e2e.core_coordinator_wrong_session_denied, "error"); +assert.strictEqual(e2e.core_coordinator_launch_task_response, "task_launched"); +assert.strictEqual(e2e.core_coordinator_assignment_response, "task_assignment"); +assert(e2e.core_coordinator_task_events >= 1, "e2e must record public coordinator task events"); +assert.strictEqual(e2e.released_live_dap_verified, true); +assert.strictEqual( + e2e.released_live_dap_coordinator_implementation, + "hosted-policy-coordinator" +); +assert.strictEqual(e2e.released_live_dap_attached_to_client_process, true); +assert.strictEqual(e2e.released_live_dap_authenticated_with_cli_session, true); +assert(e2e.released_live_dap_task_events >= 1, "e2e must record released live-DAP task events"); +assert( + e2e.evidence && + e2e.evidence.released_live_dap && + e2e.evidence.released_live_dap.attached_to_existing_client_process === true, + "e2e must prove released DAP attaches to the existing Client-authorized process" +); +assert( + e2e.evidence && + e2e.evidence.released_live_dap && + e2e.evidence.released_live_dap.variables_verified === true, + "e2e must prove released live-DAP variables are inspectable" +); +if (e2e.dns_publication_state) { + assertDnsState(e2e.dns_publication_state, "public repository e2e"); +} +if (e2e.acceptance_result) { + assert.strictEqual(e2e.acceptance_result, "passed"); +} +assertIncludes(e2e.public_repository_url, forgejoHost, "e2e public repo URL"); +assert.strictEqual(e2e.release_name, manifest.release_name); +assert.strictEqual(e2e.public_tree_identity, manifest.public_tree_identity); +assert.strictEqual(e2e.source_commit, manifest.source_commit); +assertEvidenceBooleans(e2e, [ + "downloaded_release_assets", + "verified_checksums", + "clean_public_checkout", + "public_repo_build_or_install", + "default_operator_selected", + "browser_or_cli_login", + "attached_user_node", + "core_coordinator_validated", + "released_live_dap_verified", + "ran_flagship_workflow", + "vscode_debugger_verified", + "logs_verified", + "artifact_metadata_verified", + "artifact_download_or_export_verified", +]); +assert(Array.isArray(e2e.commands) && e2e.commands.length > 0); +assert(Array.isArray(e2e.tool_versions) || typeof e2e.tool_versions === "object"); + +const finalReport = { + kind: "disasmer-public-release-dryrun-final-evidence", + release_name: manifest.release_name, + source_commit: manifest.source_commit, + public_tree_identity: manifest.public_tree_identity, + deployed_service_commit: deployment.deployed_service_commit, + default_hosted_coordinator_endpoint: serviceEndpoint, + service_addr: serviceAddr, + dns_publication_state: { + manifest: manifest.dns_publication_state, + deployment: deployment.dns_publication_state, + service: service.dns_publication_state, + public_repository_e2e: e2e.dns_publication_state || null, + }, + resolver_override: { + manifest: manifest.resolver_override, + deployment: deployment.resolver_override, + service: service.resolver_override, + public_repository_e2e: e2e.resolver_override || null, + }, + deployment_config: { + repo: service.deployment_config_repo || null, + commit: service.deployment_config_commit || null, + system_generation: service.deployment_system_generation || null, + service_unit: service.service_unit || null, + }, + coordinator_validation: { + private_hosted_default_operator: { + coordinator_implementation: service.coordinator_implementation, + service_addr: service.service_addr, + launch_task: service.evidence.launch_task, + assignment_poll: service.evidence.assignment_poll, + released_live_dap: e2e.evidence.released_live_dap, + }, + standalone_core_coordinator: { + coordinator_implementation: coreCoordinator.coordinator_implementation, + client_authority: coreCoordinator.client_authority, + authenticated_session: coreCoordinator.authenticated_session, + forged_body_authority_denied: + coreCoordinator.forged_body_authority_denied, + wrong_session_denied: coreCoordinator.wrong_session_denied, + self_hosted_cli: coreCoordinator.self_hosted_cli, + task_placement: coreCoordinator.task_placement, + task_events: coreCoordinator.task_events, + release_binary_e2e: { + client_authority: e2e.core_coordinator_client_authority, + authenticated_session: e2e.core_coordinator_authenticated_session, + forged_body_authority_denied: + e2e.core_coordinator_forged_body_authority_denied, + wrong_session_denied: e2e.core_coordinator_wrong_session_denied, + launch_task: e2e.core_coordinator_launch_task_response, + assignment_poll: e2e.core_coordinator_assignment_response, + task_events: e2e.core_coordinator_task_events, + }, + }, + }, + forgejo_host: forgejoHost, + public_repository_url: resolvedPublicRepoRemote, + forgejo_release: { + owner: forgejoRelease.owner, + repo: forgejoRelease.repo, + release_id: forgejoRelease.release_id, + asset_count: releaseAssets.size, + }, + tool_versions: compactToolVersions( + ["manifest", manifest], + ["service_smoke", service], + ["public_repository_e2e", e2e] + ), + acceptance_results: { + public_release_preparation: { + commands: manifest.commands, + result: "passed", + }, + deployment_bundle: { + commands: deployment.commands, + result: "passed", + }, + live_service_smoke: { + command: service.acceptance_command || null, + result: service.acceptance_result || "passed", + evidence: service.evidence, + }, + core_coordinator_compatibility: { + result: "passed", + evidence: coreCoordinator, + }, + public_repository_e2e: { + commands: e2e.commands, + result: "passed", + evidence: e2e.evidence, + }, + }, + evidence_files: inputs, +}; + +const output = path.join(acceptanceRoot, "public-release-dryrun-final.json"); +fs.mkdirSync(path.dirname(output), { recursive: true }); +fs.writeFileSync(output, `${JSON.stringify(finalReport, null, 2)}\n`); +console.log(`Public release dry-run final evidence passed: ${output}`); diff --git a/scripts/public-release-dryrun-preflight.js b/scripts/public-release-dryrun-preflight.js new file mode 100755 index 0000000..6787186 --- /dev/null +++ b/scripts/public-release-dryrun-preflight.js @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const releaseRoot = path.resolve( + process.env.DISASMER_PUBLIC_RELEASE_DIR || + path.join(repo, "target/public-release-dryrun") +); +const acceptanceRoot = path.join(repo, "target/acceptance"); +const manifestPath = + process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || + path.join(releaseRoot, "public-release-manifest.json"); +const reportPath = + process.env.DISASMER_PUBLIC_RELEASE_PREFLIGHT_REPORT || + path.join(acceptanceRoot, "public-release-dryrun-preflight.json"); + +function commandOutput(command, args, options = {}) { + try { + return cp + .execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + ...options, + }) + .trim(); + } catch (_) { + return null; + } +} + +function nonInteractiveEnv(extra = {}) { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false", + SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false", + GIT_SSH_COMMAND: + process.env.GIT_SSH_COMMAND || + "ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0", + ...extra, + }; +} + +function expectedSourceCommit() { + return ( + process.env.DISASMER_ACCEPTANCE_COMMIT || + commandOutput("git", ["rev-parse", "HEAD"]) || + "unknown" + ); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function sha256File(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function parseSha256Sums(file) { + const sums = new Map(); + for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + if (!line.trim()) continue; + const match = /^([0-9a-f]{64})\s+(.+)$/.exec(line.trim()); + assert(match, `malformed SHA256SUMS line: ${line}`); + sums.set(path.basename(match[2]), match[1]); + } + return sums; +} + +function remoteHead(remote) { + const output = commandOutput("git", ["ls-remote", remote, "HEAD", "refs/heads/main"], { + env: nonInteractiveEnv(), + timeout: Number(process.env.DISASMER_PUBLIC_REPO_REMOTE_TIMEOUT_MS || 30000), + }); + if (!output) return null; + const lines = output.split(/\r?\n/).filter(Boolean); + const head = lines.find((line) => line.endsWith("\tHEAD")) || lines[0]; + return head && head.split(/\s+/)[0]; +} + +function publicTreeAlreadyPushed(manifest) { + return ( + (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || + process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" + ); +} + +function publicTreePushSource(manifest) { + return manifest.public_tree_publish && manifest.public_tree_publish.pushed === true + ? "manifest" + : "external-env"; +} + +function publicRepoRemoteForManifest(manifest) { + return ( + manifest.public_repo_url || + manifest.public_repo_remote || + process.env.DISASMER_PUBLIC_REPO_REMOTE || + process.env.DISASMER_PUBLIC_REPO_URL || + null + ); +} + +function publicTreeCommitForManifest(manifest) { + return ( + (manifest.public_tree_publish && manifest.public_tree_publish.commit) || + process.env.DISASMER_PUBLIC_TREE_COMMIT || + process.env.DISASMER_PUBLIC_RELEASE_TARGET || + null + ); +} + +function envState(name) { + return process.env[name] ? "set" : "unset"; +} + +function staleEvidence(file, currentSourceCommit) { + if (!fs.existsSync(file)) { + return { file, status: "missing", source_commit: null, release_name: null }; + } + const evidence = readJson(file); + if (!evidence.source_commit) { + return { + file, + status: "unversioned", + source_commit: null, + release_name: evidence.release_name || null, + }; + } + return { + file, + status: evidence.source_commit === currentSourceCommit ? "current" : "stale", + source_commit: evidence.source_commit, + release_name: evidence.release_name || null, + }; +} + +assert(fs.existsSync(manifestPath), `missing public release manifest: ${manifestPath}`); +const manifest = readJson(manifestPath); +const currentSourceCommit = expectedSourceCommit(); +const currentTreeStatus = commandOutput("git", ["status", "--short"]) || ""; +assert.strictEqual( + currentTreeStatus, + "", + "public release preflight requires a clean source tree" +); +assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); +assert.strictEqual( + manifest.source_commit, + currentSourceCommit, + "public release manifest must be regenerated for the current acceptance commit" +); +assert.strictEqual(manifest.source_tree_clean, true, "public release prep must start clean"); +assert.strictEqual( + publicTreeAlreadyPushed(manifest), + true, + "filtered public tree must be pushed to Forgejo before release publication" +); + +const publicRepoRemote = publicRepoRemoteForManifest(manifest); +assert(publicRepoRemote, "manifest must record public repository URL or remote"); +const publicTreeCommit = publicTreeCommitForManifest(manifest); +const remoteMain = remoteHead(publicRepoRemote); +assert(remoteMain, "Forgejo public repository main branch must be readable"); +if (publicTreeCommit) { + assert.strictEqual( + remoteMain, + publicTreeCommit, + "Forgejo public repository main branch must match the prepared public tree commit" + ); +} + +assert(Array.isArray(manifest.assets) && manifest.assets.length > 0, "manifest assets missing"); +const checksumAsset = manifest.assets.find((asset) => asset.name === "SHA256SUMS"); +assert(checksumAsset, "manifest must include SHA256SUMS"); +assert(fs.existsSync(checksumAsset.file), `missing checksum asset: ${checksumAsset.file}`); +const checksums = parseSha256Sums(checksumAsset.file); +const assets = manifest.assets.map((asset) => { + assert(fs.existsSync(asset.file), `missing release asset: ${asset.file}`); + const actual = sha256File(asset.file); + const expected = checksums.get(asset.name); + if (asset.name !== "SHA256SUMS") { + assert.strictEqual(actual, expected, `checksum mismatch for ${asset.name}`); + } + return { + name: asset.name, + file: asset.file, + bytes: fs.statSync(asset.file).size, + sha256: actual, + }; +}); + +const evidence = [ + staleEvidence( + path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"), + currentSourceCommit + ), + staleEvidence( + path.join(acceptanceRoot, "public-release-dryrun-service.json"), + currentSourceCommit + ), + staleEvidence( + path.join(acceptanceRoot, "hosted-client-compat.json"), + currentSourceCommit + ), + staleEvidence( + path.join(acceptanceRoot, "core-coordinator-compat.json"), + currentSourceCommit + ), + staleEvidence( + path.join(acceptanceRoot, "public-release-dryrun-e2e.json"), + currentSourceCommit + ), + staleEvidence( + path.join(acceptanceRoot, "public-release-dryrun-final.json"), + currentSourceCommit + ), +]; + +const report = { + kind: "disasmer-public-release-dryrun-preflight", + source_commit: currentSourceCommit, + release_name: manifest.release_name, + public_tree_commit: publicTreeCommit || remoteMain, + public_tree_push_source: publicTreePushSource(manifest), + public_repo_url: publicRepoRemote, + public_repo_remote_head: remoteMain, + source_tree_clean: currentTreeStatus === "", + local_assets_ready: true, + assets, + evidence, + external_gates: { + forgejo_release_publication: { + status: envState("DISASMER_FORGEJO_TOKEN") === "set" ? "ready" : "pending", + env: { + DISASMER_FORGEJO_TOKEN: envState("DISASMER_FORGEJO_TOKEN"), + }, + }, + live_service_smoke: { + status: + envState("DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR") === "set" && + envState("DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" + ? "ready" + : "pending", + env: { + DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState( + "DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR" + ), + DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( + "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" + ), + }, + }, + public_release_e2e: { + status: + envState("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E") === "set" && + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1" && + envState("DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND") === "set" + ? "ready" + : "pending", + env: { + DISASMER_PUBLIC_RELEASE_DRYRUN_E2E: + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E || "unset", + DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND: envState( + "DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND" + ), + }, + }, + final_evidence: { + status: + envState("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL") === "set" && + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL === "1" + ? "ready" + : "pending", + env: { + DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL: + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL || "unset", + }, + }, + }, +}; + +fs.mkdirSync(path.dirname(reportPath), { recursive: true }); +fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify({ report: reportPath, release_name: report.release_name }, null, 2)); diff --git a/scripts/public-story-contract-smoke.js b/scripts/public-story-contract-smoke.js new file mode 100644 index 0000000..bbab2bb --- /dev/null +++ b/scripts/public-story-contract-smoke.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing public-story evidence: ${name}`); +} + +const readme = read("README.md"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js"); +const dapSmoke = read("scripts/dap-smoke.js"); +const wasmtimeAssignmentSmoke = read("scripts/wasmtime-assignment-smoke.js"); +const debuggerEvidence = `${dapSmoke}\n${wasmtimeAssignmentSmoke}`; +const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js"); +const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); +const artifactExportSmoke = read("scripts/artifact-export-smoke.js"); + +for (const [scriptName, script] of [ + ["public acceptance", publicAcceptance], + ["public split", publicSplit], +]) { + assert( + script.includes("node scripts/public-story-contract-smoke.js"), + `${scriptName} must run public-story-contract-smoke.js` + ); +} + +for (const [name, pattern] of [ + [ + "README states public story", + /one virtual process, many virtual threads\/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling/, + ], + [ + "README states local-first bytes policy", + /local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes/, + ], + [ + "README states normal debugger controls", + /ordinary debugger\s+controls for breakpoints, continue, pause, and restart/, + ], +]) { + expect(readme, name, pattern); +} + +for (const [name, pattern] of [ + ["CLI local run starts a node process", /cli_process_started_node_process/], + ["CLI local run checks node is separate from CLI", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, cliPid\)/], + ["CLI local run checks node is separate from coordinator", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, coordinator\.pid\)/], + ["CLI local run records the real virtual task tree", /events\.events\.length >= 4[\s\S]*prepare_source[\s\S]*compile_linux[\s\S]*package_release/], + ["CLI local run records real artifact metadata", /events\.events\.some\(\(event\) => event\.artifact_path\)/], + ["CLI local-only run starts coordinator", /cli_process_started_coordinator_process[\s\S]*true/], + ["CLI local-only run hides coordinator address requirement", /\["run"[\s\S]*"--local"[\s\S]*"--project"[\s\S]*project/], +]) { + expect(cliLocalRunSmoke, name, pattern); +} + +for (const [name, pattern] of [ + ["DAP smoke uses local-services runtime", /runtimeBackend: "local-services"/], + ["DAP smoke exposes dynamic entry and child virtual threads", /mainThread[\s\S]*build virtual process[\s\S]*childThread[\s\S]*notStrictEqual\(childThread\.id, failThread\.id\)/], + ["DAP smoke binds the real entrypoint breakpoint", /stack\[0\]\.line, buildMainLine[\s\S]*stack\[0\]\.name, \/build_main::wasm\//], + ["DAP smoke binds the real child task breakpoint", /childStack\[0\]\.line, taskTrapLine[\s\S]*childStack\[0\]\.name, \/task_trap::wasm\//], + ["DAP smoke all-stops on breakpoint", /assert\.strictEqual\(stopped\.body\.allThreadsStopped, true\)/], + ["runtime smoke proves acknowledged pause all-stop", /fully_frozen[\s\S]*resume_debug_epoch[\s\S]*fully_resumed/], + ["DAP smoke refuses active-task restart without a clean boundary", /restartFailure[\s\S]*checkpoint boundary\|still active/], + ["DAP smoke supports rebuilt terminal main restart", /restartFrame[\s\S]*Restarted main from the rebuilt bundle/], + ["DAP smoke avoids native child debugger claims", /doesNotMatch\(stack\[0\]\.name, \/podman\|cmd\\\.exe\|powershell\|pid\|native child\/i\)/], + ["DAP smoke crosses coordinator-node boundary without fabricating terminal state", /runtime_backend[\s\S]*LocalServices[\s\S]*coordinator_task_events[\s\S]*0/], +]) { + expect(debuggerEvidence, name, pattern); +} + +for (const [name, pattern] of [ + ["flagship source does not need coordinator checkout", /coordinator_requires_checkout_access[\s\S]*false/], + ["flagship source bytes remain node-local", /local_source_bytes_remain_node_local[\s\S]*true/], + ["flagship avoids default source upload", /coordinator_receives_source_bytes_by_default[\s\S]*false/], + ["flagship avoids default repo tarball", /default_full_repo_tarball[\s\S]*false/], +]) { + expect(flagshipDemoSmoke, name, pattern); +} + +for (const [name, source, pattern] of [ + ["artifact download creates scoped link", artifactDownloadSmoke, /create_artifact_download_link/], + ["artifact download records retained-node source", artifactDownloadSmoke, /assert\.deepStrictEqual\(link\.link\.source, \{ RetainedNode: "node-download" \}\)/], + ["artifact download opens scoped stream", artifactDownloadSmoke, /open_artifact_download_stream/], + ["artifact export targets attached receiver node", artifactExportSmoke, /export_artifact_to_node[\s\S]*node-export-receiver/], + ["artifact export disables coordinator bulk relay", artifactExportSmoke, /coordinator_bulk_relay_allowed[\s\S]*false/], +]) { + expect(source, name, pattern); +} + +console.log("Public story contract smoke passed"); diff --git a/scripts/publish-public-release-dryrun.js b/scripts/publish-public-release-dryrun.js new file mode 100755 index 0000000..825ecdb --- /dev/null +++ b/scripts/publish-public-release-dryrun.js @@ -0,0 +1,349 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const https = require("https"); +const path = require("path"); +const cp = require("child_process"); + +const repo = path.resolve(__dirname, ".."); +const releaseRoot = path.resolve( + process.env.DISASMER_PUBLIC_RELEASE_DIR || + path.join(repo, "target/public-release-dryrun") +); +const manifestPath = path.join(releaseRoot, "public-release-manifest.json"); +const reportPath = path.join( + repo, + "target/acceptance/public-release-dryrun-forgejo-release.json" +); +const forgejoUrl = ( + process.env.DISASMER_FORGEJO_URL || "https://git.michelpaulissen.com" +).replace(/\/+$/, ""); +const token = process.env.DISASMER_FORGEJO_TOKEN; +let owner = process.env.DISASMER_PUBLIC_REPO_OWNER; +let repoName = process.env.DISASMER_PUBLIC_REPO_NAME; + +function requireEnv(name, value) { + if (!value) { + throw new Error(`${name} is required`); + } +} + +function commandOutput(command, args) { + try { + return cp + .execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + .trim(); + } catch (_) { + return null; + } +} + +function expectedSourceCommit() { + return ( + process.env.DISASMER_ACCEPTANCE_COMMIT || + commandOutput("git", ["rev-parse", "HEAD"]) || + "unknown" + ); +} + +function parseForgejoRepoIdentity(remote) { + if (!remote) { + return null; + } + + let pathname = remote; + try { + pathname = new URL(remote).pathname; + } catch (_) { + const scpLike = /^[^@/]+@[^:]+:(.+)$/.exec(remote); + if (scpLike) { + pathname = scpLike[1]; + } + } + + const parts = pathname + .replace(/^\/+/, "") + .replace(/\.git$/, "") + .split("/") + .filter(Boolean); + if (parts.length < 2) { + return null; + } + return { + owner: parts[parts.length - 2], + repoName: parts[parts.length - 1], + }; +} + +function resolveRepoIdentity(manifest) { + if (owner && repoName) { + return; + } + + const inferred = parseForgejoRepoIdentity( + manifest.public_repo_url || + manifest.public_repo_remote || + process.env.DISASMER_PUBLIC_REPO_REMOTE + ); + owner = owner || (inferred && inferred.owner); + repoName = repoName || (inferred && inferred.repoName); + if (!owner || !repoName) { + throw new Error( + "DISASMER_PUBLIC_REPO_OWNER and DISASMER_PUBLIC_REPO_NAME are required when the manifest does not contain a parseable Forgejo repository URL" + ); + } +} + +function publicTreeAlreadyPushed(manifest) { + return ( + (manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) || + process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1" + ); +} + +function publicTreeCommit(manifest) { + return ( + (manifest.public_tree_publish && manifest.public_tree_publish.commit) || + process.env.DISASMER_PUBLIC_TREE_COMMIT || + process.env.DISASMER_PUBLIC_RELEASE_TARGET || + null + ); +} + +function apiPath(pathname) { + return `/api/v1${pathname}`; +} + +function request(method, pathname, { body, headers = {} } = {}) { + const url = new URL(apiPath(pathname), forgejoUrl); + const payload = + body === undefined + ? null + : Buffer.isBuffer(body) + ? body + : Buffer.from(JSON.stringify(body)); + const requestHeaders = { + Accept: "application/json", + Authorization: `token ${token}`, + ...headers, + }; + if (payload) { + requestHeaders["Content-Length"] = payload.length; + if (!requestHeaders["Content-Type"]) { + requestHeaders["Content-Type"] = "application/json"; + } + } + + return new Promise((resolve, reject) => { + const req = https.request( + url, + { + method, + headers: requestHeaders, + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + let parsed = null; + if (text.trim()) { + try { + parsed = JSON.parse(text); + } catch (_) { + parsed = text; + } + } + if (res.statusCode < 200 || res.statusCode >= 300) { + reject( + new Error( + `${method} ${url.pathname} failed with ${res.statusCode}: ${text}` + ) + ); + return; + } + resolve({ status: res.statusCode, body: parsed }); + }); + } + ); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +function multipartFile(fieldName, file) { + const boundary = `disasmer-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const name = path.basename(file); + const header = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${fieldName}"; filename="${name}"\r\n` + + "Content-Type: application/octet-stream\r\n\r\n" + ); + const footer = Buffer.from(`\r\n--${boundary}--\r\n`); + return { + body: Buffer.concat([header, fs.readFileSync(file), footer]), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +async function existingRelease(tagName) { + const releases = await request( + "GET", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases?limit=100` + ); + return (releases.body || []).find((release) => release.tag_name === tagName) || null; +} + +async function createOrReuseRelease(manifest) { + const tagName = manifest.release_name; + const found = await existingRelease(tagName); + if (found) { + return { release: found, created: false }; + } + const targetCommitish = + publicTreeCommit(manifest) || + "main"; + const body = [ + "Disasmer public release dry run.", + "", + `Default hosted coordinator endpoint: ${manifest.default_hosted_coordinator_endpoint}`, + `DNS publication state: ${manifest.dns_publication_state}`, + `Resolver override: ${manifest.resolver_override}`, + `Public tree identity: ${manifest.public_tree_identity}`, + `Source commit: ${manifest.source_commit}`, + ].join("\n"); + const created = await request( + "POST", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases`, + { + body: { + tag_name: tagName, + target_commitish: targetCommitish, + name: tagName, + body, + draft: false, + prerelease: true, + }, + } + ); + return { release: created.body, created: true }; +} + +async function loadRelease(releaseId) { + const response = await request( + "GET", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${releaseId}` + ); + return response.body; +} + +async function uploadAsset(release, asset) { + const { body, contentType } = multipartFile("attachment", asset.file); + const response = await request( + "POST", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, + { + body, + headers: { + "Content-Type": contentType, + }, + } + ); + return response.body; +} + +function existingAssetByName(release, name) { + return Array.isArray(release.assets) + ? release.assets.find((asset) => asset.name === name) || null + : null; +} + +async function main() { + requireEnv("DISASMER_FORGEJO_TOKEN", token); + if (!fs.existsSync(manifestPath)) { + throw new Error(`missing public release manifest: ${manifestPath}`); + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + resolveRepoIdentity(manifest); + if (manifest.kind !== "disasmer-public-release-dryrun") { + throw new Error(`unexpected public release manifest kind: ${manifest.kind}`); + } + if (manifest.source_commit !== expectedSourceCommit()) { + throw new Error( + "public release manifest is stale; regenerate it for the current acceptance commit before publishing the Forgejo Release" + ); + } + if ( + !publicTreeAlreadyPushed(manifest) + ) { + throw new Error( + "public tree must be pushed before publishing the Forgejo Release; run prepare-public-release-dryrun.js with DISASMER_PUBLISH_PUBLIC_TREE=1" + ); + } + if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) { + throw new Error("public release manifest has no assets"); + } + + const { release: releaseResult, created } = await createOrReuseRelease(manifest); + const release = await loadRelease(releaseResult.id); + const uploaded = []; + const reused = []; + for (const asset of manifest.assets) { + if (!fs.existsSync(asset.file)) { + throw new Error(`missing release asset: ${asset.file}`); + } + const existing = existingAssetByName(release, asset.name); + if (existing) { + reused.push(existing); + continue; + } + uploaded.push(await uploadAsset(release, asset)); + } + + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + const report = { + kind: "disasmer-public-release-dryrun-forgejo-release", + forgejo_url: forgejoUrl, + owner, + repo: repoName, + release_id: release.id, + release_name: release.name || manifest.release_name, + tag_name: release.tag_name || manifest.release_name, + release_created: created, + default_hosted_coordinator_endpoint: manifest.default_hosted_coordinator_endpoint, + public_tree_identity: manifest.public_tree_identity, + public_tree_commit: publicTreeCommit(manifest), + source_commit: manifest.source_commit, + uploaded_assets: uploaded.map((asset) => ({ + id: asset.id, + name: asset.name, + size: asset.size, + browser_download_url: asset.browser_download_url, + })), + reused_assets: reused.map((asset) => ({ + id: asset.id, + name: asset.name, + size: asset.size, + browser_download_url: asset.browser_download_url, + })), + }; + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log( + JSON.stringify( + { report: reportPath, uploaded: uploaded.length, reused: reused.length }, + null, + 2 + ) + ); +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/quic-smoke.js b/scripts/quic-smoke.js new file mode 100755 index 0000000..b61f16b --- /dev/null +++ b/scripts/quic-smoke.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); + +const repo = path.resolve(__dirname, ".."); + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function rendezvousRequest(overrides = {}) { + return { + type: "request_rendezvous", + scope: { + tenant: "tenant", + project: "project", + process: "vp-quic", + object: { Artifact: "quic-artifact" }, + authorization_subject: "node-a-to-node-b", + }, + source: { + node: "node-a", + advertised_addr: "node-a.mesh.invalid:4433", + public_key_fingerprint: "sha256:node-a-public-key", + }, + destination: { + node: "node-b", + advertised_addr: "node-b.mesh.invalid:4433", + public_key_fingerprint: "sha256:node-b-public-key", + }, + direct_connectivity: true, + failure_reason: "", + ...overrides, + }; +} + +(async () => { + const output = cp.execFileSync( + "cargo", + ["run", "-q", "-p", "disasmer-node", "--bin", "disasmer-quic-smoke"], + { cwd: repo, encoding: "utf8" } + ); + const report = JSON.parse(output.trim().split("\n").at(-1)); + + assert.strictEqual(report.kind, "disasmer_quic_smoke"); + assert.strictEqual(report.transport, "NativeQuic"); + assert.strictEqual(report.rust_native_quic, true); + assert.strictEqual(report.authenticated_direct_connection, true); + assert.strictEqual(report.coordinator_assisted_rendezvous, true); + assert.strictEqual(report.coordinator_bulk_relay_allowed, false); + assert.strictEqual(report.source_node, "node-a"); + assert.strictEqual(report.destination_node, "node-b"); + assert.strictEqual(report.scope.tenant, "tenant"); + assert.strictEqual(report.scope.project, "project"); + assert.strictEqual(report.scope.process, "vp-quic"); + assert.deepStrictEqual(report.scope.object, { Artifact: "quic-artifact" }); + assert.ok(report.authorization_digest.startsWith("sha256:")); + assert.ok(report.request_bytes > 0); + assert.strictEqual(report.server_received_request_bytes, report.request_bytes); + assert.ok(report.payload_bytes > 0); + + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback", + ], + { cwd: repo } + ); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + + const plan = await send(addr, rendezvousRequest()); + assert.strictEqual(plan.type, "rendezvous_plan"); + assert.strictEqual(plan.charged_rendezvous_attempts, 1); + assert.strictEqual(plan.plan.transport, "NativeQuic"); + assert.strictEqual(plan.plan.scope.tenant, "tenant"); + assert.strictEqual(plan.plan.scope.project, "project"); + assert.strictEqual(plan.plan.source.node, "node-a"); + assert.strictEqual(plan.plan.destination.node, "node-b"); + assert.strictEqual(plan.plan.coordinator_assisted_rendezvous, true); + assert.strictEqual(plan.plan.coordinator_bulk_relay_allowed, false); + assert.ok(plan.plan.authorization_digest.startsWith("sha256:")); + + const failed = await send( + addr, + rendezvousRequest({ + direct_connectivity: false, + failure_reason: "nat traversal failed", + }) + ); + assert.strictEqual(failed.type, "error"); + assert.match(failed.message, /nat traversal failed/); + assert.match(failed.message, /coordinator bulk relay is disabled/); + } finally { + coordinator.kill("SIGTERM"); + } + + console.log("QUIC smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/real-flagship-harness.js b/scripts/real-flagship-harness.js new file mode 100644 index 0000000..a315b3f --- /dev/null +++ b/scripts/real-flagship-harness.js @@ -0,0 +1,230 @@ +const assert = require("assert"); +const cp = require("child_process"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { configurePodmanTestEnvironment } = require("./podman-test-env"); + +const repo = path.resolve(__dirname, ".."); +const project = path.join(repo, "examples/launch-build-demo"); + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + if (stderr.length > 4096) stderr = stderr.slice(-4096); + }); + child.once("exit", (code) => { + const detail = stderr.trim(); + reject(new Error( + `process exited before JSON line with code ${code}${detail ? `: ${detail}` : ""}` + )); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function configureContainerPolicyHome() { + configurePodmanTestEnvironment(repo); +} + +function commandWithPodman(program, args) { + if (cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status === 0) { + return { program, args }; + } + if (cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0) { + return { + program: "nix", + args: ["shell", "nixpkgs#podman", "--command", program, ...args], + }; + } + throw new Error( + "real flagship smoke requires rootless Podman (or Nix to provide it)" + ); +} + +function ensureRootlessPodman() { + configureContainerPolicyHome(); + const invocation = commandWithPodman("podman", [ + "info", + "--format", + "{{.Host.Security.Rootless}}", + ]); + const rootless = cp.execFileSync(invocation.program, invocation.args, { + cwd: repo, + env: process.env, + encoding: "utf8", + }).trim(); + assert.strictEqual(rootless, "true", "flagship worker must use rootless Podman"); +} + +async function runFlagshipWorker(addr, node, identity) { + const enrollment = await send(addr, { + type: "create_node_enrollment_grant", + tenant: "tenant", + project: "project", + actor_user: "user", + ttl_seconds: 60, + }); + assert.strictEqual(enrollment.type, "node_enrollment_grant_created"); + const cargoArgs = [ + "run", "-q", "-p", "disasmer-node", "--bin", "disasmer-node", "--", + "--coordinator", `${addr.host}:${addr.port}`, + "--tenant", "tenant", + "--project-id", "project", + "--node", node, + "--enrollment-grant", enrollment.grant, + "--worker", + "--emit-ready", + "--project-root", project, + "--assignment-poll-ms", "25", + ]; + const invocation = commandWithPodman("cargo", cargoArgs); + const child = cp.spawn(invocation.program, invocation.args, { + cwd: repo, + env: { + ...process.env, + DISASMER_NODE_PRIVATE_KEY: identity.privateKey, + }, + }); + return { child, ready: waitForJsonLine(child) }; +} + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function waitForTaskEvent(addr, process, predicate, description) { + for (let attempt = 0; attempt < 2400; attempt += 1) { + const response = await send(addr, { + type: "list_task_events", + tenant: "tenant", + project: "project", + actor_user: "user", + process, + }); + assert.strictEqual(response.type, "task_events", JSON.stringify(response)); + const event = response.events.find(predicate); + if (event) return event; + await delay(25); + } + throw new Error(`timed out waiting for real Wasm task ${description}`); +} + +function startFlagship(addr) { + const report = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", + "run", "build", + "--coordinator", `disasmer+tcp://${addr.host}:${addr.port}`, + "--project", project, + "--json", + ], + { cwd: repo, env: process.env, encoding: "utf8" } + ) + ); + assert.strictEqual(report.command, "run"); + assert.strictEqual(report.status, "main_launched", JSON.stringify(report)); + assert.strictEqual(report.entry, "build"); + assert.strictEqual(report.task_launch.type, "main_launched"); + assert.strictEqual(report.task_launch.task_instance, report.task_instance); + assert.strictEqual(report.task_launch.task_definition, report.task_definition); + assert.strictEqual(report.worker_placement_requested, true); + assert.match(report.bundle_digest, /^sha256:[0-9a-f]{64}$/); + assert.match(report.entry_export, /^disasmer_entry_v1_/); + const virtualProcess = report.process; + return { report, process: virtualProcess }; +} + +async function launchFlagship(addr) { + const { report, process: virtualProcess } = startFlagship(addr); + const compileEvent = await waitForTaskEvent( + addr, + virtualProcess, + (event) => event.task_definition === "compile_linux", + "compile_linux" + ); + const packageEvent = await waitForTaskEvent( + addr, + virtualProcess, + (event) => event.task_definition === "package_release", + "package_release" + ); + const buildEvent = await waitForTaskEvent( + addr, + virtualProcess, + (event) => event.task === report.task_instance && event.executor === "coordinator_main", + "coordinator build main" + ); + for (const event of [compileEvent, packageEvent, buildEvent]) { + assert.strictEqual(event.terminal_state, "completed", JSON.stringify(event)); + } + return { + report, + process: virtualProcess, + compileEvent, + packageEvent, + buildEvent, + }; +} + +function flagshipNodeCapabilities() { + return { + os: "Linux", + arch: process.arch, + capabilities: [ + "Command", + "Containers", + "RootlessPodman", + "SourceFilesystem", + "VfsArtifacts", + ], + environment_backends: ["Container"], + source_providers: ["filesystem"], + }; +} + +module.exports = { + ensureRootlessPodman, + flagshipNodeCapabilities, + launchFlagship, + project, + repo, + runFlagshipWorker, + send, + startFlagship, + waitForTaskEvent, + waitForJsonLine, +}; diff --git a/scripts/release-blocker-smoke.js b/scripts/release-blocker-smoke.js new file mode 100755 index 0000000..0509db2 --- /dev/null +++ b/scripts/release-blocker-smoke.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function maybeRead(relativePath) { + const fullPath = path.join(repo, relativePath); + if (!fs.existsSync(fullPath)) return null; + return fs.readFileSync(fullPath, "utf8"); +} + +function section(source, heading) { + const marker = `## ${heading}`; + const start = source.indexOf(marker); + assert(start >= 0, `missing section ${marker}`); + const next = source.indexOf("\n## ", start + marker.length); + return source.slice(start, next >= 0 ? next : source.length); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing release-blocker evidence: ${name}`); +} + +const hiddenDemoBlockerPattern = new RegExp( + [ + "flagship demo requires", + ["undocumented", "manual state"].join(" "), + ["hard-coded", "local paths"].join(" "), + ["demo-only", "credentials"].join(" "), + ["hidden", "setup"].join(" "), + ].join("[\\s\\S]*") +); +const hiddenDemoScanPattern = new RegExp( + `demo_setup_pattern='${[ + ["undocumented", "manual state"].join(" "), + ["hidden", "setup"].join(" "), + ["demo-only", "credentials?"].join(" "), + ["hard-coded", "local paths?"].join(" "), + ] + .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("\\\\|")}'` +); + +const phase2 = read("acceptance_criteria_phase2.md"); +const base = read("acceptance_criteria.md"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); +const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); +const schedulerSmoke = read("scripts/scheduler-placement-smoke.js"); +const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js"); +const hostedClientCompatSmoke = maybeRead("private/hosted-policy/scripts/hosted-client-compat-smoke.js"); +const releaseSourceScan = read("scripts/release-source-scan.sh"); +const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js"); + +const releaseBlockers = section(phase2, "20. Release blockers"); +expect( + releaseBlockers, + "cross-tenant access is listed as a release blocker", + /Cross-tenant access succeeds for projects, nodes, processes, logs, artifacts, downloads, debug state, panels, capabilities, source manifests, credentials, or metadata/ +); +expect( + releaseBlockers, + "manual-state flagship blocker is listed", + hiddenDemoBlockerPattern +); + +expect( + section(base, "21. Authorization and tenant isolation"), + "base criteria require tenant isolation failures to block release", + /Tenant isolation failures are treated as release blockers/ +); + +for (const [scriptName, script] of [ + ["public acceptance", publicAcceptance], + ["public split", publicSplit], +]) { + for (const smoke of [ + "scripts/artifact-download-smoke.js", + "scripts/operator-panel-smoke.js", + "scripts/source-preparation-smoke.js", + "scripts/scheduler-placement-smoke.js", + "scripts/flagship-demo-smoke.js", + ]) { + assert( + script.includes(`node ${smoke}`), + `${scriptName} must run ${smoke} as part of tenant-isolation release blocking` + ); + } + assert( + script.includes("scripts/release-source-scan.sh"), + `${scriptName} must run release-source-scan.sh as part of release blocking` + ); +} + +assert( + privateAcceptance.includes("node private/hosted-policy/scripts/hosted-client-compat-smoke.js"), + "private acceptance must run hosted Client cross-tenant checks" +); +assert( + privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"), + "private acceptance must run hosted deployment checks" +); + +const boundaryEvidence = [ + [ + "artifact download", + artifactDownloadSmoke, + [/const crossTenant = await send/, /const crossTenantOpen = await send/, /tenant mismatch/], + ], + [ + "operator panel", + operatorPanelSmoke, + [/const crossTenant = await send/, /render_operator_panel/, /scope\|tenant\|project/], + ], + [ + "source preparation", + sourcePreparationSmoke, + [/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i], + ], + [ + "scheduler/node capability", + schedulerSmoke, + [/const crossTenantReport = await send/, /report_node_capabilities/, /tenant\\\/project scope/], + ], +]; + +if (hostedClientCompatSmoke) { + boundaryEvidence.push([ + "hosted Client compatibility", + hostedClientCompatSmoke, + [ + /const forged = await sendHostedControl/, + /const crossTenantTaskEventsDenied = await sendHostedControl/, + /scope\|denied\|unauthorized/, + ], + ]); +} + +for (const [name, source, patterns] of boundaryEvidence) { + for (const pattern of patterns) { + expect(source, name, pattern); + } +} + +for (const [name, pattern] of [ + ["release source scan rejects manual demo state", hiddenDemoScanPattern], + ["release source scan rejects hidden local paths", /hidden_local_pattern='file:\/\/\|\/home\/\[.*\]_.-\]\+\/\|\/Users\/\[.*\]_.-\]\+\/\|C:\\\\Users\\\\\|https\?:\/\/\(localhost\|127\\\.0\\\.0\\\.1\)/], +]) { + expect(releaseSourceScan, name, pattern); +} + +for (const [name, pattern] of [ + ["public split excludes private modules", /--exclude='\.\/private'/], + ["public split excludes experiments", /--exclude='\.\/experiments'/], + ["public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/], + ["public split builds copied workspace bins", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/], + ["public split installs CLI from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-install-smoke\.js\)/], + ["public split installs VS Code extension from copied tree", /\(cd "\$tmp_dir" && node scripts\/vscode-extension-smoke\.js\)/], + ["public split attaches node from copied tree", /\(cd "\$tmp_dir" && node scripts\/node-attach-smoke\.js\)/], + ["public split runs real Wasm assignments from copied tree", /\(cd "\$tmp_dir" && node scripts\/wasmtime-assignment-smoke\.js\)/], + ["public split runs CLI local workflow from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-local-run-smoke\.js\)/], + ["public split runs artifact download from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-download-smoke\.js\)/], + ["public split runs artifact export from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-export-smoke\.js\)/], + ["public split runs DAP smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/dap-smoke\.js\)/], + ["public split runs flagship demo smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/flagship-demo-smoke\.js\)/], +]) { + expect(publicSplit, name, pattern); +} + +for (const [name, pattern] of [ + ["flagship demo rejects local machine assumptions", /forbiddenSourceAssumptions/], + ["flagship demo rejects coordinator checkout access", /coordinator_requires_checkout_access[\s\S]*false/], + ["flagship demo asserts local source bytes stay node-local", /local_source_bytes_remain_node_local[\s\S]*true/], + ["flagship demo asserts coordinator receives no source bytes by default", /coordinator_receives_source_bytes_by_default[\s\S]*false/], + ["flagship demo asserts no default full repo tarball", /default_full_repo_tarball[\s\S]*false/], +]) { + expect(flagshipDemoSmoke, name, pattern); +} + +console.log("Release blocker smoke passed"); diff --git a/scripts/release-source-scan.sh b/scripts/release-source-scan.sh new file mode 100755 index 0000000..4f2db0c --- /dev/null +++ b/scripts/release-source-scan.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo" + +release_paths=( + Cargo.toml + Cargo.lock + README.md + crates + examples + private + scripts + vscode-extension +) + +existing_release_paths=() +for path in "${release_paths[@]}"; do + if [[ -e "$path" ]]; then + existing_release_paths+=("$path") + fi +done + +prose_scan_paths=( + README.md + crates + examples + private + scripts + vscode-extension +) + +existing_prose_scan_paths=() +for path in "${prose_scan_paths[@]}"; do + if [[ -e "$path" ]]; then + existing_prose_scan_paths+=("$path") + fi +done + +scan_globs=( + --glob '!**/target/**' + --glob '!**/node_modules/**' + --glob '!scripts/release-source-scan.sh' +) + +placeholder_pattern='debugger-gate|experiments/debugger-gate|DISASMER-DEMO|device-code-placeholder|artifact://demo|vp-local-demo' +if rg -n "${scan_globs[@]}" "$placeholder_pattern" "${existing_release_paths[@]}"; then + echo "release source scan failed: stale experiment/demo placeholder reference found" >&2 + exit 1 +fi + +demo_setup_pattern='undocumented manual state|hidden setup|demo-only credentials?|hard-coded local paths?' +if rg -n "${scan_globs[@]}" "$demo_setup_pattern" "${existing_prose_scan_paths[@]}"; then + echo "release source scan failed: demo requires hidden setup or demo-only state" >&2 + exit 1 +fi + +hidden_local_pattern='file://|/home/[[:alnum:]_.-]+/|/Users/[[:alnum:]_.-]+/|C:\\Users\\|https?://(localhost|127\.0\.0\.1)[^[:space:]]*/artifacts/' +if rg -n "${scan_globs[@]}" "$hidden_local_pattern" "${existing_release_paths[@]}"; then + echo "release source scan failed: hidden local path or local artifact URL found" >&2 + exit 1 +fi + +public_wording_pattern='reddit|hacker news|lobsters|launch forum|traffic source|free tier' +if rg -n "${scan_globs[@]}" "$public_wording_pattern" "${existing_prose_scan_paths[@]}"; then + echo "release source scan failed: public-facing launch-forum/free-tier wording found" >&2 + exit 1 +fi + +echo "Release source scan passed" diff --git a/scripts/resource-metering-contract-smoke.js b/scripts/resource-metering-contract-smoke.js new file mode 100644 index 0000000..63c01c7 --- /dev/null +++ b/scripts/resource-metering-contract-smoke.js @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function maybeRead(segments) { + const fullPath = path.join(repo, ...segments); + if (!fs.existsSync(fullPath)) return null; + return fs.readFileSync(fullPath, "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing resource metering evidence: ${name}`); +} + +function expectGate(script, gateName) { + assert( + script.includes("node scripts/resource-metering-contract-smoke.js"), + `${gateName} must run resource-metering-contract-smoke.js` + ); +} + +const coreLimits = read("crates/disasmer-core/src/limits.rs"); +// Phase 3 keeps the protocol dispatch in service.rs and the metered operation +// implementations in focused service modules. Read the complete relevant +// boundary so this contract follows the refactor instead of one mega-file. +const coordinatorService = [ + read("crates/disasmer-coordinator/src/service.rs"), + read("crates/disasmer-coordinator/src/service/routing.rs"), + read("crates/disasmer-coordinator/src/service/processes.rs"), + read("crates/disasmer-coordinator/src/service/process_launch.rs"), + read("crates/disasmer-coordinator/src/service/artifacts.rs"), +].join("\n"); +const coordinatorQuota = read("crates/disasmer-coordinator/src/service/quota.rs"); +const coordinatorLogs = read("crates/disasmer-coordinator/src/service/logs.rs"); +const coordinatorDebug = read("crates/disasmer-coordinator/src/service/debug.rs"); +const coordinatorTests = read("crates/disasmer-coordinator/src/service/tests.rs"); +const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); +const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); +const quicSmoke = read("scripts/quic-smoke.js"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); + +for (const [name, pattern] of [ + ["API call limit kind", /\bApiCall,/], + ["spawn limit kind", /\bSpawn,/], + ["log bytes limit kind", /\bLogBytes,/], + ["metadata bytes limit kind", /\bMetadataBytes,/], + ["debug read bytes limit kind", /\bDebugReadBytes,/], + ["UI event limit kind", /\bUiEvent,/], + ["rendezvous attempt limit kind", /\bRendezvousAttempt,/], + ["artifact download bytes limit kind", /\bArtifactDownloadBytes,/], + ["hosted fuel limit kind", /\bHostedFuel,/], + [ + "preflight can check without consuming", + /pub fn can_charge\([\s\S]*used\.saturating_add\(amount\) > limit/, + ], + [ + "charge goes through preflight", + /pub fn charge\([\s\S]*self\.can_charge\(limits, kind, amount\)\?/, + ], +]) { + expect(coreLimits, name, pattern); +} + +for (const [name, pattern] of [ + [ + "rendezvous charges before transport planning", + /handle_request_rendezvous[\s\S]*charge_rendezvous_attempt\([\s\S]*&scope\.tenant[\s\S]*&scope\.project[\s\S]*now_epoch_seconds[\s\S]*plan_authenticated_direct_bulk_transfer/, + ], + [ + "artifact link creation preflights downloadable bytes before link creation", + /handle_create_artifact_download_link[\s\S]*downloadable_size[\s\S]*can_charge_download\([\s\S]*&context\.tenant[\s\S]*&context\.project[\s\S]*downloadable_size[\s\S]*create_download_link/, + ], + [ + "artifact delivery charges scoped bytes before advancing its offset", + /handle_open_artifact_download_stream[\s\S]*stream_download_chunk\([\s\S]*charge_download\([\s\S]*&context\.tenant[\s\S]*&context\.project[\s\S]*streamed_bytes[\s\S]*delivered_offset = end/, + ], +]) { + expect(coordinatorService, name, pattern); +} + +for (const [name, pattern] of [ + [ + "quota keys include tenant/project resource kind and window", + /struct ProjectQuotaScope[\s\S]*tenant: TenantId[\s\S]*project: ProjectId[\s\S]*struct MeterKey[\s\S]*kind: LimitKind[\s\S]*window: u64/, + ], + [ + "quota module discards expired windows for an accessed scope and kind", + /fn meter_mut[\s\S]*self\.meters\.retain[\s\S]*existing\.scope != key\.scope[\s\S]*existing\.kind != kind[\s\S]*existing\.window == key\.window/, + ], + [ + "quota module charges rendezvous attempts through the scoped window meter", + /fn charge_rendezvous_attempt[\s\S]*self\.charge\([\s\S]*tenant[\s\S]*project[\s\S]*LimitKind::RendezvousAttempt/, + ], + [ + "quota module charges authenticated API calls through the scoped window meter", + /fn charge_api_call[\s\S]*self\.charge\(tenant, project, LimitKind::ApiCall, 1, now_epoch_seconds\)/, + ], + [ + "quota module preflights and charges log bytes through the scoped window meter", + /fn can_charge_log_bytes[\s\S]*LimitKind::LogBytes[\s\S]*fn charge_log_bytes[\s\S]*LimitKind::LogBytes/, + ], + [ + "quota module preflights artifact download bytes through the scoped meter", + /fn can_charge_download[\s\S]*self\.can_charge\([\s\S]*tenant[\s\S]*project[\s\S]*LimitKind::ArtifactDownloadBytes/, + ], + [ + "quota status reports current scoped window usage", + /fn project_status[\s\S]*for kind in LimitKind::ALL[\s\S]*self\.used\(tenant, project, kind, now_epoch_seconds\)/, + ], +]) { + expect(coordinatorQuota, name, pattern); +} + +for (const [source, name, pattern] of [ + [ + coordinatorService, + "authenticated API calls are charged after session authorization and before dispatch", + /authenticate_cli_session[\s\S]*authorize_authenticated_user_operation[\s\S]*charge_api_call[\s\S]*match request/, + ], + [ + coordinatorLogs, + "signed node log ingestion preflights and charges bytes before accepting the report", + /handle_report_task_log[\s\S]*authorize_node_for_process_or_termination[\s\S]*can_charge_log_bytes[\s\S]*charge_log_bytes[\s\S]*TaskLogRecorded/, + ], + [ + coordinatorDebug, + "debug reads charge the scoped debug-read budget before audit state is recorded", + /record_debug_audit_event[\s\S]*charge_debug_read[\s\S]*DebugAuditEvent/, + ], + [ + coordinatorTests, + "tests prove API-call and log-byte quota enforcement and project isolation", + /authenticated_api_calls_are_metered_per_tenant_and_project_before_dispatch[\s\S]*project-a[\s\S]*project-b[\s\S]*signed_node_log_ingestion_checks_scoped_quota_before_accepting_bytes[\s\S]*LogBytes/, + ], +]) { + expect(source, name, pattern); +} + +for (const [name, source, patterns] of [ + [ + "rendezvous smoke", + quicSmoke, + [/charged_rendezvous_attempts, 1/, /coordinator bulk relay is disabled/], + ], + [ + "artifact download smoke", + artifactDownloadSmoke, + [ + /downloaded\.response\.charged_download_bytes,[\s\S]*packageEvent\.artifact_size_bytes/, + /retaining_node_reverse_stream/, + /revoked/, + ], + ], + [ + "operator panel smoke", + operatorPanelSmoke, + [ + /type: "submit_panel_event"/, + /max_events: 1/, + /used_events, 1/, + /rate limit/i, + /max_download_bytes: 1/, + /exceeds download limit/, + ], + ], +]) { + for (const pattern of patterns) { + expect(source, name, pattern); + } +} + +expectGate(publicAcceptance, "public acceptance"); +expectGate(publicSplit, "public split acceptance"); +expectGate(privateAcceptance, "private acceptance"); + +const privateHostedLibSource = maybeRead(["private", "hosted-policy", "src", "lib.rs"]); +const privateHostedTests = maybeRead(["private", "hosted-policy", "src", "tests.rs"]); +const privateHostedLib = privateHostedLibSource + ? [privateHostedLibSource, privateHostedTests].filter(Boolean).join("\n") + : null; +if (privateHostedLib) { + for (const [name, pattern] of [ + [ + "private hosted configuration owns exact limits and quota windows", + /community_tier_resource_limits[\s\S]*LimitKind::ApiCall[\s\S]*LimitKind::HostedFuel[\s\S]*community_tier_quota_configuration[\s\S]*CoordinatorQuotaConfiguration::new/, + ], + [ + "hosted zero-capability Wasm budget preflight is atomic across every resource kind", + /preflight_zero_capability_hosted_wasm\([\s\S]*let mut trial_meter = meter\.clone\(\)[\s\S]*LimitKind::HostedFuel[\s\S]*LimitKind::HostedMemoryBytes[\s\S]*LimitKind::HostedWallClockMs[\s\S]*LimitKind::HostedStateBytes[\s\S]*LimitKind::LogBytes[\s\S]*LimitKind::MetadataBytes[\s\S]*LimitKind::UiEvent[\s\S]*LimitKind::ApiCall[\s\S]*\*meter = trial_meter/, + ], + [ + "hosted tests cover each zero-capability Wasm budget", + /hosted_zero_capability_wasm_preflight_rejects_each_budget_over_limit\([\s\S]*LimitKind::HostedFuel[\s\S]*LimitKind::LogBytes[\s\S]*LimitKind::MetadataBytes[\s\S]*LimitKind::UiEvent[\s\S]*LimitKind::ApiCall/, + ], + [ + "hosted tests prove tenant/project quota isolation", + /hosted_resource_usage_is_isolated_by_tenant_and_project\([\s\S]*project-a[\s\S]*project-b[\s\S]*HostedFuel/, + ], + ]) { + expect(privateHostedLib, name, pattern); + } +} + +console.log("Resource metering contract smoke passed"); diff --git a/scripts/scheduler-placement-smoke.js b/scripts/scheduler-placement-smoke.js new file mode 100755 index 0000000..5d36dda --- /dev/null +++ b/scripts/scheduler-placement-smoke.js @@ -0,0 +1,407 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { nodeIdentity, signedNodeRequest } = require("./node-signing"); + +const repo = path.resolve(__dirname, ".."); +const digest = (value) => + `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +const environmentDigest = digest("scheduler-linux-container"); +const dependencyDigest = digest("scheduler-toolchain-dependencies"); +const sourceDigest = digest("scheduler-source-tree"); + +function buildFlagshipBundle() { + const output = cp.execFileSync( + "cargo", + [ + "run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", + "build", "--project", "examples/launch-build-demo", "--json", + ], + { cwd: repo, encoding: "utf8" } + ); + const report = JSON.parse(output); + const directory = path.resolve(repo, report.bundle_artifact.directory); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); + const entrypoints = JSON.parse( + fs.readFileSync(path.join(directory, manifest.entrypoints), "utf8") + ); + const entrypoint = entrypoints.find((candidate) => candidate.name === "build"); + assert(entrypoint, "flagship bundle omitted build entrypoint"); + const taskDescriptors = JSON.parse( + fs.readFileSync(path.join(directory, manifest.task_descriptors), "utf8") + ); + const prepareSource = taskDescriptors.find( + (candidate) => candidate.name === "prepare_source" + ); + assert(prepareSource, "flagship bundle omitted prepare_source task"); + return { + digest: manifest.bundle_digest, + taskExport: prepareSource.export, + wasmModuleBase64: fs.readFileSync(path.join(directory, "module.wasm")).toString("base64"), + }; +} + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function linuxCapabilities() { + return { + os: "Linux", + arch: "x86_64", + capabilities: [ + "Command", + "Containers", + "RootlessPodman", + "SourceFilesystem", + "VfsArtifacts" + ], + environment_backends: ["Container"], + source_providers: ["filesystem"] + }; +} + +function gitCapabilities() { + const capabilities = linuxCapabilities(); + capabilities.capabilities = [...capabilities.capabilities, "SourceGit"].sort(); + capabilities.source_providers = [...capabilities.source_providers, "git"].sort(); + return capabilities; +} + +async function attachNode(addr, node) { + const identity = nodeIdentity("scheduler-placement-smoke", node); + const attached = await send(addr, { + type: "attach_node", + tenant: "tenant", + project: "project", + node, + public_key: identity.publicKey + }); + assert.strictEqual(attached.type, "node_attached"); + assert.strictEqual(attached.node, node); + return identity; +} + +async function reportNode(addr, node, identity, locality) { + const recorded = await send(addr, signedNodeRequest(node, identity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node, + capabilities: locality.capabilities || linuxCapabilities(), + cached_environment_digests: locality.cached_environment_digests, + dependency_cache_digests: locality.dependency_cache_digests, + source_snapshots: locality.source_snapshots, + artifact_locations: locality.artifact_locations, + direct_connectivity: locality.direct_connectivity !== false, + online: true + })); + assert.strictEqual( + recorded.type, + "node_capabilities_recorded", + JSON.stringify(recorded) + ); + assert.strictEqual(recorded.node, node); + return recorded; +} + +(async () => { + const bundle = buildFlagshipBundle(); + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback" + ], + { cwd: repo } + ); + let coordinatorStderr = ""; + coordinator.stderr.on("data", (chunk) => { + coordinatorStderr += chunk.toString(); + }); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const coldNode = await attachNode(addr, "cold-node"); + const warmNode = await attachNode(addr, "warm-node"); + + const cold = await reportNode(addr, "cold-node", coldNode, { + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: false + }); + assert.strictEqual(cold.node_descriptors, 1); + + const warm = await reportNode(addr, "warm-node", warmNode, { + cached_environment_digests: [environmentDigest], + dependency_cache_digests: [dependencyDigest], + source_snapshots: [sourceDigest], + artifact_locations: ["toolchain-cache"] + }); + assert.strictEqual(warm.node_descriptors, 2); + const reportedNodes = new Set([cold.node, warm.node]); + assert.strictEqual(reportedNodes.size, 2); + assert(reportedNodes.has("cold-node")); + assert(reportedNodes.has("warm-node")); + + const inspected = await send(addr, { + type: "list_node_descriptors", + tenant: "tenant", + project: "project", + actor_user: "operator" + }); + assert.strictEqual(inspected.type, "node_descriptors"); + assert.strictEqual(inspected.actor, "operator"); + assert.strictEqual(inspected.descriptors.length, 2); + const warmDescriptor = inspected.descriptors.find( + (descriptor) => descriptor.id === "warm-node" + ); + assert(warmDescriptor, "warm node descriptor must be visible to inspector state"); + assert(warmDescriptor.capabilities.capabilities.includes("Command")); + assert(warmDescriptor.capabilities.capabilities.includes("RootlessPodman")); + assert(warmDescriptor.cached_environments.includes(environmentDigest)); + assert(warmDescriptor.dependency_caches.includes(dependencyDigest)); + assert(warmDescriptor.source_snapshots.includes(sourceDigest)); + assert(warmDescriptor.artifact_locations.includes("toolchain-cache")); + + const crossScopeInspection = await send(addr, { + type: "list_node_descriptors", + tenant: "other-tenant", + project: "project", + actor_user: "operator" + }); + assert.strictEqual(crossScopeInspection.type, "node_descriptors"); + assert.strictEqual(crossScopeInspection.descriptors.length, 0); + + const crossTenantReport = await send(addr, signedNodeRequest("warm-node", warmNode, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "other-tenant", + project: "project", + node: "warm-node", + capabilities: linuxCapabilities(), + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: true, + online: true + })); + assert.strictEqual(crossTenantReport.type, "error"); + assert.match(crossTenantReport.message, /tenant\/project scope/); + + const placement = await send(addr, { + type: "schedule_task", + tenant: "tenant", + project: "project", + environment: { + os: "Linux", + arch: null, + capabilities: ["Containers", "RootlessPodman"] + }, + environment_digest: environmentDigest, + required_capabilities: ["Command"], + dependency_cache: dependencyDigest, + source_snapshot: sourceDigest, + required_artifacts: ["toolchain-cache"], + prefer_node: null + }); + assert.strictEqual(placement.type, "task_placement"); + assert.strictEqual(placement.placement.node, "warm-node"); + assert.ok(placement.placement.score > 0); + assert.ok(placement.placement.reasons.includes("warm environment cache")); + assert.ok(placement.placement.reasons.includes("warm dependency cache")); + assert.ok(placement.placement.reasons.includes("source snapshot already local")); + assert.ok( + placement.placement.reasons.includes("1 required artifact(s) already local") + ); + + const impossible = await send(addr, { + type: "schedule_task", + tenant: "tenant", + project: "project", + environment: null, + environment_digest: null, + required_capabilities: ["WindowsCommandDev"], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + prefer_node: null + }); + assert.strictEqual(impossible.type, "error"); + assert.match(impossible.message, /WindowsCommandDev/); + + const started = await send(addr, { + type: "start_process", + tenant: "tenant", + project: "project", + actor_user: "operator", + process: "vp-wait-for-git" + }); + assert.strictEqual(started.type, "process_started"); + + const queued = await send(addr, { + type: "launch_task", + tenant: "tenant", + project: "project", + actor_user: "operator", + task_spec: { + tenant: "tenant", + project: "project", + process: "vp-wait-for-git", + task_definition: "prepare_source", + task_instance: "prepare_source-1", + dispatch: { + kind: "coordinator_node_wasm", + export: bundle.taskExport, + abi: "task_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: ["SourceFilesystem", "SourceGit"], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [], + vfs_epoch: started.epoch, + bundle_digest: bundle.digest, + }, + wait_for_node: true, + artifact_path: "/vfs/artifacts/git-status.txt", + wasm_module_base64: bundle.wasmModuleBase64, + }); + assert.strictEqual(queued.type, "task_queued"); + assert.strictEqual(queued.process, "vp-wait-for-git"); + assert.strictEqual(queued.task, "prepare_source-1"); + assert.match(queued.reason, /SourceGit/); + assert.strictEqual(queued.queued_tasks, 1); + + const gitNode = await attachNode(addr, "git-node"); + const gitRecorded = await reportNode(addr, "git-node", gitNode, { + capabilities: gitCapabilities(), + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: false + }); + assert.strictEqual(gitRecorded.type, "node_capabilities_recorded"); + + const pendingAssignment = await send(addr, signedNodeRequest("git-node", gitNode, "poll_task_assignment", { + type: "poll_task_assignment", + tenant: "tenant", + project: "project", + node: "git-node" + })); + assert.strictEqual(pendingAssignment.type, "task_assignment"); + assert(pendingAssignment.assignment, "late capable node should receive queued assignment"); + assert.strictEqual(pendingAssignment.assignment.process, "vp-wait-for-git"); + assert.strictEqual(pendingAssignment.assignment.task, "prepare_source-1"); + assert.strictEqual(pendingAssignment.assignment.node, "git-node"); + assert.strictEqual( + pendingAssignment.assignment.task_spec.dispatch.export, + bundle.taskExport + ); + + const emptyAssignment = await send(addr, signedNodeRequest("git-node", gitNode, "poll_task_assignment", { + type: "poll_task_assignment", + tenant: "tenant", + project: "project", + node: "git-node" + })); + assert.strictEqual(emptyAssignment.type, "task_assignment"); + assert.strictEqual(emptyAssignment.assignment, null); + + await reportNode(addr, "warm-node", warmNode, { + cached_environment_digests: [], + dependency_cache_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: false + }); + const disconnectedTransfer = await send(addr, { + type: "schedule_task", + tenant: "tenant", + project: "project", + environment: null, + environment_digest: null, + required_capabilities: ["Command"], + dependency_cache: null, + source_snapshot: sourceDigest, + required_artifacts: ["toolchain-cache"], + prefer_node: null + }); + assert.strictEqual(disconnectedTransfer.type, "error"); + assert.match(disconnectedTransfer.message, /source snapshot unavailable/); + assert.match(disconnectedTransfer.message, /required artifact\(s\) unavailable/); + assert.match(disconnectedTransfer.message, /direct connectivity unavailable/); + } catch (error) { + if (coordinatorStderr) { + error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`; + } + throw error; + } finally { + coordinator.kill("SIGTERM"); + } + + console.log("Scheduler placement smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/sdk-spawn-runtime-smoke.js b/scripts/sdk-spawn-runtime-smoke.js new file mode 100755 index 0000000..5e01c30 --- /dev/null +++ b/scripts/sdk-spawn-runtime-smoke.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const sdk = fs.readFileSync(path.join(repo, "crates/disasmer-sdk/src/lib.rs"), "utf8"); +const productRuntime = fs.readFileSync( + path.join(repo, "crates/disasmer-sdk/src/sdk_runtime.rs"), + "utf8" +); + +assert.match(sdk, /pub struct RuntimeSpawnEvent/); +assert.match(sdk, /pub debugger_visible: bool/); +assert.match(sdk, /fn register_runtime_thread/); +assert.match(sdk, /register_runtime_thread\(id, self\.name, self\.env\)/); +assert.match(sdk, /pub fn debugger_visible\(&self\) -> bool/); +assert.match(sdk, /ProductRuntimeConfig::from_env\(\)/); +assert.match(sdk, /start_remote_task\(config, task_id/); +assert.match(sdk, /start_guest_host_task/); +assert.match(sdk, /join_remote_task\(remote\)/); +assert.match(productRuntime, /let spec = TaskSpec \{/); +assert.match(productRuntime, /CoordinatorNodeWasm/); +assert.match(productRuntime, /task_start_v1/); +assert.match(productRuntime, /task_join_v1/); +assert.match(productRuntime, /command_run_v1/); +assert.match(productRuntime, /"task_spec": spec/); +assert.match(productRuntime, /remote_completion_observed/); +assert.match(productRuntime, /TaskJoinState::Pending/); +assert.doesNotMatch( + productRuntime, + /entry\s*\(/, + "product runtime must not invoke the submitted local Rust closure" +); + +cp.execFileSync( + "cargo", + [ + "test", + "-p", + "disasmer-sdk", + "spawn_task_start_registers_debugger_visible_runtime_thread", + ], + { cwd: repo, stdio: "inherit" } +); + +console.log("SDK spawn runtime smoke passed"); diff --git a/scripts/self-hosted-coordinator-smoke.js b/scripts/self-hosted-coordinator-smoke.js new file mode 100644 index 0000000..90087fd --- /dev/null +++ b/scripts/self-hosted-coordinator-smoke.js @@ -0,0 +1,723 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); + +const repo = path.resolve(__dirname, ".."); +const teamArtifactDigest = `sha256:${"a".repeat(64)}`; +const teamEnvironmentDigest = `sha256:${crypto + .createHash("sha256") + .update("env-team-linux") + .digest("hex")}`; +const teamDependencyCacheDigest = `sha256:${crypto + .createHash("sha256") + .update("cargo-cache") + .digest("hex")}`; +const teamSourceDigest = `sha256:${crypto + .createHash("sha256") + .update("source-team") + .digest("hex")}`; +const coordinatorTaskModule = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); +const coordinatorTaskBundleDigest = `sha256:${crypto + .createHash("sha256") + .update(coordinatorTaskModule) + .digest("hex")}`; +const adminToken = "self-hosted-smoke-admin-token"; +const clientSessionSecret = "self-hosted-smoke-client-session-secret"; +const releaseManifestPath = + process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || + path.join(repo, "target/public-release-dryrun/public-release-manifest.json"); + +function sha256(value) { + return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +} + +function digestFromParts(parts) { + const hash = crypto.createHash("sha256"); + for (const value of parts) { + const bytes = Buffer.from(String(value)); + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(bytes.length)); + hash.update(length); + hash.update(bytes); + } + return `sha256:${hash.digest("hex")}`; +} + +function adminRequest(token, operation, tenant, actorUser, targetTenant, nonce) { + const issuedAtEpochSeconds = Math.floor(Date.now() / 1000); + return { + type: operation, + tenant, + actor_user: actorUser, + ...(operation === "suspend_tenant" ? { target_tenant: targetTenant } : {}), + admin_proof: digestFromParts([ + "disasmer-admin-request-proof:v1", + sha256(token), + operation, + tenant, + actorUser, + targetTenant, + nonce, + issuedAtEpochSeconds, + ]), + admin_nonce: nonce, + issued_at_epoch_seconds: issuedAtEpochSeconds, + }; +} + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function authenticated(request, sessionSecret = clientSessionSecret) { + return { + type: "authenticated", + session_secret: sessionSecret, + request, + }; +} + +function linuxNodeCapabilities() { + return { + os: "Linux", + arch: "x86_64", + capabilities: ["Command", "RootlessPodman", "VfsArtifacts", "QuicDirect"], + environment_backends: ["Container"], + source_providers: ["filesystem", "git"] + }; +} + +function nodeIdentity(node) { + void node; + const { privateKey: privateKeyObject, publicKey } = + crypto.generateKeyPairSync("ed25519"); + const publicDer = publicKey.export({ + format: "der", + type: "spki", + }); + return { + publicKey: `ed25519:${Buffer.from(publicDer).subarray(-32).toString("base64")}`, + privateKeyObject, + }; +} + +function signedRequestPayloadDigest(request) { + const canonicalize = (value, topLevel = true) => { + if (Array.isArray(value)) return value.map((entry) => canonicalize(entry, false)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter( + ([key, entry]) => + entry !== null && + (!topLevel || !["agent_signature", "node_signature"].includes(key)) + ) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, canonicalize(entry, false)]) + ); + } + return value; + }; + return `sha256:${crypto + .createHash("sha256") + .update(JSON.stringify(canonicalize(request))) + .digest("hex")}`; +} + +function nodeSignatureMessage( + node, + requestKind, + payloadDigest, + nonce, + issuedAtEpochSeconds +) { + const parts = [ + "disasmer-node-request-signature:v2", + node, + requestKind, + payloadDigest, + nonce, + String(issuedAtEpochSeconds), + ]; + return Buffer.concat( + parts.flatMap((part) => [ + Buffer.from(`${Buffer.byteLength(part)}:`), + Buffer.from(part), + Buffer.from("\n"), + ]) + ); +} + +function signedNodeHeartbeat(node, identity) { + const request = { type: "node_heartbeat", node }; + const nonce = `self-hosted-heartbeat-${process.pid}-${Date.now()}`; + const issuedAt = Math.floor(Date.now() / 1000); + const signature = crypto.sign( + null, + nodeSignatureMessage( + node, + "node_heartbeat", + signedRequestPayloadDigest(request), + nonce, + issuedAt + ), + identity.privateKeyObject + ); + return { + nonce, + issued_at_epoch_seconds: issuedAt, + signature: `ed25519:${signature.toString("base64")}`, + }; +} + +function signedNodeRequest(node, identity, requestKind, request) { + return { + type: "signed_node", + node, + node_signature: signedNodeHeartbeatForKind(node, identity, requestKind, request), + request, + }; +} + +function signedNodeHeartbeatForKind(node, identity, requestKind, request) { + const nonce = `${requestKind}-${process.pid}-${Date.now()}`; + const issuedAt = Math.floor(Date.now() / 1000); + const signature = crypto.sign( + null, + nodeSignatureMessage( + node, + requestKind, + signedRequestPayloadDigest(request), + nonce, + issuedAt + ), + identity.privateKeyObject + ); + return { + nonce, + issued_at_epoch_seconds: issuedAt, + signature: `ed25519:${signature.toString("base64")}`, + }; +} + +function commandOutput(command, args) { + try { + return cp + .execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + .trim(); + } catch (_) { + return null; + } +} + +function runJson(command, args, options = {}) { + return JSON.parse( + cp.execFileSync(command, args, { + cwd: options.cwd || repo, + encoding: "utf8", + input: options.input, + }) + ); +} + +function expectedSourceCommit() { + return ( + process.env.DISASMER_ACCEPTANCE_COMMIT || + commandOutput("git", ["rev-parse", "HEAD"]) + ); +} + +function readReleaseManifest() { + if (!fs.existsSync(releaseManifestPath)) return null; + const manifest = JSON.parse(fs.readFileSync(releaseManifestPath, "utf8")); + assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); + const expectedCommit = expectedSourceCommit(); + if (expectedCommit) { + assert.strictEqual( + manifest.source_commit, + expectedCommit, + "self-hosted coordinator smoke must use the manifest for the current acceptance commit" + ); + } + return manifest; +} + +function releaseIdentity() { + const manifest = readReleaseManifest(); + return { + sourceCommit: manifest ? manifest.source_commit : expectedSourceCommit(), + releaseName: + (manifest && manifest.release_name) || + process.env.DISASMER_PUBLIC_RELEASE_NAME || + null, + }; +} + +async function attachTrustedNode(addr, node, capabilities = linuxNodeCapabilities()) { + const identity = nodeIdentity(node); + const grant = await send(addr, authenticated({ + type: "create_node_enrollment_grant", + ttl_seconds: 900, + })); + assert.strictEqual(grant.type, "node_enrollment_grant_created"); + + const attached = await send(addr, { + type: "exchange_node_enrollment_grant", + tenant: "team", + project: "self-hosted", + node, + public_key: identity.publicKey, + enrollment_grant: grant.grant, + }); + assert.strictEqual(attached.type, "node_enrollment_exchanged"); + + const heartbeat = await send(addr, { + type: "node_heartbeat", + node, + node_signature: signedNodeHeartbeat(node, identity) + }); + assert.strictEqual(heartbeat.type, "node_heartbeat"); + + const reported = await send(addr, signedNodeRequest(node, identity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "team", + project: "self-hosted", + node, + capabilities, + cached_environment_digests: [teamEnvironmentDigest], + dependency_cache_digests: [teamDependencyCacheDigest], + source_snapshots: [teamSourceDigest], + artifact_locations: [], + direct_connectivity: true, + online: true + })); + assert.strictEqual(reported.type, "node_capabilities_recorded"); + return identity; +} + +(async () => { + const release = releaseIdentity(); + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0" + ], + { + cwd: repo, + env: { + ...process.env, + DISASMER_ADMIN_TOKEN: adminToken, + DISASMER_SELF_HOSTED_SESSION_SECRET: clientSessionSecret, + DISASMER_SELF_HOSTED_TENANT: "team", + DISASMER_SELF_HOSTED_PROJECT: "self-hosted", + DISASMER_SELF_HOSTED_USER: "developer", + }, + } + ); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual(ready.client_authority, "strict"); + assert.strictEqual(ready.self_hosted_session_bootstrapped, true); + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const forgedBodyAuthority = await send(addr, { + type: "start_process", + tenant: "victim-tenant", + project: "victim-project", + actor_user: "forged-user", + process: "vp-forged", + }); + assert.strictEqual(forgedBodyAuthority.type, "error"); + assert.match(forgedBodyAuthority.message, /body.*identity.*not authority/i); + const wrongSession = await send( + addr, + authenticated({ type: "auth_status" }, "wrong-session-secret") + ); + assert.strictEqual(wrongSession.type, "error"); + assert.match(wrongSession.message, /session.*not recognized|not recognized.*session/i); + const authStatus = await send(addr, authenticated({ type: "auth_status" })); + assert.strictEqual(authStatus.type, "auth_status"); + assert.strictEqual(authStatus.tenant, "team"); + assert.strictEqual(authStatus.project, "self-hosted"); + assert.strictEqual(authStatus.actor, "developer"); + + const missingAdminCredential = await send( + addr, + adminRequest("", "admin_status", "team", "forged-admin", "team", "admin-empty") + ); + assert.strictEqual(missingAdminCredential.type, "error"); + assert.match(missingAdminCredential.message, /admin.*proof.*invalid/i); + const wrongAdminCredential = await send( + addr, + adminRequest( + "wrong-token", + "admin_status", + "team", + "forged-admin", + "team", + "admin-wrong" + ) + ); + assert.strictEqual(wrongAdminCredential.type, "error"); + assert.match(wrongAdminCredential.message, /admin.*proof.*invalid/i); + const replayableAdminRequest = adminRequest( + adminToken, + "admin_status", + "team", + "self-hosted-admin", + "team", + "admin-replay" + ); + const directAdminStatus = await send(addr, replayableAdminRequest); + assert.strictEqual(directAdminStatus.type, "admin_status"); + const replayedAdminStatus = await send(addr, replayableAdminRequest); + assert.strictEqual(replayedAdminStatus.type, "error"); + assert.match(replayedAdminStatus.message, /nonce was already used/i); + const cargoTargetDir = path.resolve( + process.env.CARGO_TARGET_DIR || path.join(repo, "target") + ); + const cliBin = path.join( + cargoTargetDir, + "debug", + process.platform === "win32" ? "disasmer.exe" : "disasmer" + ); + const selfHostedCliProject = path.join( + repo, + "target/acceptance/self-hosted-cli-project" + ); + fs.rmSync(selfHostedCliProject, { recursive: true, force: true }); + fs.mkdirSync(selfHostedCliProject, { recursive: true }); + const cliConnect = runJson( + cliBin, + [ + "auth", + "connect-self-hosted", + "--coordinator", + ready.listen, + "--tenant", + "team", + "--project-id", + "self-hosted", + "--user", + "developer", + "--session-secret-stdin", + "--json", + ], + { cwd: selfHostedCliProject, input: `${clientSessionSecret}\n` } + ); + assert.strictEqual(cliConnect.status, "connected"); + assert.strictEqual(cliConnect.session_secret_read_from_stdin, true); + assert.strictEqual(cliConnect.session_secret_exposed_in_report, false); + assert.strictEqual(cliConnect.coordinator_response.type, "auth_status"); + const sessionPath = path.join( + selfHostedCliProject, + ".disasmer/session.json" + ); + const storedSession = JSON.parse(fs.readFileSync(sessionPath, "utf8")); + assert.strictEqual(storedSession.kind, "self_hosted"); + assert.strictEqual(storedSession.session_secret, clientSessionSecret); + assert.strictEqual(storedSession.provider_tokens_exposed_to_cli, false); + assert.strictEqual(storedSession.provider_tokens_sent_to_nodes, false); + if (process.platform !== "win32") { + assert.strictEqual(fs.statSync(sessionPath).mode & 0o777, 0o600); + } + const cliAuthStatus = runJson( + cliBin, + ["auth", "status", "--json"], + { cwd: selfHostedCliProject } + ); + assert.strictEqual(cliAuthStatus.active_coordinator, ready.listen); + assert.strictEqual( + cliAuthStatus.coordinator_account_status.used_cli_session_credential, + true + ); + assert.strictEqual( + cliAuthStatus.coordinator_account_status.coordinator_response_type, + "auth_status" + ); + const cliAdminStatus = runJson(cliBin, [ + "admin", + "status", + "--coordinator", + ready.listen, + "--tenant", + "team", + "--user", + "self-hosted-admin", + "--admin-token", + adminToken, + "--json", + ]); + assert.strictEqual(cliAdminStatus.response.type, "admin_status"); + assert.strictEqual(cliAdminStatus.suspended, false); + const cliAdminSuspend = runJson(cliBin, [ + "admin", + "suspend-tenant", + "--coordinator", + ready.listen, + "--tenant", + "team", + "--user", + "self-hosted-admin", + "--target-tenant", + "admin-probe-tenant", + "--admin-token", + adminToken, + "--yes", + "--json", + ]); + assert.strictEqual(cliAdminSuspend.response.type, "tenant_suspended"); + assert.strictEqual(cliAdminSuspend.suspended, true); + const cliAdminProbeStatus = runJson(cliBin, [ + "admin", + "status", + "--coordinator", + ready.listen, + "--tenant", + "admin-probe-tenant", + "--user", + "self-hosted-admin", + "--admin-token", + adminToken, + "--json", + ]); + assert.strictEqual(cliAdminProbeStatus.suspended, true); + + const teamLinuxA = await attachTrustedNode(addr, "team-linux-a"); + const teamLinuxBCapabilities = linuxNodeCapabilities(); + teamLinuxBCapabilities.capabilities = teamLinuxBCapabilities.capabilities.filter( + (capability) => capability !== "VfsArtifacts" + ); + await attachTrustedNode(addr, "team-linux-b", teamLinuxBCapabilities); + + const placement = await send(addr, authenticated({ + type: "schedule_task", + environment: { + os: "Linux", + arch: "x86_64", + capabilities: ["Command", "QuicDirect"] + }, + environment_digest: teamEnvironmentDigest, + required_capabilities: ["VfsArtifacts"], + source_snapshot: teamSourceDigest, + required_artifacts: [], + prefer_node: "team-linux-a" + })); + assert.strictEqual(placement.type, "task_placement"); + assert.strictEqual(placement.placement.node, "team-linux-a"); + assert(placement.placement.reasons.includes("preferred node")); + assert(placement.placement.reasons.includes("warm environment cache")); + assert(placement.placement.reasons.includes("source snapshot already local")); + + const started = await send(addr, authenticated({ + type: "start_process", + process: "vp-team-build", + restart: false, + })); + assert.strictEqual(started.type, "process_started"); + + const reconnected = await send(addr, signedNodeRequest("team-linux-a", teamLinuxA, "reconnect_node", { + type: "reconnect_node", + node: "team-linux-a", + process: "vp-team-build", + epoch: started.epoch + })); + assert.strictEqual(reconnected.type, "node_reconnected"); + + const launched = await send(addr, authenticated({ + type: "launch_task", + task_spec: { + tenant: "team", + project: "self-hosted", + process: "vp-team-build", + task_definition: "compile-linux", + task_instance: "compile-linux", + dispatch: { + kind: "coordinator_node_wasm", + export: "compile_linux", + abi: "task_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: ["VfsArtifacts"], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [], + vfs_epoch: started.epoch, + bundle_digest: coordinatorTaskBundleDigest, + }, + wait_for_node: false, + artifact_path: "/vfs/artifacts/team-output.txt", + wasm_module_base64: coordinatorTaskModule.toString("base64"), + })); + assert.strictEqual(launched.type, "task_launched", JSON.stringify(launched)); + assert.strictEqual(launched.placement.node, "team-linux-a"); + + const completed = await send(addr, signedNodeRequest("team-linux-a", teamLinuxA, "task_completed", { + type: "task_completed", + tenant: "team", + project: "self-hosted", + process: "vp-team-build", + node: "team-linux-a", + task: "compile-linux", + status_code: 0, + stdout_bytes: 18, + stderr_bytes: 0, + stdout_tail: "", + stderr_tail: "", + stdout_truncated: false, + stderr_truncated: false, + artifact_path: "/vfs/artifacts/team-output.txt", + artifact_digest: teamArtifactDigest, + artifact_size_bytes: 18 + })); + assert.strictEqual(completed.type, "task_recorded", JSON.stringify(completed)); + + const events = await send(addr, authenticated({ + type: "list_task_events", + process: "vp-team-build", + })); + assert.strictEqual(events.type, "task_events"); + assert.strictEqual(events.events.length, 1); + assert.strictEqual(events.events[0].node, "team-linux-a"); + assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/team-output.txt"); + + const link = await send(addr, authenticated({ + type: "create_artifact_download_link", + artifact: "team-output.txt", + max_bytes: 1024 * 1024, + ttl_seconds: 60 + })); + assert.strictEqual(link.type, "artifact_download_link"); + assert.deepStrictEqual(link.link.source, { RetainedNode: "team-linux-a" }); + + const exportPlan = await send(addr, authenticated({ + type: "export_artifact_to_node", + artifact: "team-output.txt", + receiver_node: "team-linux-b", + direct_connectivity: true, + failure_reason: "" + })); + assert.strictEqual(exportPlan.type, "artifact_export_plan"); + assert.strictEqual(exportPlan.source_node, "team-linux-a"); + assert.strictEqual(exportPlan.receiver_node, "team-linux-b"); + assert.strictEqual(exportPlan.plan.transport, "NativeQuic"); + assert.strictEqual(exportPlan.plan.coordinator_assisted_rendezvous, true); + assert.strictEqual(exportPlan.plan.coordinator_bulk_relay_allowed, false); + + const reportPath = path.join(repo, "target/acceptance/core-coordinator-compat.json"); + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync( + reportPath, + `${JSON.stringify( + { + kind: "disasmer-core-coordinator-compatibility", + source_commit: release.sourceCommit, + release_name: release.releaseName, + coordinator_implementation: "standalone-core-coordinator", + coordinator_addr: ready.listen, + client_authority: ready.client_authority, + authenticated_session: authStatus.authenticated, + forged_body_authority_denied: forgedBodyAuthority.type, + wrong_session_denied: wrongSession.type, + ping: "pong", + nodes: ["team-linux-a", "team-linux-b"], + task_placement: placement.type, + process_started: started.type, + task_completion: completed.type, + task_events: events.events.length, + artifact_download_link: link.type, + artifact_export_plan: exportPlan.type, + self_hosted_admin: { + missing_credential_denied: missingAdminCredential.type, + wrong_credential_denied: wrongAdminCredential.type, + nonce_bound_proof_succeeded: directAdminStatus.type, + replay_denied: replayedAdminStatus.type, + cli_status: cliAdminStatus.response.type, + cli_suspend: cliAdminSuspend.response.type, + suspended_state_observed: cliAdminProbeStatus.suspended, + }, + self_hosted_cli: { + connected: cliConnect.status, + secret_read_from_stdin: cliConnect.session_secret_read_from_stdin, + secret_exposed_in_report: cliConnect.session_secret_exposed_in_report, + session_file_mode: + process.platform === "win32" + ? "windows-best-effort" + : (fs.statSync(sessionPath).mode & 0o777).toString(8), + authenticated_status: + cliAuthStatus.coordinator_account_status.coordinator_response_type, + }, + }, + null, + 2 + )}\n` + ); + } finally { + coordinator.kill("SIGTERM"); + } + + console.log("Self-hosted coordinator smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/source-preparation-smoke.js b/scripts/source-preparation-smoke.js new file mode 100755 index 0000000..039a46a --- /dev/null +++ b/scripts/source-preparation-smoke.js @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { nodeIdentity, signedNodeRequest } = require("./node-signing"); + +const repo = path.resolve(__dirname, ".."); + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function sourceCapableNode(sourceProviders = ["git"]) { + return { + os: "Linux", + arch: "x86_64", + capabilities: ["Command", "SourceFilesystem", "SourceGit"], + environment_backends: [], + source_providers: sourceProviders + }; +} + +(async () => { + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback" + ], + { cwd: repo } + ); + let coordinatorStderr = ""; + coordinator.stderr.on("data", (chunk) => { + coordinatorStderr += chunk.toString(); + }); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const pending = await send(addr, { + type: "request_source_preparation", + tenant: "tenant", + project: "project", + provider: "Git" + }); + assert.strictEqual(pending.type, "source_preparation"); + assert.strictEqual(pending.status.preparation.tenant, "tenant"); + assert.strictEqual(pending.status.preparation.project, "project"); + assert.strictEqual(pending.status.preparation.provider, "Git"); + assert.strictEqual( + pending.status.preparation.coordinator_requires_checkout_access, + false + ); + assert.deepStrictEqual(pending.status.preparation.required_capabilities, [ + "SourceGit" + ]); + assert.match(pending.status.disposition.Pending.reason, /waiting|node/i); + + const nodeIdentities = new Map(); + for (const node of ["source-cold", "source-ready"]) { + const identity = nodeIdentity("source-preparation-smoke", node); + nodeIdentities.set(node, identity); + const attached = await send(addr, { + type: "attach_node", + tenant: "tenant", + project: "project", + node, + public_key: identity.publicKey + }); + assert.strictEqual(attached.type, "node_attached"); + } + + const cold = await send(addr, signedNodeRequest("source-cold", nodeIdentities.get("source-cold"), "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node: "source-cold", + capabilities: sourceCapableNode(), + cached_environment_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: true, + online: true + })); + assert.strictEqual(cold.type, "node_capabilities_recorded"); + + const readyReport = await send(addr, signedNodeRequest("source-ready", nodeIdentities.get("source-ready"), "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node: "source-ready", + capabilities: sourceCapableNode(), + cached_environment_digests: [], + source_snapshots: [], + artifact_locations: [], + direct_connectivity: true, + online: true + })); + assert.strictEqual(readyReport.type, "node_capabilities_recorded"); + + const assigned = await send(addr, { + type: "request_source_preparation", + tenant: "tenant", + project: "project", + provider: "Git" + }); + assert.strictEqual(assigned.type, "source_preparation"); + assert(["source-cold", "source-ready"].includes(assigned.status.disposition.Assigned.node)); + assert.strictEqual( + assigned.status.preparation.coordinator_requires_checkout_access, + false + ); + + const crossTenantCompletion = await send(addr, signedNodeRequest("source-ready", nodeIdentities.get("source-ready"), "complete_source_preparation", { + type: "complete_source_preparation", + tenant: "other", + project: "project", + node: "source-ready", + provider: "Git", + source_snapshot: "sha256:source-prepared" + })); + assert.strictEqual(crossTenantCompletion.type, "error"); + assert.match(crossTenantCompletion.message, /tenant\/project scope/i); + + const completed = await send(addr, signedNodeRequest("source-ready", nodeIdentities.get("source-ready"), "complete_source_preparation", { + type: "complete_source_preparation", + tenant: "tenant", + project: "project", + node: "source-ready", + provider: "Git", + source_snapshot: "sha256:source-prepared" + })); + assert.strictEqual(completed.type, "source_preparation_completed"); + assert.strictEqual(completed.node, "source-ready"); + assert.strictEqual(completed.provider, "Git"); + assert.strictEqual(completed.source_snapshot, "sha256:source-prepared"); + + const placement = await send(addr, { + type: "schedule_task", + tenant: "tenant", + project: "project", + environment: null, + environment_digest: null, + required_capabilities: ["SourceGit"], + source_snapshot: "sha256:source-prepared", + required_artifacts: [], + prefer_node: null + }); + assert.strictEqual(placement.type, "task_placement"); + assert.strictEqual(placement.placement.node, "source-ready"); + assert( + placement.placement.reasons.includes("source snapshot already local"), + "completed source preparation must update node source locality" + ); + } catch (error) { + if (coordinatorStderr) { + error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`; + } + throw error; + } finally { + coordinator.kill("SIGTERM"); + } + + console.log("Source preparation smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/tenant-isolation-contract-smoke.js b/scripts/tenant-isolation-contract-smoke.js new file mode 100644 index 0000000..35ea3c5 --- /dev/null +++ b/scripts/tenant-isolation-contract-smoke.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function maybeRead(segments) { + const fullPath = path.join(repo, ...segments); + if (!fs.existsSync(fullPath)) return null; + return fs.readFileSync(fullPath, "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing tenant-isolation evidence: ${name}`); +} + +function expectGate(script, gateName) { + assert( + script.includes("node scripts/tenant-isolation-contract-smoke.js"), + `${gateName} must run tenant-isolation-contract-smoke.js` + ); +} + +const auth = read("crates/disasmer-core/src/auth.rs"); +const artifact = read("crates/disasmer-core/src/artifact.rs"); +const operatorPanel = read("crates/disasmer-core/src/operator_panel.rs"); +const source = read("crates/disasmer-core/src/source.rs"); +const coordinatorService = [ + read("crates/disasmer-coordinator/src/service.rs"), + read("crates/disasmer-coordinator/src/service/routing.rs"), + read("crates/disasmer-coordinator/src/service/nodes.rs"), + read("crates/disasmer-coordinator/src/service/keys.rs"), + read("crates/disasmer-coordinator/src/service/artifacts.rs"), + read("crates/disasmer-coordinator/src/service/processes.rs"), + read("crates/disasmer-coordinator/src/service/process_launch.rs"), + read("crates/disasmer-coordinator/src/service/tests.rs"), +].join("\n"); +const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js"); +const operatorPanelSmoke = read("scripts/operator-panel-smoke.js"); +const schedulerSmoke = read("scripts/scheduler-placement-smoke.js"); +const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js"); +const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); +const privateAcceptance = read("scripts/acceptance-private.sh"); + +for (const [name, pattern] of [ + ["auth contexts carry tenant and project", /pub struct AuthContext[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/], + ["enrollment grants carry tenant and project", /pub struct EnrollmentGrant[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/], + ["node credentials carry tenant and project", /pub struct NodeCredential[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/], + ["same tenant/project denies tenant mismatch", /pub fn same_tenant_project[\s\S]*tenant mismatch/], + ["same tenant/project denies project mismatch", /pub fn same_tenant_project[\s\S]*project mismatch/], + ["auth unit denies cross-tenant access", /fn tenant_project_scope_denies_cross_tenant_access\(\)/], +]) { + expect(auth, name, pattern); +} + +for (const [name, pattern] of [ + ["artifact metadata carries tenant and project", /pub struct ArtifactMetadata[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/], + ["download links carry tenant project process and actor", /pub struct DownloadLink[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId[\s\S]*pub actor: Actor/], + ["downloads authorize against scoped metadata", /let authz = same_tenant_project\(context, &scope\)/], + ["artifact test denies cross-tenant download", /fn cross_tenant_download_is_denied_even_with_known_artifact_id\(\)/], + ["artifact test denies cross-project download", /fn cross_project_download_is_denied_even_with_known_artifact_id\(\)/], +]) { + expect(artifact, name, pattern); +} + +for (const [name, pattern] of [ + ["panel state carries tenant project and process", /pub struct PanelState[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId/], + ["panel events carry tenant project and process", /pub struct PanelEvent[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId/], + ["panel events reject scope mismatch", /PanelError::ScopeMismatch/], + ["panel scope validation compares tenant project process", /self\.tenant != event\.tenant[\s\S]*self\.project != event\.project[\s\S]*self\.process != event\.process/], +]) { + expect(operatorPanel, name, pattern); +} + +expect( + source, + "source preparation carries tenant and project", + /pub struct SourcePreparation[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/ +); + +for (const [name, pattern] of [ + ["project creation rejects foreign tenant reuse", /project id is outside the signed-in tenant scope/], + ["project selection rejects foreign tenant", /project is outside the signed-in tenant scope/], + ["project listing uses tenant-scoped context", /CoordinatorRequest::ListProjects[\s\S]*list_projects\(&context\)/], + ["node capability reports check enrollment tenant scope", /node capability report is outside the enrolled tenant\/project scope/], + ["operator panel stop state is tenant/project/process keyed", /type PanelStopKey = \(TenantId, ProjectId, ProcessId\)/], + ["download service tests tenant mismatch", /cross_tenant\.to_string\(\)\.contains\("tenant mismatch"\)/], + ["download service tests project mismatch", /cross_project\.to_string\(\)\.contains\("project mismatch"\)/], + ["node capability test rejects cross-scope report", /fn service_rejects_node_capability_report_outside_enrollment_scope\(\)/], + ["source preparation test rejects cross-scope completion", /fn service_rejects_source_preparation_completion_outside_node_scope\(\)/], +]) { + expect(coordinatorService, name, pattern); +} + +for (const [name, sourceText, patterns] of [ + [ + "artifact download smoke", + artifactDownloadSmoke, + [ + /const crossTenant = await send/, + /const crossProject = await send/, + /const crossTenantOpen = await send/, + /const crossProjectOpen = await send/, + /tenant mismatch/, + /project mismatch/, + /token is invalid/, + ], + ], + [ + "operator panel smoke", + operatorPanelSmoke, + [/const crossTenant = await send/, /render_operator_panel/, /scope\|tenant\|project/], + ], + [ + "scheduler and capability smoke", + schedulerSmoke, + [ + /const crossScopeInspection = await send/, + /assert\.strictEqual\(crossScopeInspection\.descriptors\.length, 0\)/, + /const crossTenantReport = await send/, + /tenant\\\/project scope/, + ], + ], + [ + "source preparation smoke", + sourcePreparationSmoke, + [/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i], + ], +]) { + for (const pattern of patterns) { + expect(sourceText, name, pattern); + } +} + +expect(releaseBlockerSmoke, "release blockers list tenant-isolation failure", /cross-tenant access is listed as a release blocker/); +expectGate(publicAcceptance, "public acceptance"); +expectGate(publicSplit, "public split acceptance"); +expectGate(privateAcceptance, "private acceptance"); + +const hostedLibRoot = maybeRead(["private", "hosted-policy", "src", "lib.rs"]); +const hostedServiceSource = maybeRead([ + "private", + "hosted-policy", + "src", + "bin", + "disasmer-hosted-service.rs", +]); +const hostedLib = hostedLibRoot; +const hostedSmoke = maybeRead([ + "private", + "hosted-policy", + "scripts", + "hosted-client-compat-smoke.js", +]); + +if (hostedLib && hostedServiceSource && hostedSmoke) { + for (const [name, pattern] of [ + ["hosted private layer is a compact identity broker", /pub struct HostedIdentityBroker[\s\S]*cli_session_ttl_seconds/], + ["hosted private layer has no parallel coordinator", /Runtime, persistence, nodes, processes,[\s\S]*remain owned by public CoordinatorService/], + ["hosted service delegates Client state to Core", /core_coordinator: CoordinatorService/], + ["hosted login creates project through Core", /issue_cli_session[\s\S]*AuthenticatedCoordinatorRequest::CreateProject/], + ]) { + expect(`${hostedLib}\n${hostedServiceSource}`, name, pattern); + } + + for (const forbidden of [ + "HostedCommunityControlPlane", + "HostedObservabilitySnapshot", + "agent_public_keys: BTreeMap", + "node_statuses: BTreeMap", + "process_statuses: BTreeMap", + "debug_sessions: BTreeMap", + ]) { + assert( + !hostedLib.includes(forbidden), + `private hosted policy must not reimplement Core state: ${forbidden}` + ); + } + + for (const [name, pattern] of [ + ["client-supplied identity is denied", /const forged = await sendHostedControl[\s\S]*authenticated CLI session/], + ["second OIDC subject receives a different tenant", /assert\.notStrictEqual\(victimLogin\.session\.tenant, session\.tenant\)/], + ["cross-tenant process events are denied", /const crossTenantTaskEventsDenied = await sendHostedControl[\s\S]*vp-victim[\s\S]*scope\|denied\|unauthorized/], + ]) { + expect(hostedSmoke, name, pattern); + } +} + +console.log("Tenant isolation contract smoke passed"); diff --git a/scripts/user-session-token-boundary-smoke.js b/scripts/user-session-token-boundary-smoke.js new file mode 100755 index 0000000..dce7096 --- /dev/null +++ b/scripts/user-session-token-boundary-smoke.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(file) { + return fs.readFileSync(path.join(repo, file), "utf8"); +} + +function extractBalancedBlock(source, marker) { + const start = source.indexOf(marker); + assert(start >= 0, `missing marker ${marker}`); + const open = source.indexOf("{", start); + assert(open >= 0, `missing opening brace for ${marker}`); + let depth = 0; + for (let index = open; index < source.length; index += 1) { + const char = source[index]; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + } + throw new Error(`missing closing brace for ${marker}`); +} + +function extractEnumVariant(source, variant) { + const marker = ` ${variant} {`; + return extractBalancedBlock(source, marker); +} + +const forbiddenUserSessionCredentials = + /\b(BrowserSession|CliDeviceSession|BrowserLoginFlow|CliLoginFlow|authorization_url|verification_url|device_code|access_token|refresh_token|provider_token|provider_tokens|session_token|user_token|oauth_token)\b/i; + +function assertNoUserSessionCredential(surface, text) { + assert.doesNotMatch( + text, + forbiddenUserSessionCredentials, + `${surface} must not carry user OAuth/browser/session credentials` + ); +} + +const coordinatorService = `${read("crates/disasmer-coordinator/src/service/protocol.rs")}\n${read("crates/disasmer-coordinator/src/service/protocol/responses.rs")}`; +for (const variant of [ + "AdminStatus", + "SuspendTenant", + "AttachNode", + "RegisterAgentPublicKey", + "ListAgentPublicKeys", + "RotateAgentPublicKey", + "RevokeAgentPublicKey", + "NodeHeartbeat", + "ReportNodeCapabilities", + "RevokeNodeCredential", + "RequestRendezvous", + "RequestSourcePreparation", + "CompleteSourcePreparation", + "StartProcess", + "ReconnectNode", + "CancelTask", + "CancelProcess", + "PollTaskControl", + "RestartTask", + "DebugAttach", + "TaskCompleted", +]) { + assertNoUserSessionCredential( + `CoordinatorRequest::${variant}`, + extractEnumVariant(coordinatorService, variant) + ); +} + +const nodeRuntime = [ + read("crates/disasmer-node/src/lib.rs"), + read("crates/disasmer-node/src/command_runner.rs"), +].join("\n"); +for (const marker of [ + "pub struct LinuxCommandRunPlan", + "pub struct LinuxCommandTaskOutput", + "pub struct CapturedCommandLogs", + "pub struct VirtualThreadCommand", + "pub struct CommandOutput", +]) { + assertNoUserSessionCredential(marker, extractBalancedBlock(nodeRuntime, marker)); +} + +const coreExecution = read("crates/disasmer-core/src/execution.rs"); +assertNoUserSessionCredential( + "CommandInvocation", + extractBalancedBlock(coreExecution, "pub struct CommandInvocation") +); + +const dapAdapter = read("crates/disasmer-dap/src/variables.rs"); +assertNoUserSessionCredential( + "DAP variables response", + extractBalancedBlock(dapAdapter, "fn variables_response") +); + +const panel = read("crates/disasmer-core/src/operator_panel.rs"); +assertNoUserSessionCredential( + "PanelEvent", + extractBalancedBlock(panel, "pub struct PanelEvent") +); + +const auth = read("crates/disasmer-core/src/auth.rs"); +assert.match( + auth, + /task_credentials_do_not_contain_user_session/, + "core auth must keep the task credential user-session guard" +); +assert.match( + auth, + /CredentialKind::BrowserSession \| CredentialKind::CliDeviceSession/, + "task credential guard must reject browser and CLI sessions" +); + +console.log("User session token boundary smoke passed"); diff --git a/scripts/verify-public-split.sh b/scripts/verify-public-split.sh new file mode 100755 index 0000000..ad8c9f5 --- /dev/null +++ b/scripts/verify-public-split.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_commit="$(git -C "$repo_root" rev-parse HEAD)" +export DISASMER_ACCEPTANCE_COMMIT="$source_commit" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +tar \ + --exclude='./.git' \ + --exclude='./target' \ + --exclude='./private' \ + --exclude='./experiments' \ + --exclude='./.disasmer' \ + --exclude='./vscode-extension/node_modules' \ + --exclude='./scripts/containers-home' \ + -C "$repo_root" \ + -cf - . | tar -C "$tmp_dir" -xf - + +if find "$tmp_dir" -path "$tmp_dir/private" -print -quit | grep -q .; then + echo "private directory leaked into public split" >&2 + exit 1 +fi + +if find "$tmp_dir" -path "$tmp_dir/experiments" -print -quit | grep -q .; then + echo "experiments directory leaked into public split" >&2 + exit 1 +fi + +cargo test --workspace --manifest-path "$tmp_dir/Cargo.toml" +cargo build --workspace --bins --manifest-path "$tmp_dir/Cargo.toml" +(cd "$tmp_dir" && node scripts/acceptance-report-smoke.js) +(cd "$tmp_dir" && node scripts/acceptance-doc-contract-smoke.js) +(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js) +(cd "$tmp_dir" && node scripts/acceptance-evidence-contract-smoke.js) +(cd "$tmp_dir" && node scripts/code-size-guard.js) +(cd "$tmp_dir" && node scripts/public-private-boundary-smoke.js) +(cd "$tmp_dir" && node scripts/release-blocker-smoke.js) +(cd "$tmp_dir" && node scripts/resource-metering-contract-smoke.js) +(cd "$tmp_dir" && node scripts/hostile-input-contract-smoke.js) +(cd "$tmp_dir" && node scripts/tenant-isolation-contract-smoke.js) +(cd "$tmp_dir" && node scripts/public-story-contract-smoke.js) +(cd "$tmp_dir" && node scripts/public-release-dryrun-contract-smoke.js) +(cd "$tmp_dir" && node scripts/self-hosted-coordinator-smoke.js) +(cd "$tmp_dir" && node scripts/public-local-demo-matrix-smoke.js) +(cd "$tmp_dir" && scripts/release-source-scan.sh) +(cd "$tmp_dir" && node scripts/prepare-public-release-dryrun.js) +if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then + (cd "$tmp_dir" && node scripts/publish-public-release-dryrun.js) +fi +(cd "$tmp_dir" && node scripts/docs-smoke.js) +(cd "$tmp_dir" && node scripts/cli-output-mode-smoke.js) +(cd "$tmp_dir" && node scripts/cli-login-smoke.js) +(cd "$tmp_dir" && node scripts/cli-error-exit-smoke.js) +(cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js) +(cd "$tmp_dir" && node scripts/cli-install-smoke.js) +(cd "$tmp_dir" && node scripts/user-session-token-boundary-smoke.js) +(cd "$tmp_dir" && node scripts/sdk-spawn-runtime-smoke.js) +(cd "$tmp_dir" && node scripts/node-lifecycle-contract-smoke.js) +(cd "$tmp_dir" && node scripts/vscode-extension-smoke.js) +(cd "$tmp_dir" && node scripts/vscode-f5-smoke.js) +(cd "$tmp_dir" && node scripts/node-attach-smoke.js) +(cd "$tmp_dir" && node scripts/wasmtime-assignment-smoke.js) +(cd "$tmp_dir" && node scripts/cli-local-run-smoke.js) +(cd "$tmp_dir" && node scripts/artifact-download-smoke.js) +(cd "$tmp_dir" && node scripts/artifact-export-smoke.js) +(cd "$tmp_dir" && node scripts/operator-panel-smoke.js) +(cd "$tmp_dir" && node scripts/source-preparation-smoke.js) +(cd "$tmp_dir" && node scripts/scheduler-placement-smoke.js) +(cd "$tmp_dir" && node scripts/windows-best-effort-smoke.js) +(cd "$tmp_dir" && node scripts/windows-validation-contract-smoke.js) +(cd "$tmp_dir" && node scripts/quic-smoke.js) +(cd "$tmp_dir" && node scripts/dap-smoke.js) +(cd "$tmp_dir" && node scripts/flagship-demo-smoke.js) diff --git a/scripts/vscode-extension-smoke.js b/scripts/vscode-extension-smoke.js new file mode 100755 index 0000000..a77c651 --- /dev/null +++ b/scripts/vscode-extension-smoke.js @@ -0,0 +1,232 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const assert = require("assert"); + +const extension = require("../vscode-extension/extension"); +const packageJson = require("../vscode-extension/package.json"); +const extensionSource = fs.readFileSync( + path.join(__dirname, "../vscode-extension/extension.js"), + "utf8" +); +const readme = fs.readFileSync(path.join(__dirname, "../README.md"), "utf8"); +const repo = path.resolve(__dirname, ".."); + +assert.strictEqual(packageJson.main, "./extension.js"); +assert(fs.existsSync(path.join(__dirname, "../vscode-extension", packageJson.main))); +assert.deepStrictEqual(packageJson.dependencies || {}, {}); +assert.match(readme, /code --extensionDevelopmentPath "\$\(pwd\)\/vscode-extension"/); + +const root = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-vscode-")); +fs.mkdirSync(path.join(root, "envs/linux"), { recursive: true }); +fs.writeFileSync(path.join(root, "envs/linux/Containerfile"), "FROM alpine\n"); +fs.mkdirSync(path.join(root, ".disasmer"), { recursive: true }); +fs.writeFileSync( + extension.disasmerViewStatePath(root), + JSON.stringify({ + nodes: [{ id: "node-linux", status: "online", capabilities: "Command RootlessPodman" }], + processes: [{ id: "vp-build", status: "running", entry: "build" }], + logs: [{ task: "compile-linux", message: "stdout=12 stderr=0", bytes: 12 }], + artifacts: [{ path: "/vfs/artifacts/app.tar.zst", status: "retained", size: 12 }], + inspector: [{ label: "debug", value: "attached" }] + }) +); + +const envs = extension.discoverEnvironmentNames(root); +assert.deepStrictEqual(envs, ["linux"]); + +const diagnostics = extension.diagnoseEnvReferences( + 'let _ = env!("linux"); let _ = env!("windows");', + envs +); +assert.strictEqual(diagnostics.length, 1); +assert.strictEqual(diagnostics[0].name, "windows"); +assert.match(diagnostics[0].message, /envs\/windows\/Containerfile/); + +const inspectCommand = extension.bundleInspectCommand(root, "/repo"); +assert.strictEqual(inspectCommand.command, "cargo"); +assert.deepStrictEqual(inspectCommand.args.slice(0, 8), [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "bundle" +]); +assert(inspectCommand.args.includes("inspect")); +assert(inspectCommand.args.includes("--project")); +assert(inspectCommand.args.includes(root)); +assert(inspectCommand.args.includes("--json")); + +const refreshed = extension.refreshBundleBeforeLaunch(root, "/repo", (command, args, options) => { + assert.strictEqual(command, "cargo"); + assert(args.includes("bundle")); + assert.strictEqual(options.cwd, "/repo"); + return { + status: 0, + stdout: JSON.stringify({ metadata: { identity: "sha256:abc" } }), + stderr: "" + }; +}); +assert.strictEqual(refreshed.metadata.identity, "sha256:abc"); + +const launch = extension.resolveDisasmerDebugConfiguration( + { uri: { fsPath: root } }, + {} +); +assert.deepStrictEqual(launch, { + name: "Disasmer: Launch Virtual Process", + type: "disasmer", + request: "launch", + entry: "build", + project: root, + runtimeBackend: "local-services" +}); +assert.strictEqual( + extension.disasmerProcessId("/workspace/app", "build"), + "vp-e4bd6ef50539" +); +assert.strictEqual( + extension.existingProcessRelationship( + { process: "vp-e4bd6ef50539", state: "running" }, + "vp-e4bd6ef50539" + ), + "same_launch_target" +); +assert.strictEqual( + extension.existingProcessRelationship( + { process: "vp-other", state: "running" }, + "vp-e4bd6ef50539" + ), + "different_launch_target" +); + +const liveProcesses = extension.loadLiveProcesses(root, "/repo", (_command, args) => { + assert.deepStrictEqual(args.slice(-3), ["process", "list", "--json"]); + return { + status: 0, + stdout: JSON.stringify({ + coordinator: "https://disasmer.michelpaulissen.com", + tenant: "tenant-live", + project: "project-live", + user: "user-live", + processes: [{ process: "vp-live", state: "cancelling" }] + }), + stderr: "" + }; +}); +assert.deepStrictEqual(liveProcesses.processes, [ + { process: "vp-live", state: "cancelling" } +]); +assert.strictEqual(liveProcesses.project, "project-live"); + +const adapter = extension.debugAdapterExecutableSpec(root, repo); +assert.strictEqual(adapter.command, "cargo"); +assert.deepStrictEqual(adapter.args, [ + "run", + "-q", + "-p", + "disasmer-dap", + "--bin", + "disasmer-debug-dap" +]); +assert.deepStrictEqual(adapter.options, { cwd: repo }); + +const releasedAdapterPath = path.join(root, process.platform === "win32" ? "disasmer-debug-dap.exe" : "disasmer-debug-dap"); +fs.writeFileSync(releasedAdapterPath, ""); +const releasedAdapter = extension.debugAdapterExecutableSpec(root, repo); +assert.strictEqual(releasedAdapter.command, releasedAdapterPath); +assert.deepStrictEqual(releasedAdapter.args, []); +assert.deepStrictEqual(releasedAdapter.options, { cwd: root }); + +assert.throws( + () => + extension.refreshBundleBeforeLaunch(root, "/repo", () => ({ + status: 1, + stdout: "", + stderr: "missing environment linux" + })), + /missing environment linux/ +); + +assert( + packageJson.contributes.viewsContainers.activitybar.some( + (container) => container.id === "disasmer" && container.title === "Disasmer" + ), + "package.json must contribute a Disasmer activity-bar container" +); +assert( + fs.existsSync(path.join(__dirname, "../vscode-extension/resources/disasmer.svg")), + "Disasmer activity-bar icon must exist" +); +const packageViewIds = packageJson.contributes.views.disasmer.map((view) => view.id).sort(); +const descriptorViewIds = extension.disasmerViewDescriptors().map((view) => view.id).sort(); +assert.deepStrictEqual(descriptorViewIds, [ + "disasmer.artifacts", + "disasmer.inspector", + "disasmer.logs", + "disasmer.nodes", + "disasmer.processes" +]); +assert.deepStrictEqual(packageViewIds, descriptorViewIds); +for (const viewId of descriptorViewIds) { + const items = extension.disasmerViewItems( + extension.loadDisasmerViewState(root), + viewId + ); + assert( + items.length > 0 && !items[0].label.startsWith("No "), + `${viewId} should render state-backed items` + ); +} +assert.deepStrictEqual( + extension.disasmerViewItems(extension.loadDisasmerViewState(root), "disasmer.nodes")[0], + { + label: "node-linux", + description: "online Command RootlessPodman" + } +); + +assert( + packageJson.contributes.debuggers.some((debuggerContribution) => debuggerContribution.type === "disasmer"), + "package.json must contribute the disasmer debugger type" +); +for (const viewId of descriptorViewIds) { + assert( + packageJson.activationEvents.includes(`onView:${viewId}`), + `${viewId} should activate the extension when opened` + ); +} +const launchProperties = + packageJson.contributes.debuggers[0].configurationAttributes.launch.properties; +assert.strictEqual(launchProperties.runtimeBackend.default, "local-services"); +assert(launchProperties.runtimeBackend.enum.includes("live-services")); +assert.strictEqual(launchProperties.coordinatorEndpoint.default, undefined); +assert.strictEqual(launchProperties.tenant, undefined); +assert.strictEqual(launchProperties.projectId, undefined); +assert.strictEqual(launchProperties.actorUser, undefined); +assert( + packageJson.contributes.debuggers[0].configurationAttributes.attach, + "package.json must contribute an attach configuration" +); +for (const command of [ + "disasmer.refreshProcesses", + "disasmer.process.attach", + "disasmer.process.cancel", + "disasmer.process.abort" +]) { + assert( + packageJson.contributes.commands.some((entry) => entry.command === command), + `${command} must be contributed` + ); +} +assert.match(extensionSource, /registerDebugAdapterDescriptorFactory\("disasmer"/); +assert.match(extensionSource, /disasmer-debug-dap/); +assert.match(extensionSource, /\.disasmer\/views\.json/); + +fs.rmSync(root, { recursive: true, force: true }); +console.log("VS Code extension smoke passed"); diff --git a/scripts/vscode-f5-smoke.js b/scripts/vscode-f5-smoke.js new file mode 100755 index 0000000..c6e8c22 --- /dev/null +++ b/scripts/vscode-f5-smoke.js @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const extension = require("../vscode-extension/extension"); + +class DapClient { + constructor(spec) { + this.child = cp.spawn(spec.command, spec.args, { + cwd: spec.options && spec.options.cwd, + env: spec.options && spec.options.env, + detached: process.platform !== "win32" + }); + this.seq = 1; + this.buffer = Buffer.alloc(0); + this.messages = []; + this.waiters = []; + this.stderr = ""; + + this.child.stdout.on("data", (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.parse(); + }); + this.child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString(); + }); + this.child.on("exit", () => this.flushWaiters()); + } + + send(command, args = {}) { + const seq = this.seq++; + const message = { seq, type: "request", command, arguments: args }; + const payload = Buffer.from(JSON.stringify(message)); + this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); + this.child.stdin.write(payload); + return seq; + } + + async response(seq, command) { + const message = await this.waitFor( + (item) => + item.type === "response" && + item.request_seq === seq && + item.command === command + ); + if (!message.success) { + throw new Error( + `DAP ${command} failed: ${message.message || JSON.stringify(message)}\n${this.stderr}` + ); + } + return message; + } + + terminate() { + if (this.child.exitCode !== null) return; + if (process.platform === "win32") { + this.child.kill("SIGKILL"); + return; + } + try { + process.kill(-this.child.pid, "SIGKILL"); + } catch (_) { + this.child.kill("SIGKILL"); + } + } + + waitFor(predicate, timeoutMs = 240000) { + const existing = this.messages.find(predicate); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.terminate(); + reject(new Error(`timed out waiting for DAP message\n${this.stderr}`)); + }, timeoutMs); + this.waiters.push({ predicate, resolve, timer }); + }); + } + + parse() { + while (true) { + const headerEnd = this.buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = this.buffer.slice(0, headerEnd).toString(); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) throw new Error(`bad DAP header: ${header}`); + const length = Number(match[1]); + const start = headerEnd + 4; + const end = start + length; + if (this.buffer.length < end) return; + const payload = this.buffer.slice(start, end).toString(); + this.buffer = this.buffer.slice(end); + this.messages.push(JSON.parse(payload)); + this.flushWaiters(); + } + } + + flushWaiters() { + for (const waiter of [...this.waiters]) { + const message = this.messages.find(waiter.predicate); + if (!message) continue; + clearTimeout(waiter.timer); + this.waiters.splice(this.waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + } + + async close() { + if (this.child.exitCode !== null) return; + const seq = this.send("disconnect"); + await this.response(seq, "disconnect"); + this.child.stdin.end(); + } +} + +(async () => { + const repo = path.resolve(__dirname, ".."); + const project = path.join(repo, "examples/launch-build-demo"); + const buildSourceLines = fs + .readFileSync(path.join(project, "src/build.rs"), "utf8") + .split(/\r?\n/); + const sourceLine = (needle) => { + const index = buildSourceLines.findIndex((line) => line.includes(needle)); + assert(index >= 0, `flagship source must contain ${needle}`); + return index + 1; + }; + const buildMainLine = sourceLine("pub async fn build_main()"); + const launchConfig = extension.resolveDisasmerDebugConfiguration( + { uri: { fsPath: project } }, + {} + ); + + assert.strictEqual(launchConfig.type, "disasmer"); + assert.strictEqual(launchConfig.request, "launch"); + assert.strictEqual(launchConfig.entry, "build"); + assert.strictEqual(launchConfig.project, project); + assert.strictEqual(launchConfig.runtimeBackend, "local-services"); + + const inspection = extension.refreshBundleBeforeLaunch(project, repo); + assert.match(inspection.metadata.identity, /^sha256:/); + + // Keep the timed attach focused on the runtime boundary. A completely fresh + // public checkout may otherwise spend most of that window compiling the + // coordinator or node after the adapter has already started waiting. + cp.execFileSync( + "cargo", + [ + "build", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "-p", + "disasmer-node", + "--bin", + "disasmer-node", + ], + { cwd: repo, stdio: "inherit" } + ); + const executableSuffix = process.platform === "win32" ? ".exe" : ""; + const adapterSpec = extension.debugAdapterExecutableSpec(repo); + adapterSpec.options = { + ...(adapterSpec.options || {}), + env: { + ...process.env, + DISASMER_COORDINATOR_BIN: path.join( + repo, + "target", + "debug", + `disasmer-coordinator${executableSuffix}` + ), + DISASMER_NODE_BIN: path.join( + repo, + "target", + "debug", + `disasmer-node${executableSuffix}` + ), + }, + }; + + const client = new DapClient(adapterSpec); + try { + const initialize = client.send("initialize", { + adapterID: "disasmer", + linesStartAt1: true, + columnsStartAt1: true + }); + await client.response(initialize, "initialize"); + + const launch = client.send("launch", launchConfig); + await client.response(launch, "launch"); + await client.waitFor( + (message) => message.type === "event" && message.event === "initialized" + ); + + const breakpoints = client.send("setBreakpoints", { + source: { path: path.join(project, "src/build.rs") }, + breakpoints: [{ line: buildMainLine }] + }); + const breakpointResponse = await client.response(breakpoints, "setBreakpoints"); + assert.strictEqual(breakpointResponse.body.breakpoints[0].verified, true); + + const configurationDone = client.send("configurationDone"); + await client.response(configurationDone, "configurationDone"); + const stopped = await client.waitFor( + (message) => message.type === "event" && message.event === "stopped" + ); + assert.strictEqual(stopped.body.allThreadsStopped, true); + assert.strictEqual(stopped.body.reason, "breakpoint"); + + const threadsRequest = client.send("threads"); + const threads = (await client.response(threadsRequest, "threads")).body.threads; + const mainThread = threads.find((thread) => thread.name.includes("build virtual process")); + assert(mainThread, "F5 launch must expose the real entrypoint as a virtual thread"); + + const stackRequest = client.send("stackTrace", { + threadId: mainThread.id, + startFrame: 0, + levels: 1 + }); + const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames; + assert.strictEqual(stack[0].line, buildMainLine); + assert.strictEqual(stack[0].source.path, path.join(project, "src/build.rs")); + assert.strictEqual(stack[0].source.sourceReference || 0, 0); + + const sourceRequest = client.send("source", { source: stack[0].source }); + const source = (await client.response(sourceRequest, "source")).body; + assert.match(source.content, /compile_linux/); + + const scopesRequest = client.send("scopes", { frameId: stack[0].id }); + const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; + const localsScope = scopes.find((scope) => scope.name === "Source Locals"); + const argsScope = scopes.find((scope) => scope.name === "Task Args and Handles"); + const runtimeScope = scopes.find((scope) => scope.name === "Disasmer Runtime"); + const outputScope = scopes.find((scope) => scope.name === "Recent Output"); + assert(localsScope, "F5 launch must expose source locals scope"); + assert(argsScope, "F5 launch must expose task args and handles"); + assert(runtimeScope, "F5 launch must expose Disasmer runtime state"); + assert(outputScope, "F5 launch must expose recent output state"); + + const localsRequest = client.send("variables", { + variablesReference: localsScope.variablesReference + }); + const locals = (await client.response(localsRequest, "variables")).body.variables; + assert( + locals.some( + (variable) => + variable.name === "unavailable-local-diagnostic" && + String(variable.value).includes("cannot be inspected") + ), + "source locals scope must report unavailable real Rust locals explicitly" + ); + + const runtimeRequest = client.send("variables", { + variablesReference: runtimeScope.variablesReference + }); + const runtime = (await client.response(runtimeRequest, "variables")).body.variables; + assert( + runtime.some( + (variable) => variable.name === "runtime_backend" && variable.value === "LocalServices" + ), + "extension-resolved F5 launch must use the real local-services backend" + ); + assert( + runtime.some( + (variable) => variable.name === "coordinator_task_events" && variable.value === 0 + ), + "a task frozen at its entry probe must not fabricate a terminal task event" + ); + assert( + runtime.some( + (variable) => + variable.name === "command_status" && + String(variable.value).includes( + "frozen through local services at executing Wasm probe" + ) + ) + ); + assert( + runtime.some( + (variable) => variable.name === "state" && variable.value === "Frozen" + ), + "F5 must expose the node-acknowledged frozen participant state" + ); + assert(runtime.some((variable) => variable.name === "command_spec")); + assert(runtime.some((variable) => variable.name === "stdout_tail")); + assert(runtime.some((variable) => variable.name === "stderr_tail")); + + const outputRequest = client.send("variables", { + variablesReference: outputScope.variablesReference + }); + const output = (await client.response(outputRequest, "variables")).body.variables; + assert(output.some((variable) => variable.name === "stdout_tail")); + assert(output.some((variable) => variable.name === "stderr_tail")); + + await client.close(); + } catch (error) { + client.terminate(); + throw error; + } + + console.log("VS Code F5 smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/wasmtime-assignment-smoke.js b/scripts/wasmtime-assignment-smoke.js new file mode 100644 index 0000000..e4adb3d --- /dev/null +++ b/scripts/wasmtime-assignment-smoke.js @@ -0,0 +1,1313 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { nodeIdentity } = require("./node-signing"); +const { configurePodmanTestEnvironment } = require("./podman-test-env"); + +const repo = path.resolve(__dirname, ".."); +const project = path.join(repo, "examples/launch-build-demo"); +const bundleDirectory = path.join(repo, "target/acceptance/sdk-runtime-bundle"); +const editedBundleDirectory = path.join( + repo, + "target/acceptance/sdk-runtime-edited-bundle" +); +const incompatibleBundleDirectory = path.join( + repo, + "target/acceptance/sdk-runtime-incompatible-bundle" +); +const wasmPath = path.join(bundleDirectory, "module.wasm"); +const projectSourcePath = path.join(project, "src/build.rs"); +const node = "wasmtime-assignment-node"; +const identity = nodeIdentity("wasmtime-assignment-smoke", node); +const trapNode = "wasmtime-trap-node"; +const trapIdentity = nodeIdentity("wasmtime-assignment-smoke", trapNode); +const clientSessionSecret = "wasmtime-assignment-client-session-secret"; + +class JsonLineReader { + constructor(stream) { + this.buffer = ""; + this.messages = []; + this.waiters = []; + stream.on("data", (chunk) => { + this.buffer += chunk.toString(); + while (this.buffer.includes("\n")) { + const newline = this.buffer.indexOf("\n"); + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (line) this.messages.push(JSON.parse(line)); + } + this.flush(); + }); + } + + next(timeoutMs = 240000, label = "JSON line") { + if (this.messages.length > 0) return Promise.resolve(this.messages.shift()); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((waiter) => waiter.resolve !== resolve); + reject(new Error(`timed out waiting for ${label}`)); + }, timeoutMs); + this.waiters.push({ resolve, timer }); + }); + } + + flush() { + while (this.messages.length > 0 && this.waiters.length > 0) { + const waiter = this.waiters.shift(); + clearTimeout(waiter.timer); + waiter.resolve(this.messages.shift()); + } + } +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`timed out waiting for ${message.type}`)); + }, 30000); + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + clearTimeout(timer); + socket.destroy(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function authenticated(request) { + return { + type: "authenticated", + session_secret: clientSessionSecret, + request, + }; +} + +function stopChild(child) { + if (!child || child.exitCode !== null) return; + child.kill("SIGTERM"); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function commandWithPodman(program, args) { + if (cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status === 0) { + return { program, args }; + } + if (cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0) { + return { + program: "nix", + args: ["shell", "nixpkgs#podman", "--command", program, ...args], + }; + } + throw new Error( + "Wasmtime assignment smoke requires rootless Podman (or Nix to provide it)" + ); +} + +function procChildren(pid) { + try { + return fs + .readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8") + .trim() + .split(/\s+/) + .filter(Boolean) + .map(Number); + } catch (_) { + return []; + } +} + +function procDescendants(rootPid) { + const pending = [rootPid]; + const descendants = []; + while (pending.length > 0) { + const parent = pending.shift(); + for (const child of procChildren(parent)) { + descendants.push(child); + pending.push(child); + } + } + return descendants; +} + +function procCommand(pid) { + try { + return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " "); + } catch (_) { + return ""; + } +} + +function procState(pid) { + const status = fs.readFileSync(`/proc/${pid}/status`, "utf8"); + return status.match(/^State:\s+([^\n]+)$/m)?.[1] || "unknown"; +} + +async function waitForNativeSleep(rootPid) { + for (let attempt = 0; attempt < 2400; attempt += 1) { + const sleep = procDescendants(rootPid).find((pid) => + /(?:^|\s)sleep\s+30(?:\s|$)/.test(procCommand(pid)) + ); + if (sleep) return sleep; + await delay(25); + } + throw new Error(`worker ${rootPid} did not start the abort probe's native sleep process`); +} + +async function waitForDebugEpochState(addr, process, epoch, field) { + for (let attempt = 0; attempt < 1200; attempt += 1) { + const status = await send(addr, authenticated({ + type: "inspect_debug_epoch", + process, + epoch, + })); + assert.strictEqual(status.type, "debug_epoch_status", JSON.stringify(status)); + if (status.failed) { + throw new Error( + `debug epoch ${epoch} failed: ${status.failure_messages.join("; ")}` + ); + } + if (status[field]) return status; + await delay(25); + } + throw new Error(`timed out waiting for debug epoch ${epoch} ${field}`); +} + +async function waitForBreakpointHit(addr, process) { + for (let attempt = 0; attempt < 1200; attempt += 1) { + const status = await send(addr, authenticated({ + type: "inspect_debug_breakpoints", + process, + })); + assert.strictEqual(status.type, "debug_breakpoints", JSON.stringify(status)); + if (status.hit_epoch != null) return status; + await delay(25); + } + throw new Error(`timed out waiting for executing Wasm breakpoint in ${process}`); +} + +(async () => { + configurePodmanTestEnvironment(repo); + const build = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "build", + "--project", + project, + "--output", + bundleDirectory, + "--json", + ], + { cwd: repo, encoding: "utf8" } + ) + ); + assert.strictEqual(build.status, "built"); + assert.strictEqual(build.bundle_artifact.module, wasmPath); + assert(build.bundle_artifact.task_descriptor_count >= 2); + const wasm = fs.readFileSync(wasmPath); + const wasmModuleBase64 = wasm.toString("base64"); + const bundleDigest = `sha256:${crypto.createHash("sha256").update(wasm).digest("hex")}`; + assert.strictEqual(build.bundle_artifact.bundle_digest, bundleDigest); + const linuxEnvironment = build.bundle.metadata.environments.find( + (environment) => environment.name === "linux" + ); + assert(linuxEnvironment, "build bundle must declare the Linux environment"); + + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + ], + { + cwd: repo, + env: { + ...process.env, + DISASMER_SELF_HOSTED_SESSION_SECRET: clientSessionSecret, + DISASMER_SELF_HOSTED_TENANT: "tenant", + DISASMER_SELF_HOSTED_PROJECT: "project", + DISASMER_SELF_HOSTED_USER: "user", + }, + } + ); + const coordinatorLines = new JsonLineReader(coordinator.stdout); + let worker; + let projectSourceBeforeEdit; + let coordinatorStderr = ""; + let workerStderr = ""; + coordinator.stderr.on("data", (chunk) => { + coordinatorStderr += chunk.toString(); + }); + + try { + const ready = await coordinatorLines.next(); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual(ready.client_authority, "strict"); + assert.strictEqual(ready.self_hosted_session_bootstrapped, true); + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const grant = await send(addr, authenticated({ + type: "create_node_enrollment_grant", + ttl_seconds: 900, + })); + assert.strictEqual( + grant.type, + "node_enrollment_grant_created", + JSON.stringify(grant) + ); + + const initialWorkerInvocation = commandWithPodman("cargo", [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-node", + "--", + "--coordinator", + ready.listen, + "--tenant", + "tenant", + "--project-id", + "project", + "--node", + node, + "--public-key", + identity.publicKey, + "--enrollment-grant", + grant.grant, + "--worker", + "--assignment-poll-ms", + "25", + "--emit-ready", + "--project-root", + project, + ]); + worker = cp.spawn( + initialWorkerInvocation.program, + initialWorkerInvocation.args, + { + cwd: repo, + env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: identity.privateKey }, + } + ); + worker.stderr.on("data", (chunk) => { + workerStderr += chunk.toString(); + }); + const workerLines = new JsonLineReader(worker.stdout); + const workerReady = await workerLines.next(); + assert.strictEqual(workerReady.node_status, "ready"); + assert.strictEqual(workerReady.node, node); + + const started = await send(addr, authenticated({ + type: "start_process", + process: "vp-wasmtime-assignment", + restart: false, + })); + assert.strictEqual(started.type, "process_started"); + + const sdkRun = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "launch-build-demo", + "--bin", + "sdk-product-runtime", + ], + { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + DISASMER_SDK_COORDINATOR: ready.listen, + DISASMER_SDK_TENANT: "tenant", + DISASMER_SDK_PROJECT: "project", + DISASMER_SDK_PROCESS: "vp-wasmtime-assignment", + DISASMER_SDK_USER: "user", + DISASMER_SDK_SESSION_SECRET: clientSessionSecret, + DISASMER_SDK_BUNDLE_DIGEST: bundleDigest, + DISASMER_SDK_WASM_MODULE_PATH: wasmPath, + DISASMER_SDK_VFS_EPOCH: String(started.epoch), + }, + } + ) + ); + assert.strictEqual(sdkRun.kind, "disasmer-sdk-product-runtime"); + assert.strictEqual(sdkRun.remote_result, 42); + assert.strictEqual(sdkRun.local_function_invoked_by_spawn, false); + assert.strictEqual(sdkRun.task_spec.task_definition, "task_add_one"); + assert.match(sdkRun.task_spec.task_instance, /^ti:vp-wasmtime-assignment:\d+$/); + assert.strictEqual(sdkRun.task_spec.environment_id, "linux"); + assert.strictEqual(sdkRun.task_spec.vfs_epoch, started.epoch); + assert.strictEqual(sdkRun.task_spec.bundle_digest, bundleDigest); + assert.strictEqual( + sdkRun.task_spec.dispatch.kind, + "coordinator_node_wasm" + ); + assert.strictEqual(sdkRun.task_spec.dispatch.export, null); + assert.strictEqual(sdkRun.task_spec.dispatch.abi, "task_v1"); + assert.deepStrictEqual(sdkRun.task_spec.args, [{ SmallJson: 41 }]); + + const nodeRun = await workerLines.next(); + assert.strictEqual(nodeRun.node_status, "completed"); + assert.strictEqual(nodeRun.status_code, 0); + assert.strictEqual(nodeRun.stdout_tail, '{"SmallJson":42}\n'); + assert.strictEqual(nodeRun.staged_artifact, null); + assert.strictEqual(nodeRun.large_bytes_uploaded, false); + assert.strictEqual( + nodeRun.task_assignment_response.task, + sdkRun.task_spec.task_instance + ); + assert.strictEqual( + nodeRun.task_assignment_response.task_spec.dispatch.abi, + "task_v1" + ); + assert.strictEqual(nodeRun.vfs_metadata_response.type, "vfs_metadata_recorded"); + assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded"); + + const events = await send(addr, authenticated({ + type: "list_task_events", + process: "vp-wasmtime-assignment", + })); + assert.strictEqual(events.type, "task_events"); + assert.strictEqual(events.events.length, 1); + assert.strictEqual(events.events[0].task, sdkRun.task_spec.task_instance); + assert.strictEqual(events.events[0].stdout_tail, '{"SmallJson":42}\n'); + assert.deepStrictEqual(events.events[0].result, { SmallJson: 42 }); + + // Prove the edited-task restart boundary with a real Rust source edit and + // real Wasm rebuild. The first execution uses the original bundle, then the + // coordinator accepts a compatible replacement bundle, preserves the + // original argument envelope, assigns a fresh task-instance ID, and the + // same node executes the changed code from the clean task-entry checkpoint. + const editableLaunch = await send(addr, authenticated({ + type: "launch_task", + task_spec: { + tenant: "tenant", + project: "project", + process: "vp-wasmtime-assignment", + task_definition: "task_add_one", + task_instance: "task_add_one-editable-1", + dispatch: { + kind: "coordinator_node_wasm", + export: null, + abi: "task_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: [], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [{ SmallJson: 41 }], + vfs_epoch: started.epoch, + bundle_digest: bundleDigest, + }, + wait_for_node: false, + artifact_path: "/vfs/artifacts/task-add-one-editable.json", + wasm_module_base64: wasmModuleBase64, + })); + assert.strictEqual(editableLaunch.type, "task_launched", JSON.stringify(editableLaunch)); + const initialEditableRun = await workerLines.next( + 60000, + "initial editable task completion" + ); + assert.strictEqual(initialEditableRun.node_status, "completed"); + assert.strictEqual(initialEditableRun.task_assignment_response.task, "task_add_one-editable-1"); + assert.strictEqual(initialEditableRun.stdout_tail, '{"SmallJson":42}\n'); + + const initialEditableJoin = await send(addr, authenticated({ + type: "join_task", + process: "vp-wasmtime-assignment", + task: "task_add_one-editable-1", + })); + assert.strictEqual(initialEditableJoin.type, "task_joined"); + assert.strictEqual(initialEditableJoin.join.state, "completed"); + assert.deepStrictEqual(initialEditableJoin.join.result, { SmallJson: 42 }); + + projectSourceBeforeEdit = fs.readFileSync(projectSourcePath, "utf8"); + const originalBody = "pub extern \"C\" fn task_add_one(input: i32) -> i32 {\n input + 1\n}"; + const editedBody = + "pub extern \"C\" fn task_add_one(input: i32) -> i32 {\n" + + " input + 2 // edited-task restart integration probe\n" + + "}"; + assert( + projectSourceBeforeEdit.includes(originalBody), + "flagship task_add_one source must retain the expected editable body" + ); + fs.writeFileSync( + projectSourcePath, + projectSourceBeforeEdit.replace(originalBody, editedBody) + ); + + let editedBuild; + try { + editedBuild = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "build", + "--project", + project, + "--output", + editedBundleDirectory, + "--json", + ], + { cwd: repo, encoding: "utf8" } + ) + ); + } finally { + fs.writeFileSync(projectSourcePath, projectSourceBeforeEdit); + projectSourceBeforeEdit = undefined; + } + assert.strictEqual(editedBuild.status, "built"); + const editedWasmPath = path.join(editedBundleDirectory, "module.wasm"); + assert.strictEqual(editedBuild.bundle_artifact.module, editedWasmPath); + const editedWasm = fs.readFileSync(editedWasmPath); + const editedBundleDigest = `sha256:${crypto + .createHash("sha256") + .update(editedWasm) + .digest("hex")}`; + assert.strictEqual(editedBuild.bundle_artifact.bundle_digest, editedBundleDigest); + assert.notStrictEqual( + editedBundleDigest, + bundleDigest, + "the real Rust source edit must produce changed Wasm bytes" + ); + + const originalTaskDescriptor = JSON.parse( + fs.readFileSync(path.join(bundleDirectory, "task-descriptors.json"), "utf8") + ).find((descriptor) => descriptor.name === "task_add_one"); + const editedTaskDescriptor = JSON.parse( + fs.readFileSync(path.join(editedBundleDirectory, "task-descriptors.json"), "utf8") + ).find((descriptor) => descriptor.name === "task_add_one"); + assert(originalTaskDescriptor, "original bundle must describe task_add_one"); + assert(editedTaskDescriptor, "edited bundle must describe task_add_one"); + assert.strictEqual(editedTaskDescriptor.stable_id, originalTaskDescriptor.stable_id); + assert.strictEqual( + editedTaskDescriptor.restart_compatibility_hash, + originalTaskDescriptor.restart_compatibility_hash, + "body-only edit must remain restart-compatible" + ); + + const editedRestart = await send(addr, authenticated({ + type: "restart_task", + process: "vp-wasmtime-assignment", + task: "task_add_one-editable-1", + replacement_bundle: { + bundle_digest: editedBundleDigest, + wasm_module_base64: editedWasm.toString("base64"), + }, + })); + assert.strictEqual(editedRestart.type, "task_restart", JSON.stringify(editedRestart)); + assert.strictEqual(editedRestart.accepted, true); + assert.strictEqual(editedRestart.clean_boundary_available, true); + assert.strictEqual(editedRestart.active_task, false); + assert.strictEqual(editedRestart.completed_event_observed, true); + assert.strictEqual(editedRestart.requires_whole_process_restart, false); + assert.match(editedRestart.restarted_task_instance, /^ti_/); + assert.notStrictEqual(editedRestart.restarted_task_instance, "task_add_one-editable-1"); + + const editedNodeRun = await workerLines.next( + 60000, + "edited replacement task completion" + ); + assert.strictEqual(editedNodeRun.node_status, "completed"); + assert.strictEqual( + editedNodeRun.task_assignment_response.task, + editedRestart.restarted_task_instance + ); + assert.strictEqual( + editedNodeRun.task_assignment_response.task_spec.bundle_digest, + editedBundleDigest + ); + assert.deepStrictEqual( + editedNodeRun.task_assignment_response.task_spec.args, + [{ SmallJson: 41 }], + "edited restart must preserve the original serialized argument envelope" + ); + assert.strictEqual( + editedNodeRun.task_assignment_response.task_spec.vfs_epoch, + started.epoch, + "edited restart must use the original clean VFS entry boundary" + ); + assert.strictEqual( + editedNodeRun.stdout_tail, + '{"SmallJson":43}\n', + "the restarted assignment must execute the edited Rust code" + ); + + const editedJoin = await send(addr, authenticated({ + type: "join_task", + process: "vp-wasmtime-assignment", + task: editedRestart.restarted_task_instance, + })); + assert.strictEqual(editedJoin.type, "task_joined"); + assert.strictEqual(editedJoin.join.state, "completed"); + assert.deepStrictEqual(editedJoin.join.result, { SmallJson: 43 }); + + // A real signature edit must be rejected before a replacement assignment + // is created, while the compatible restarted result remains intact. + projectSourceBeforeEdit = fs.readFileSync(projectSourcePath, "utf8"); + const incompatibleBody = + "pub extern \"C\" fn task_add_one(input: i32) -> i64 {\n" + + " i64::from(input) + 1\n" + + "}"; + assert(projectSourceBeforeEdit.includes(originalBody)); + const restartJoinOriginal = + ' .expect("restart probe child should complete");'; + const restartJoinWithConversion = + ' .expect("restart probe child should complete")\n' + + ' .try_into()\n' + + ' .expect("incompatible fixture result should fit i32");'; + const restartHostOriginal = " task_add_one(41)\n}"; + const restartHostWithConversion = + ' i32::try_from(task_add_one(41))\n' + + ' .expect("incompatible fixture result should fit i32")\n' + + "}"; + assert(projectSourceBeforeEdit.includes(restartJoinOriginal)); + assert(projectSourceBeforeEdit.includes(restartHostOriginal)); + const incompatibleSource = projectSourceBeforeEdit + .replace(originalBody, incompatibleBody) + .replace(restartJoinOriginal, restartJoinWithConversion) + .replace(restartHostOriginal, restartHostWithConversion); + fs.writeFileSync( + projectSourcePath, + incompatibleSource + ); + let incompatibleBuild; + try { + incompatibleBuild = JSON.parse( + cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "build", + "--project", + project, + "--output", + incompatibleBundleDirectory, + "--json", + ], + { cwd: repo, encoding: "utf8" } + ) + ); + } finally { + fs.writeFileSync(projectSourcePath, projectSourceBeforeEdit); + projectSourceBeforeEdit = undefined; + } + assert.strictEqual(incompatibleBuild.status, "built"); + const incompatibleWasmPath = path.join(incompatibleBundleDirectory, "module.wasm"); + const incompatibleWasm = fs.readFileSync(incompatibleWasmPath); + const incompatibleBundleDigest = `sha256:${crypto + .createHash("sha256") + .update(incompatibleWasm) + .digest("hex")}`; + assert.strictEqual( + incompatibleBuild.bundle_artifact.bundle_digest, + incompatibleBundleDigest + ); + const incompatibleTaskDescriptor = JSON.parse( + fs.readFileSync( + path.join(incompatibleBundleDirectory, "task-descriptors.json"), + "utf8" + ) + ).find((descriptor) => descriptor.name === "task_add_one"); + assert(incompatibleTaskDescriptor, "incompatible bundle must still name task_add_one"); + assert.notStrictEqual( + incompatibleTaskDescriptor.restart_compatibility_hash, + originalTaskDescriptor.restart_compatibility_hash, + "signature edit must change restart compatibility metadata" + ); + + const incompatibleRestart = await send(addr, authenticated({ + type: "restart_task", + process: "vp-wasmtime-assignment", + task: "task_add_one-editable-1", + replacement_bundle: { + bundle_digest: incompatibleBundleDigest, + wasm_module_base64: incompatibleWasm.toString("base64"), + }, + })); + assert.strictEqual( + incompatibleRestart.type, + "task_restart", + JSON.stringify(incompatibleRestart) + ); + assert.strictEqual(incompatibleRestart.accepted, false); + assert.strictEqual(incompatibleRestart.requires_whole_process_restart, true); + assert.strictEqual(incompatibleRestart.restarted_task_instance, null); + assert.match( + incompatibleRestart.message, + /task (?:entrypoint|ABI) changed/, + "signature edit must explain which restart-compatibility boundary changed" + ); + + const preservedEditedJoin = await send(addr, authenticated({ + type: "join_task", + process: "vp-wasmtime-assignment", + task: editedRestart.restarted_task_instance, + })); + assert.strictEqual(preservedEditedJoin.type, "task_joined"); + assert.strictEqual(preservedEditedJoin.join.state, "completed"); + assert.deepStrictEqual( + preservedEditedJoin.join.result, + { SmallJson: 43 }, + "incompatible edit denial must not corrupt the compatible restarted result" + ); + + const retiredFirstWorker = await send(addr, authenticated({ + type: "revoke_node_credential", + node, + })); + assert.strictEqual(retiredFirstWorker.type, "node_credential_revoked"); + stopChild(worker); + await delay(100); + + const trapGrant = await send(addr, authenticated({ + type: "create_node_enrollment_grant", + ttl_seconds: 900, + })); + assert.strictEqual(trapGrant.type, "node_enrollment_grant_created"); + const trapWorkerInvocation = commandWithPodman("cargo", [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-node", + "--", + "--coordinator", + ready.listen, + "--tenant", + "tenant", + "--project-id", + "project", + "--node", + trapNode, + "--public-key", + trapIdentity.publicKey, + "--enrollment-grant", + trapGrant.grant, + "--worker", + "--assignment-poll-ms", + "25", + "--emit-ready", + "--project-root", + project, + ]); + worker = cp.spawn( + trapWorkerInvocation.program, + trapWorkerInvocation.args, + { + cwd: repo, + env: { + ...process.env, + DISASMER_NODE_PRIVATE_KEY: trapIdentity.privateKey, + }, + } + ); + worker.stderr.on("data", (chunk) => { + workerStderr += chunk.toString(); + }); + const trapWorkerLines = new JsonLineReader(worker.stdout); + const trapWorkerReady = await trapWorkerLines.next(); + assert.strictEqual(trapWorkerReady.node_status, "ready"); + assert.strictEqual(trapWorkerReady.node, trapNode); + const nodeDescriptors = await send(addr, authenticated({ + type: "list_node_descriptors", + })); + assert.strictEqual( + nodeDescriptors.type, + "node_descriptors", + JSON.stringify(nodeDescriptors) + ); + const trapNodeDescriptor = nodeDescriptors.descriptors.find( + (descriptor) => descriptor.id === trapNode + ); + assert(trapNodeDescriptor, "trap worker descriptor must be visible"); + assert.deepStrictEqual( + trapNodeDescriptor.source_snapshots.length, + 1, + "trap worker must report its authoritative checkout snapshot" + ); + const sourceSnapshot = trapNodeDescriptor.source_snapshots[0]; + + const trapLaunch = await send(addr, authenticated({ + type: "launch_task", + task_spec: { + tenant: "tenant", + project: "project", + process: "vp-wasmtime-assignment", + task_definition: "task_trap", + task_instance: "task_trap-1", + dispatch: { + kind: "coordinator_node_wasm", + export: "task_trap", + abi: "task_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: [], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [{ SmallJson: 0 }], + vfs_epoch: started.epoch, + bundle_digest: bundleDigest, + }, + wait_for_node: false, + artifact_path: "/vfs/artifacts/wasmtime-trap.txt", + wasm_module_base64: wasmModuleBase64, + })); + assert.strictEqual(trapLaunch.type, "task_launched", JSON.stringify(trapLaunch)); + const trapNodeRun = await trapWorkerLines.next(); + assert.strictEqual(trapNodeRun.node_status, "failed"); + assert.strictEqual(trapNodeRun.terminal_state, "failed"); + assert.match(trapNodeRun.stderr_tail, /wasmtime task failed|unreachable|trap/i); + assert.strictEqual(trapNodeRun.log_event_response.type, "task_log_recorded"); + assert.strictEqual(trapNodeRun.coordinator_response.type, "task_recorded"); + + const trapJoin = await send(addr, authenticated({ + type: "join_task", + process: "vp-wasmtime-assignment", + task: "task_trap-1", + })); + assert.strictEqual(trapJoin.type, "task_joined"); + assert.strictEqual(trapJoin.join.state, "failed"); + assert.strictEqual(trapJoin.join.remote_completion_observed, true); + assert.match(trapJoin.join.message, /wasmtime task failed|unreachable|trap/i); + + const trapRestart = await send(addr, authenticated({ + type: "restart_task", + process: "vp-wasmtime-assignment", + task: "task_trap-1", + })); + assert.strictEqual(trapRestart.type, "task_restart", JSON.stringify(trapRestart)); + assert.strictEqual(trapRestart.accepted, true); + assert.strictEqual(trapRestart.clean_boundary_available, true); + assert.strictEqual(trapRestart.active_task, false); + assert.strictEqual(trapRestart.completed_event_observed, true); + assert.strictEqual(trapRestart.requires_whole_process_restart, false); + assert.match(trapRestart.restarted_task_instance, /^ti_/); + assert.match(trapRestart.message, /clean VFS entry boundary epoch/); + const restartedTrapNodeRun = await trapWorkerLines.next( + 60000, + "restarted trap task completion" + ); + assert.strictEqual(restartedTrapNodeRun.node_status, "failed"); + assert.strictEqual(restartedTrapNodeRun.terminal_state, "failed"); + assert.strictEqual( + restartedTrapNodeRun.task_assignment_response.task, + trapRestart.restarted_task_instance + ); + + const configuredBreakpoints = await send(addr, authenticated({ + type: "set_debug_breakpoints", + process: "vp-wasmtime-assignment", + probe_symbols: ["disasmer.probe.cooperative_cancellation_probe"], + })); + assert.strictEqual(configuredBreakpoints.type, "debug_breakpoints"); + assert.deepStrictEqual(configuredBreakpoints.probe_symbols, [ + "disasmer.probe.cooperative_cancellation_probe", + ]); + const cancellationLaunch = await send(addr, authenticated({ + type: "launch_task", + task_spec: { + tenant: "tenant", + project: "project", + process: "vp-wasmtime-assignment", + task_definition: "cooperative_cancellation_probe", + task_instance: "cooperative_cancellation_probe-1", + dispatch: { + kind: "coordinator_node_wasm", + export: null, + abi: "task_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: [], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [], + vfs_epoch: started.epoch, + bundle_digest: bundleDigest, + }, + wait_for_node: false, + artifact_path: "/vfs/artifacts/cooperative-cancellation-probe.txt", + wasm_module_base64: wasmModuleBase64, + })); + assert.strictEqual(cancellationLaunch.type, "task_launched"); + const breakpointHit = await waitForBreakpointHit( + addr, + "vp-wasmtime-assignment" + ); + assert.strictEqual( + breakpointHit.hit_probe_symbol, + "disasmer.probe.cooperative_cancellation_probe" + ); + assert.strictEqual( + breakpointHit.hit_task, + "cooperative_cancellation_probe-1" + ); + const debugEpoch = { epoch: breakpointHit.hit_epoch }; + const frozenDebugEpoch = await waitForDebugEpochState( + addr, + "vp-wasmtime-assignment", + debugEpoch.epoch, + "fully_frozen" + ); + assert.strictEqual(frozenDebugEpoch.acknowledgements.length, 1); + assert.strictEqual(frozenDebugEpoch.acknowledgements[0].state, "frozen"); + assert.strictEqual( + frozenDebugEpoch.acknowledgements[0].task, + "cooperative_cancellation_probe-1" + ); + const debugResume = await send(addr, authenticated({ + type: "resume_debug_epoch", + process: "vp-wasmtime-assignment", + epoch: debugEpoch.epoch, + })); + assert.strictEqual(debugResume.type, "debug_epoch"); + assert.strictEqual(debugResume.command, "resume"); + const resumedDebugEpoch = await waitForDebugEpochState( + addr, + "vp-wasmtime-assignment", + debugEpoch.epoch, + "fully_resumed" + ); + assert.strictEqual(resumedDebugEpoch.acknowledgements.length, 1); + assert.strictEqual(resumedDebugEpoch.acknowledgements[0].state, "running"); + const cancellationRequested = await send(addr, authenticated({ + type: "cancel_process", + process: "vp-wasmtime-assignment", + })); + assert.strictEqual( + cancellationRequested.type, + "process_cancellation_requested" + ); + assert.strictEqual(cancellationRequested.cancelled_tasks.length, 1); + assert.strictEqual( + cancellationRequested.cancelled_tasks[0].task, + "cooperative_cancellation_probe-1" + ); + const cancellationNodeRun = await trapWorkerLines.next( + 10000, + "cooperative cancellation task completion" + ); + assert.strictEqual(cancellationNodeRun.node_status, "completed"); + assert.strictEqual(cancellationNodeRun.terminal_state, "completed"); + const cancellationJoin = await send(addr, authenticated({ + type: "join_task", + process: "vp-wasmtime-assignment", + task: "cooperative_cancellation_probe-1", + })); + assert.strictEqual(cancellationJoin.type, "task_joined"); + assert.strictEqual(cancellationJoin.join.state, "completed"); + assert.deepStrictEqual(cancellationJoin.join.result, { SmallJson: 17 }); + + // Cooperative cancellation leaves process cleanup to the process code. End + // this test process explicitly before starting the forced-abort scenario. + const cancelledProcessCleanup = await send(addr, authenticated({ + type: "abort_process", + process: "vp-wasmtime-assignment", + })); + assert.strictEqual(cancelledProcessCleanup.type, "process_aborted"); + assert.deepStrictEqual(cancelledProcessCleanup.aborted_tasks, []); + + const multiParticipantProcess = await send(addr, authenticated({ + type: "start_process", + process: "vp-wasmtime-multi-debug", + restart: false, + })); + assert.strictEqual(multiParticipantProcess.type, "process_started"); + const multiParticipantLaunch = await send(addr, authenticated({ + type: "launch_task", + task_spec: { + tenant: "tenant", + project: "project", + process: multiParticipantProcess.process, + task_definition: "debug_parent_probe", + task_instance: "debug_parent_probe-1", + dispatch: { + kind: "coordinator_node_wasm", + export: null, + abi: "task_v1", + }, + environment_id: null, + environment: null, + environment_digest: null, + required_capabilities: [], + dependency_cache: null, + source_snapshot: null, + required_artifacts: [], + args: [], + vfs_epoch: multiParticipantProcess.epoch, + bundle_digest: bundleDigest, + }, + wait_for_node: false, + artifact_path: "/vfs/artifacts/debug-parent-probe.txt", + wasm_module_base64: wasmModuleBase64, + })); + assert.strictEqual(multiParticipantLaunch.type, "task_launched"); + const debugChildInstance = "debug_parent_probe-1:child:1"; + await delay(1000); + let multiParticipantFreeze; + let multiParticipantFrozen; + for (let attempt = 0; attempt < 20; attempt += 1) { + multiParticipantFreeze = await send(addr, authenticated({ + type: "create_debug_epoch", + process: multiParticipantProcess.process, + stopped_task: debugChildInstance, + reason: "prove all-stop across active parent and child Wasm participants", + })); + assert.strictEqual( + multiParticipantFreeze.type, + "debug_epoch", + JSON.stringify(multiParticipantFreeze) + ); + multiParticipantFrozen = await waitForDebugEpochState( + addr, + multiParticipantProcess.process, + multiParticipantFreeze.epoch, + "fully_frozen" + ); + if (multiParticipantFreeze.affected_tasks.length === 2) break; + const earlyResume = await send(addr, authenticated({ + type: "resume_debug_epoch", + process: multiParticipantProcess.process, + epoch: multiParticipantFreeze.epoch, + })); + assert.strictEqual(earlyResume.type, "debug_epoch"); + await waitForDebugEpochState( + addr, + multiParticipantProcess.process, + multiParticipantFreeze.epoch, + "fully_resumed" + ); + await delay(100); + } + assert.strictEqual(multiParticipantFreeze.affected_tasks.length, 2); + assert.strictEqual(multiParticipantFrozen.acknowledgements.length, 2); + assert.deepStrictEqual( + multiParticipantFrozen.acknowledgements + .map((acknowledgement) => acknowledgement.task) + .sort(), + [debugChildInstance, "debug_parent_probe-1"].sort() + ); + const multiParticipantResume = await send(addr, authenticated({ + type: "resume_debug_epoch", + process: multiParticipantProcess.process, + epoch: multiParticipantFreeze.epoch, + })); + assert.strictEqual(multiParticipantResume.type, "debug_epoch"); + const multiParticipantResumed = await waitForDebugEpochState( + addr, + multiParticipantProcess.process, + multiParticipantFreeze.epoch, + "fully_resumed" + ); + assert.strictEqual(multiParticipantResumed.acknowledgements.length, 2); + const multiParticipantCancellation = await send(addr, authenticated({ + type: "cancel_process", + process: multiParticipantProcess.process, + })); + assert.strictEqual( + multiParticipantCancellation.type, + "process_cancellation_requested" + ); + assert.strictEqual(multiParticipantCancellation.cancelled_tasks.length, 2); + const multiParticipantNodeRun = await trapWorkerLines.next( + 10000, + "multi-participant parent completion" + ); + assert.strictEqual(multiParticipantNodeRun.node_status, "completed"); + const multiParticipantJoin = await send(addr, authenticated({ + type: "join_task", + process: multiParticipantProcess.process, + task: "debug_parent_probe-1", + })); + assert.strictEqual(multiParticipantJoin.type, "task_joined"); + assert.strictEqual(multiParticipantJoin.join.state, "completed"); + assert.deepStrictEqual(multiParticipantJoin.join.result, { SmallJson: 23 }); + const multiParticipantCleanup = await send(addr, authenticated({ + type: "abort_process", + process: multiParticipantProcess.process, + })); + assert.strictEqual(multiParticipantCleanup.type, "process_aborted"); + assert.deepStrictEqual(multiParticipantCleanup.aborted_tasks, []); + + const abortProcess = await send(addr, authenticated({ + type: "start_process", + process: "vp-wasmtime-abort", + restart: false, + })); + assert.strictEqual(abortProcess.type, "process_started"); + + const abortLaunch = await send(addr, authenticated({ + type: "launch_task", + task_spec: { + tenant: "tenant", + project: "project", + process: abortProcess.process, + task_definition: "abort_probe", + task_instance: "abort_probe-1", + dispatch: { + kind: "coordinator_node_wasm", + export: null, + abi: "task_v1", + }, + environment_id: "linux", + environment: linuxEnvironment.requirements, + environment_digest: linuxEnvironment.digest, + required_capabilities: ["Command"], + dependency_cache: null, + source_snapshot: sourceSnapshot, + required_artifacts: [], + args: [], + vfs_epoch: abortProcess.epoch, + bundle_digest: bundleDigest, + }, + wait_for_node: false, + artifact_path: "/vfs/artifacts/abort-probe.txt", + wasm_module_base64: wasmModuleBase64, + })); + assert.strictEqual(abortLaunch.type, "task_launched"); + assert.strictEqual(process.platform, "linux", "native all-stop proof is the Linux MVP path"); + const nativeSleepPid = await waitForNativeSleep(worker.pid); + const nativeFreeze = await send(addr, authenticated({ + type: "create_debug_epoch", + process: abortProcess.process, + stopped_task: "abort_probe-1", + reason: "freeze while the abort probe native command is running", + })); + assert.strictEqual(nativeFreeze.type, "debug_epoch", JSON.stringify(nativeFreeze)); + assert.strictEqual(nativeFreeze.command, "freeze"); + const nativeFrozen = await waitForDebugEpochState( + addr, + abortProcess.process, + nativeFreeze.epoch, + "fully_frozen" + ); + assert.strictEqual(nativeFrozen.acknowledgements.length, 1); + assert.strictEqual(nativeFrozen.acknowledgements[0].task, "abort_probe-1"); + assert.match(procState(nativeSleepPid), /^T \(stopped\)/); + const nativeResume = await send(addr, authenticated({ + type: "resume_debug_epoch", + process: abortProcess.process, + epoch: nativeFreeze.epoch, + })); + assert.strictEqual(nativeResume.type, "debug_epoch"); + assert.strictEqual(nativeResume.command, "resume"); + const nativeResumed = await waitForDebugEpochState( + addr, + abortProcess.process, + nativeFreeze.epoch, + "fully_resumed" + ); + assert.strictEqual(nativeResumed.acknowledgements.length, 1); + assert.doesNotMatch(procState(nativeSleepPid), /^T \(stopped\)/); + const abortStartedAt = Date.now(); + const aborted = await send(addr, authenticated({ + type: "abort_process", + process: abortProcess.process, + })); + assert.strictEqual(aborted.type, "process_aborted"); + assert.strictEqual(aborted.aborted_tasks.length, 1); + assert.strictEqual(aborted.aborted_tasks[0].task, "abort_probe-1"); + const abortedNodeRun = await trapWorkerLines.next( + 10000, + "forced-abort task completion" + ); + assert.strictEqual( + abortedNodeRun.node_status, + "cancelled", + JSON.stringify(abortedNodeRun) + ); + assert.strictEqual(abortedNodeRun.terminal_state, "cancelled"); + const abortElapsedMs = Date.now() - abortStartedAt; + assert( + abortElapsedMs < 10000, + `abort should stop the running command promptly; observed ${abortElapsedMs}ms` + ); + const afterAbort = await send(addr, authenticated({ type: "list_processes" })); + assert.strictEqual(afterAbort.type, "process_statuses"); + assert.deepStrictEqual(afterAbort.processes, []); + + const report = { + kind: "disasmer-wasmtime-assignment-smoke", + coordinator: ready.listen, + client_authority: ready.client_authority, + process: started.process, + epoch: started.epoch, + task: sdkRun.task_spec.task, + node, + bundle_digest: bundleDigest, + sdk_product_runtime: sdkRun, + assignment_response: nodeRun.task_assignment_response.type, + assignment_wasm_abi: nodeRun.task_assignment_response.task_spec.dispatch.abi, + node_runtime_status: nodeRun.node_status, + node_runtime_stdout: nodeRun.stdout_tail, + vfs_metadata_response: nodeRun.vfs_metadata_response.type, + task_completion_response: nodeRun.coordinator_response.type, + task_events: events.events.length, + large_bytes_uploaded: nodeRun.large_bytes_uploaded, + edited_task_restart: { + source_edit: "task_add_one: input + 1 -> input + 2", + original_bundle_digest: bundleDigest, + replacement_bundle_digest: editedBundleDigest, + original_task_instance: "task_add_one-editable-1", + restarted_task_instance: editedRestart.restarted_task_instance, + original_args: [{ SmallJson: 41 }], + clean_vfs_epoch: started.epoch, + initial_result: initialEditableJoin.join.result, + edited_result: editedJoin.join.result, + incompatible_source_edit: "task_add_one result: i32 -> i64", + incompatible_bundle_digest: incompatibleBundleDigest, + incompatible_requires_whole_process_restart: + incompatibleRestart.requires_whole_process_restart, + preserved_result_after_incompatible_denial: + preservedEditedJoin.join.result, + }, + trapped_task: { + task: trapLaunch.task, + node_status: trapNodeRun.node_status, + terminal_state: trapNodeRun.terminal_state, + log_response: trapNodeRun.log_event_response.type, + completion_response: trapNodeRun.coordinator_response.type, + join_state: trapJoin.join.state, + remote_completion_observed: + trapJoin.join.remote_completion_observed, + checkpoint_restart: { + accepted: trapRestart.accepted, + clean_boundary_available: trapRestart.clean_boundary_available, + requires_whole_process_restart: + trapRestart.requires_whole_process_restart, + rerun_terminal_state: restartedTrapNodeRun.terminal_state, + }, + }, + cooperative_cancellation: { + task: cancellationLaunch.task, + request: cancellationRequested.type, + task_observed_request: true, + terminal_state: cancellationNodeRun.terminal_state, + result: cancellationJoin.join.result, + process_cleanup_was_explicit: true, + }, + debug_epoch: { + epoch: debugEpoch.epoch, + wasm_probe_hit: breakpointHit.hit_probe_symbol, + freeze_acknowledgements: frozenDebugEpoch.acknowledgements.length, + fully_frozen: frozenDebugEpoch.fully_frozen, + resume_acknowledgements: resumedDebugEpoch.acknowledgements.length, + fully_resumed: resumedDebugEpoch.fully_resumed, + }, + native_command_debug_epoch: { + process: abortProcess.process, + task: abortLaunch.task, + native_sleep_pid: nativeSleepPid, + epoch: nativeFreeze.epoch, + freeze_acknowledgements: nativeFrozen.acknowledgements.length, + sleep_process_observed_stopped: true, + resume_acknowledgements: nativeResumed.acknowledgements.length, + sleep_process_observed_resumed: true, + }, + multi_participant_debug_epoch: { + process: multiParticipantProcess.process, + parent_task: multiParticipantLaunch.task, + epoch: multiParticipantFreeze.epoch, + expected_participants: multiParticipantFreeze.affected_tasks.length, + freeze_acknowledgements: + multiParticipantFrozen.acknowledgements.length, + fully_frozen: multiParticipantFrozen.fully_frozen, + resume_acknowledgements: + multiParticipantResumed.acknowledgements.length, + fully_resumed: multiParticipantResumed.fully_resumed, + parent_result: multiParticipantJoin.join.result, + }, + aborted_running_task: { + process: abortProcess.process, + task: abortLaunch.task, + terminal_state: abortedNodeRun.terminal_state, + process_slot_released: afterAbort.processes.length === 0, + elapsed_ms: abortElapsedMs, + }, + }; + const reportPath = path.join(repo, "target", "acceptance", "wasmtime-assignment.json"); + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`Wasmtime assignment smoke passed: ${reportPath}`); + } catch (error) { + if (coordinatorStderr.trim()) console.error(coordinatorStderr); + if (workerStderr.trim()) console.error(workerStderr); + throw error; + } finally { + if (projectSourceBeforeEdit !== undefined) { + fs.writeFileSync(projectSourcePath, projectSourceBeforeEdit); + } + stopChild(worker); + stopChild(coordinator); + } +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/wasmtime-node-smoke.js b/scripts/wasmtime-node-smoke.js new file mode 100644 index 0000000..7da7e74 --- /dev/null +++ b/scripts/wasmtime-node-smoke.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const wasmTarget = path.join( + repo, + "target", + "wasm32-unknown-unknown", + "release", + "launch_build_demo.wasm" +); + +cp.execFileSync( + "cargo", + [ + "build", + "--release", + "-p", + "launch-build-demo", + "--target", + "wasm32-unknown-unknown", + ], + { + cwd: repo, + stdio: "inherit", + env: { + ...process.env, + CARGO_PROFILE_RELEASE_OPT_LEVEL: "z", + CARGO_PROFILE_RELEASE_LTO: "thin", + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "1", + }, + } +); + +assert(fs.existsSync(wasmTarget), `missing compiled Wasm module at ${wasmTarget}`); + +const output = cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-wasmtime-smoke", + "--", + wasmTarget, + "task_add_one", + "41", + "42", + ], + { cwd: repo, encoding: "utf8" } +); + +const report = JSON.parse(output); +assert.strictEqual(report.type, "wasmtime_task_smoke"); +assert.strictEqual(report.export, "task_add_one"); +assert.strictEqual(report.arg, 41); +assert.strictEqual(report.result, 42); + +const debugOutput = cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-wasmtime-smoke", + "--", + "--debug-freeze-resume", + wasmTarget, + "task_add_one", + "41", + "42", + ], + { cwd: repo, encoding: "utf8" } +); +const debugReport = JSON.parse(debugOutput); +assert.strictEqual(debugReport.type, "wasmtime_debug_freeze_resume_smoke"); +assert.strictEqual(debugReport.export, "task_add_one"); +assert.strictEqual(debugReport.task, "task_add_one"); +assert.strictEqual(debugReport.frozen_state, "Frozen"); +assert.strictEqual(debugReport.resumed_state, "Running"); +assert(debugReport.stack_frames.some((frame) => String(frame).includes("task_add_one"))); +assert( + debugReport.local_values.some( + ([name, value]) => name === "wasm_local_0" && String(value).includes("41") + ), + "Wasmtime debug snapshot must expose the real i32 argument as a frame local" +); +assert.strictEqual(debugReport.node_runtime_captured_wasm_locals, true); +assert.strictEqual(debugReport.result, 42); +assert.strictEqual(debugReport.node_runtime_reached_wasm_task, true); + +const hostCommandOutput = cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-wasmtime-smoke", + "--", + "--host-command", + wasmTarget, + "compile_linux", + ], + { cwd: repo, encoding: "utf8" } +); +const hostCommandReport = JSON.parse(hostCommandOutput); +assert.strictEqual(hostCommandReport.type, "wasmtime_host_command_smoke"); +assert.strictEqual(hostCommandReport.task, "compile_linux"); +assert.match(hostCommandReport.export, /^disasmer_task_v1_[0-9a-f]{64}$/); +assert.strictEqual(hostCommandReport.program, "cc"); +assert.deepStrictEqual(hostCommandReport.args, [ + "-Os", + "-static", + "-s", + "fixture/hello-disasmer.c", + "-o", + "/disasmer/output/hello-disasmer", +]); +assert.strictEqual(hostCommandReport.working_directory, "/workspace"); +assert.deepStrictEqual(hostCommandReport.environment_variables, { + SOURCE_DATE_EPOCH: "0", +}); +assert.strictEqual(hostCommandReport.timeout_ms, 180000); +assert.strictEqual(hostCommandReport.network, "disabled"); +assert.strictEqual(hostCommandReport.status_code, 0); +assert.strictEqual(hostCommandReport.stdout, ""); +assert.strictEqual(hostCommandReport.artifact_name, "hello-disasmer"); +assert.strictEqual(hostCommandReport.artifact_size_bytes, "hello-disasmer".length); +assert.match(hostCommandReport.artifact_digest, /^sha256:[0-9a-f]{64}$/); +assert.strictEqual(hostCommandReport.node_host_import, "disasmer.command_run_v1"); +assert.strictEqual(hostCommandReport.artifact_host_import, "disasmer.vfs_operation_v1"); +assert.strictEqual(hostCommandReport.flagship_linux_build_task, true); +assert.strictEqual(hostCommandReport.node_executed_host_command, true); +assert.strictEqual(hostCommandReport.hosted_control_plane_ran_command, false); + +const artifactOutput = cp.execFileSync( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-wasmtime-smoke", + "--", + "--task-artifact", + wasmTarget, + "package_release", + ], + { cwd: repo, encoding: "utf8" } +); +const artifactReport = JSON.parse(artifactOutput); +assert.strictEqual(artifactReport.type, "wasmtime_task_artifact_smoke"); +assert.strictEqual(artifactReport.task, "package_release"); +assert.strictEqual(artifactReport.artifact_name, "release.tar"); +assert.strictEqual(artifactReport.artifact_size_bytes, "release.tar".length); +assert.match(artifactReport.artifact_digest, /^sha256:[0-9a-f]{64}$/); +assert.strictEqual(artifactReport.host_import, "disasmer.vfs_operation_v1"); +assert.strictEqual(artifactReport.host_issued_handle_returned, true); +assert.ok(artifactReport.artifact.id.endsWith(artifactReport.artifact_digest.slice("sha256:".length))); + +console.log("Wasmtime node smoke passed"); diff --git a/scripts/website-inventory-contract-smoke.js b/scripts/website-inventory-contract-smoke.js new file mode 100644 index 0000000..63caf7f --- /dev/null +++ b/scripts/website-inventory-contract-smoke.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const inventoryPath = path.join(repo, "website_mvp_inventory.md"); +if (!fs.existsSync(inventoryPath)) { + if (fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json"))) { + console.log( + "Website inventory contract smoke skipped: website_mvp_inventory.md is filtered from this public tree" + ); + process.exit(0); + } + throw new Error("website_mvp_inventory.md is missing"); +} +const source = fs.readFileSync(inventoryPath, "utf8"); + +function expect(name, pattern) { + assert.match(source, pattern, `missing website inventory evidence: ${name}`); +} + +function criterionLines() { + return source + .split(/\r?\n/) + .filter((line) => /^- \[[ x]\] \*\*/.test(line)); +} + +expect("header", /^# Disasmer MVP Website Acceptance Criteria/m); +expect( + "acceptance-style status", + /\*\*Status:\*\* (?:private )?hosted website addendum to `acceptance_criteria\.md`, `acceptance_criteria_phase2\.md`, and `cli_acceptance_criteria\.md`/ +); +expect( + "hosted signup exception", + /except hosted Authentik account creation/ +); +expect( + "no duplicate work note", + /does not automatically mean new product code, a new feature, or even actual implementation work is required/ +); +expect( + "prove existing behavior before implementation", + /prefer proving, documenting, or tightening that existing behavior over duplicating capability or overengineering a parallel website mechanism/ +); +expect( + "barebones no CSS", + /barebones functional HTML with no CSS/ +); +expect( + "future hosted business scope", + /Billing is not part of the MVP[\s\S]*Paid checkout, paid-plan management, upgrade flows, hosted support tooling, a full hosted admin console, broad moderation workflows/ +); +expect( + "billing is not MVP website work", + /Do not build billing\/upgrade flows into the minimal website for this MVP/ +); +expect( + "billing-only surfaces are postponed", + /If a website route, API, database field, control, or acceptance item exists only for billing, paid plans, hosted business operations, broad admin\/moderation, team management, provider setup, secret management, durable account\/business-process management, or any similarly future hosted-business concern, postpone it instead of building it for this MVP\./ +); +expect( + "billing plan flags are placeholders", + /Design-document references to billing, paid plans, or plan flags are future metadata placeholders; they do not require MVP website routes, database fields, UI controls, CLI commands, coordinator routes, or service logic/ +); +assert.doesNotMatch(source, /community-tier/, "use `community tier`, not `community-tier`"); + +const lines = criterionLines(); +assert(lines.length > 0, "website inventory must contain status-prefixed checklist items"); +for (const line of lines) { + assert.match( + line, + /^- \[[ x]\] \*\*(Passed|Partial|Open|Postponed)(?: \([^)]+\))?:\*\*/, + `website inventory item lacks an explicit status prefix: ${line}` + ); +} + +for (const [name, pattern] of [ + ["CLI login command", /`disasmer login \[--browser\] \[--coordinator \]`/], + ["CLI auth status command", /`disasmer auth status`/], + ["CLI key command", /`disasmer key add --public-key `/], + ["CLI project init command", /`disasmer project init`/], + ["CLI node enroll command", /`disasmer node enroll --coordinator --ttl-seconds `/], + ["CLI process status command", /`disasmer process status`/], + ["CLI task list command", /`disasmer task list`/], + ["CLI DAP command", /`disasmer dap`/], + ["CLI quota command", /`disasmer quota status`/], + ["CLI admin status command", /`disasmer admin status`/], +]) { + expect(name, pattern); +} + +for (const [name, pattern] of [ + ["website default project is open", /- \[ \] \*\*Open:\*\* land in their default project\./], + ["website node attach is open", /- \[ \] \*\*Open:\*\* copy a node attach command\./], + ["website process view is open", /- \[ \] \*\*Open:\*\* see the single current virtual process\./], + ["website logs are open", /- \[ \] \*\*Open:\*\* see recent bounded logs\./], + ["website artifact download is open", /- \[ \] \*\*Open:\*\* securely download an available best-effort retained artifact\./], + ["website keys are partial through CLI", /- \[ \] \*\*Partial:\*\* manage\/revoke public keys or know the CLI command to do so\./], +]) { + expect(name, pattern); +} + +console.log("Website inventory contract smoke passed"); diff --git a/scripts/windows-best-effort-smoke.js b/scripts/windows-best-effort-smoke.js new file mode 100755 index 0000000..dba5865 --- /dev/null +++ b/scripts/windows-best-effort-smoke.js @@ -0,0 +1,302 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { nodeIdentity, signedNodeRequest } = require("./node-signing"); + +const repo = path.resolve(__dirname, ".."); +const digest = (value) => + `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +const environmentDigest = digest("windows-command-dev-environment"); +const sourceDigest = digest("windows-best-effort-source"); +const windowsArtifactBytes = Buffer.from("windows-output-data"); +const windowsArtifactDigest = digest(windowsArtifactBytes); +const emptyWasm = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); +const emptyWasmDigest = digest(emptyWasm); +const nodeDaemon = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/daemon.rs"), "utf8"); +const taskReports = fs.readFileSync( + path.join(repo, "crates/disasmer-node/src/task_reports.rs"), + "utf8" +); +const nodeLib = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/lib.rs"), "utf8"); +const windowsDev = fs.readFileSync( + path.join(repo, "crates/disasmer-node/src/windows_dev.rs"), + "utf8" +); +const executionCore = fs.readFileSync( + path.join(repo, "crates/disasmer-core/src/execution.rs"), + "utf8" +); +const dapSource = [ + fs.readFileSync(path.join(repo, "crates/disasmer-dap/src/demo_backend.rs"), "utf8"), + fs.readFileSync(path.join(repo, "crates/disasmer-dap/src/tests.rs"), "utf8"), +].join("\n"); +const windowsNode = "windows-node"; +const windowsIdentity = nodeIdentity("windows-best-effort-smoke", windowsNode); + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function windowsCapabilities() { + return { + os: "Windows", + arch: "x86_64", + capabilities: ["Command", "VfsArtifacts", "WindowsCommandDev"], + environment_backends: ["WindowsCommandDev"], + source_providers: ["filesystem"] + }; +} + +function assertWindowsBackendBoundary() { + assert.match(nodeDaemon, /CoordinatorSession::connect\(&args\.coordinator\)/); + assert.match(nodeDaemon, /record_completed_task\(/); + assert.match(taskReports, /"type": "task_completed"/); + assert.doesNotMatch(nodeDaemon, /WindowsCommandDev|windows-command-dev|cfg\(windows\)/); + + assert.match(nodeLib, /impl CommandBackend for LinuxRootlessPodmanBackend/); + assert.match(nodeLib, /mod windows_dev/); + assert.match(nodeLib, /pub use windows_dev::\{WindowsCommandDevBackend, WindowsSandboxStubBackend\}/); + assert.match(windowsDev, /impl CommandBackend for WindowsCommandDevBackend/); + assert.match(windowsDev, /impl CommandBackend for WindowsSandboxStubBackend/); + assert.doesNotMatch(windowsDev, /LinuxRootlessPodmanBackend/); + assert.match(executionCore, /\bLinuxRootlessPodman\b/); + assert.match(executionCore, /\bWindowsCommandDev\b/); + assert.match(executionCore, /\bStubbedWindowsSandbox\b/); + + assert.match(dapSource, /thread\(WINDOWS_THREAD, "compile-windows", "compile windows"/); + assert.match(dapSource, /fn launch_threads_include_windows_task_in_same_virtual_process\(\)/); + assert.match(dapSource, /fn windows_thread_runtime_variables_share_virtual_process\(\)/); +} + +(async () => { + assertWindowsBackendBoundary(); + + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback" + ], + { cwd: repo } + ); + let coordinatorStderr = ""; + coordinator.stderr.on("data", (chunk) => { + coordinatorStderr += chunk.toString(); + }); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const attached = await send(addr, { + type: "attach_node", + tenant: "tenant", + project: "project", + node: windowsNode, + public_key: windowsIdentity.publicKey + }); + assert.strictEqual(attached.type, "node_attached"); + assert.strictEqual(attached.node, windowsNode); + + const recorded = await send(addr, signedNodeRequest(windowsNode, windowsIdentity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node: windowsNode, + capabilities: windowsCapabilities(), + cached_environment_digests: [environmentDigest], + dependency_cache_digests: [], + source_snapshots: [sourceDigest], + artifact_locations: [], + direct_connectivity: true, + online: true + })); + assert.strictEqual(recorded.type, "node_capabilities_recorded"); + assert.strictEqual(recorded.node, windowsNode); + + const placement = await send(addr, { + type: "schedule_task", + tenant: "tenant", + project: "project", + environment: { + os: "Windows", + arch: null, + capabilities: ["WindowsCommandDev"] + }, + environment_digest: environmentDigest, + required_capabilities: ["Command"], + dependency_cache: null, + source_snapshot: sourceDigest, + required_artifacts: [], + prefer_node: null + }); + assert.strictEqual(placement.type, "task_placement"); + assert.strictEqual(placement.placement.node, windowsNode); + assert.ok(placement.placement.reasons.includes("warm environment cache")); + assert.ok(placement.placement.reasons.includes("source snapshot already local")); + + const started = await send(addr, { + type: "start_process", + tenant: "tenant", + project: "project", + process: "vp-windows" + }); + assert.strictEqual(started.type, "process_started"); + assert.strictEqual(started.process, "vp-windows"); + + const reconnected = await send(addr, signedNodeRequest(windowsNode, windowsIdentity, "reconnect_node", { + type: "reconnect_node", + node: windowsNode, + process: "vp-windows", + epoch: started.epoch + })); + assert.strictEqual(reconnected.type, "node_reconnected"); + + const launched = await send(addr, { + type: "launch_task", + tenant: "tenant", + project: "project", + actor_user: "operator", + task_spec: { + tenant: "tenant", + project: "project", + process: "vp-windows", + task_definition: "windows-command-dev", + task_instance: "windows-command-dev", + dispatch: { + kind: "coordinator_node_wasm", + export: "windows_command_dev", + abi: "task_v1", + }, + environment_id: "windows", + environment: { + os: "Windows", + arch: null, + capabilities: ["WindowsCommandDev"], + }, + environment_digest: environmentDigest, + required_capabilities: ["Command", "WindowsCommandDev"], + dependency_cache: null, + source_snapshot: sourceDigest, + required_artifacts: [], + args: [], + vfs_epoch: started.epoch, + bundle_digest: emptyWasmDigest, + }, + wait_for_node: false, + artifact_path: "/vfs/artifacts/windows-output.txt", + wasm_module_base64: emptyWasm.toString("base64"), + }); + assert.strictEqual(launched.type, "task_launched", JSON.stringify(launched)); + assert.strictEqual(launched.placement.node, windowsNode); + + const recordedTask = await send(addr, signedNodeRequest(windowsNode, windowsIdentity, "task_completed", { + type: "task_completed", + tenant: "tenant", + project: "project", + process: "vp-windows", + node: windowsNode, + task: "windows-command-dev", + status_code: 0, + stdout_bytes: 18, + stderr_bytes: 0, + stdout_tail: "", + stderr_tail: "", + stdout_truncated: false, + stderr_truncated: false, + artifact_path: "/vfs/artifacts/windows-output.txt", + artifact_digest: windowsArtifactDigest, + artifact_size_bytes: windowsArtifactBytes.length + })); + assert.strictEqual(recordedTask.type, "task_recorded"); + assert.strictEqual(recordedTask.task, "windows-command-dev"); + + const events = await send(addr, { + type: "list_task_events", + tenant: "tenant", + project: "project", + actor_user: "user", + process: "vp-windows" + }); + assert.strictEqual(events.type, "task_events"); + assert.strictEqual(events.events.length, 1); + assert.strictEqual(events.events[0].node, "windows-node"); + assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/windows-output.txt"); + + const link = await send(addr, { + type: "create_artifact_download_link", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact: "windows-output.txt", + max_bytes: 1024 + }); + assert.strictEqual(link.type, "artifact_download_link"); + assert.strictEqual(link.link.process, "vp-windows"); + assert.deepStrictEqual(link.link.source, { RetainedNode: "windows-node" }); + } catch (error) { + if (coordinatorStderr) { + error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`; + } + throw error; + } finally { + coordinator.kill("SIGTERM"); + } + + console.log("Windows best-effort smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/windows-runner-smoke.js b/scripts/windows-runner-smoke.js new file mode 100755 index 0000000..e49ab94 --- /dev/null +++ b/scripts/windows-runner-smoke.js @@ -0,0 +1,479 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const net = require("net"); +const path = require("path"); +const { coordinatorWireRequest } = require("./coordinator-wire"); +const { nodeIdentity, signedNodeRequest } = require("./node-signing"); + +const repo = path.resolve(__dirname, ".."); +const forgejoWindowsNode = "forgejo-windows-node"; +const forgejoWindowsIdentity = nodeIdentity("windows-runner-smoke", forgejoWindowsNode); +const windowsEnvironmentDigest = `sha256:${crypto + .createHash("sha256") + .update("disasmer/windows-command-dev/v1") + .digest("hex")}`; +const sourceSnapshot = `sha256:${crypto + .createHash("sha256") + .update("disasmer/windows-runner/source/v1") + .digest("hex")}`; + +class DapClient { + constructor() { + this.child = cp.spawn( + "cargo", + ["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"], + { cwd: repo } + ); + this.seq = 1; + this.buffer = Buffer.alloc(0); + this.messages = []; + this.waiters = []; + this.stderr = ""; + + this.child.stdout.on("data", (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.parse(); + }); + this.child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString(); + }); + this.child.on("exit", () => this.flushWaiters()); + } + + send(command, args = {}) { + const seq = this.seq++; + const message = { seq, type: "request", command, arguments: args }; + const payload = Buffer.from(JSON.stringify(coordinatorWireRequest(message))); + this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); + this.child.stdin.write(payload); + return seq; + } + + async response(seq, command) { + const message = await this.waitFor( + (item) => + item.type === "response" && + item.request_seq === seq && + item.command === command + ); + if (!message.success) { + throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(coordinatorWireRequest(message))}`); + } + return message; + } + + waitFor(predicate, timeoutMs = 120000) { + const existing = this.messages.find(predicate); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.child.kill("SIGKILL"); + reject(new Error(`timed out waiting for DAP message\n${this.stderr}`)); + }, timeoutMs); + this.waiters.push({ predicate, resolve, timer }); + }); + } + + parse() { + while (true) { + const headerEnd = this.buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = this.buffer.slice(0, headerEnd).toString(); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) throw new Error(`bad DAP header: ${header}`); + const length = Number(match[1]); + const start = headerEnd + 4; + const end = start + length; + if (this.buffer.length < end) return; + const payload = this.buffer.slice(start, end).toString(); + this.buffer = this.buffer.slice(end); + this.messages.push(JSON.parse(payload)); + this.flushWaiters(); + } + } + + flushWaiters() { + for (const waiter of [...this.waiters]) { + const message = this.messages.find(waiter.predicate); + if (!message) continue; + clearTimeout(waiter.timer); + this.waiters.splice(this.waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + } + + async close() { + if (this.child.exitCode !== null) return; + const seq = this.send("disconnect"); + await this.response(seq, "disconnect"); + this.child.stdin.end(); + } +} + +function waitForJsonLine(child) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + try { + resolve(JSON.parse(buffer.slice(0, newline).trim())); + } catch (error) { + reject(error); + } + }); + child.once("exit", (code) => { + reject(new Error(`process exited before JSON line with code ${code}`)); + }); + }); +} + +function send(addr, message) { + return new Promise((resolve, reject) => { + const socket = net.connect(addr.port, addr.host, () => { + socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`); + }); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + socket.end(); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }); + socket.on("error", reject); + }); +} + +function runWindowsAttach(addr, grant) { + return new Promise((resolve, reject) => { + const child = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "node", + "attach", + "--coordinator", + `${addr.host}:${addr.port}`, + "--tenant", + "tenant", + "--project-id", + "project", + "--node", + forgejoWindowsNode, + "--public-key", + forgejoWindowsIdentity.publicKey, + "--enrollment-grant", + grant, + "--cap", + "windows-command-dev", + "--json" + ], + { + cwd: repo, + env: { + ...process.env, + DISASMER_NODE_PRIVATE_KEY: forgejoWindowsIdentity.privateKey, + }, + } + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("exit", (code) => { + if (code !== 0) { + reject(new Error(`Windows node attach failed with code ${code}\n${stderr}`)); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch (error) { + reject( + new Error(`Windows node attach output was not JSON: ${stdout}\n${error.stack || error.message}`) + ); + } + }); + }); +} + +function runWindowsNode(addr, grant) { + return new Promise((resolve, reject) => { + const child = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-node", + "--", + "--coordinator", + `${addr.host}:${addr.port}`, + "--tenant", + "tenant", + "--project-id", + "project", + "--node", + forgejoWindowsNode, + "--public-key", + forgejoWindowsIdentity.publicKey, + "--enrollment-grant", + grant, + "--process", + "vp-forgejo-windows", + "--task", + "windows-command-dev", + "--command", + "cmd", + "--arg", + "/C", + "--arg", + "echo disasmer-windows-runner", + "--artifact", + "/vfs/artifacts/windows-runner-output.txt" + ], + { + cwd: repo, + env: { + ...process.env, + DISASMER_NODE_PRIVATE_KEY: forgejoWindowsIdentity.privateKey, + }, + } + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("exit", (code) => { + if (code !== 0) { + reject(new Error(`Windows node smoke failed with code ${code}\n${stderr}`)); + return; + } + try { + resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1))); + } catch (error) { + reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`)); + } + }); + }); +} + +async function assertDebuggerShowsWindowsThread() { + const client = new DapClient(); + try { + const initialize = client.send("initialize", { + adapterID: "disasmer", + linesStartAt1: true, + columnsStartAt1: true + }); + await client.response(initialize, "initialize"); + + const launch = client.send("launch", { + entry: "build", + project: path.join(repo, "examples/launch-build-demo"), + runtimeBackend: "simulated" + }); + await client.response(launch, "launch"); + await client.waitFor( + (message) => message.type === "event" && message.event === "initialized" + ); + + const configurationDone = client.send("configurationDone"); + await client.response(configurationDone, "configurationDone"); + + const threadsRequest = client.send("threads"); + const threads = (await client.response(threadsRequest, "threads")).body.threads; + assert( + threads.some((thread) => thread.name.includes("compile windows")), + "debugger must represent the Windows task as a virtual thread" + ); + + await client.close(); + } catch (error) { + client.child.kill("SIGKILL"); + throw error; + } +} + +(async () => { + if (process.platform !== "win32") { + throw new Error( + `Windows runner validation requires win32; current platform is ${process.platform}` + ); + } + + const coordinator = cp.spawn( + "cargo", + [ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + "--allow-local-trusted-loopback" + ], + { cwd: repo } + ); + let coordinatorStderr = ""; + coordinator.stderr.on("data", (chunk) => { + coordinatorStderr += chunk.toString(); + }); + + try { + const ready = await waitForJsonLine(coordinator); + const [host, portText] = ready.listen.split(":"); + const addr = { host, port: Number(portText) }; + assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); + + const attachGrant = await send(addr, { + type: "create_node_enrollment_grant", + tenant: "tenant", + project: "project", + actor_user: "operator", + ttl_seconds: 900 + }); + assert.strictEqual(attachGrant.type, "node_enrollment_grant_created"); + assert.strictEqual(attachGrant.scope, "node:attach"); + + const attach = await runWindowsAttach(addr, attachGrant.grant); + assert.strictEqual(attach.plan.node, forgejoWindowsNode); + assert.strictEqual(attach.boundary.cli_contacted_coordinator, true); + assert.strictEqual(attach.boundary.used_enrollment_exchange, true); + assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged"); + assert.strictEqual(attach.coordinator_response.credential.scope, "node:attach"); + assert(attach.plan.capabilities.capabilities.includes("WindowsCommandDev")); + + const runtimeGrant = await send(addr, { + type: "create_node_enrollment_grant", + tenant: "tenant", + project: "project", + actor_user: "operator", + ttl_seconds: 900 + }); + assert.strictEqual(runtimeGrant.type, "node_enrollment_grant_created"); + + const report = await runWindowsNode(addr, runtimeGrant.grant); + assert.strictEqual(report.node_status, "completed"); + assert.strictEqual(report.virtual_thread, "windows-command-dev"); + assert.strictEqual(report.status_code, 0); + assert.strictEqual(report.large_bytes_uploaded, false); + assert.strictEqual( + report.staged_artifact.path, + "/vfs/artifacts/windows-runner-output.txt" + ); + assert.strictEqual(report.registration_response.type, "node_enrollment_exchanged"); + assert.strictEqual(report.registration_response.credential.scope, "node:attach"); + assert.strictEqual(report.coordinator_response.type, "task_recorded"); + + const recorded = await send(addr, signedNodeRequest(forgejoWindowsNode, forgejoWindowsIdentity, "report_node_capabilities", { + type: "report_node_capabilities", + tenant: "tenant", + project: "project", + node: forgejoWindowsNode, + capabilities: { + os: "Windows", + arch: "x86_64", + capabilities: ["Command", "WindowsCommandDev", "VfsArtifacts"], + environment_backends: ["WindowsCommandDev"], + source_providers: ["filesystem"] + }, + cached_environment_digests: [windowsEnvironmentDigest], + source_snapshots: [sourceSnapshot], + artifact_locations: ["windows-runner-output.txt"], + direct_connectivity: true, + online: true + })); + assert.strictEqual(recorded.type, "node_capabilities_recorded"); + + const placement = await send(addr, { + type: "schedule_task", + tenant: "tenant", + project: "project", + environment: { + os: "Windows", + arch: null, + capabilities: ["WindowsCommandDev"] + }, + environment_digest: windowsEnvironmentDigest, + required_capabilities: ["Command"], + source_snapshot: sourceSnapshot, + required_artifacts: ["windows-runner-output.txt"], + prefer_node: null + }); + assert.strictEqual(placement.type, "task_placement"); + assert.strictEqual(placement.placement.node, "forgejo-windows-node"); + + const link = await send(addr, { + type: "create_artifact_download_link", + tenant: "tenant", + project: "project", + actor_user: "user", + artifact: "windows-runner-output.txt", + max_bytes: 1024 + }); + assert.strictEqual(link.type, "artifact_download_link"); + assert.deepStrictEqual(link.link.source, { + RetainedNode: "forgejo-windows-node" + }); + } catch (error) { + if (coordinatorStderr) { + error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`; + } + throw error; + } finally { + coordinator.kill("SIGTERM"); + } + + await assertDebuggerShowsWindowsThread(); + + const outDir = path.join(repo, "target", "acceptance"); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.join(outDir, "windows-runner.json"), + `${JSON.stringify( + { + kind: "disasmer_windows_runner_validation", + platform: process.platform, + runner: process.env.FORGEJO_RUNNER_NAME || process.env.RUNNER_NAME || null, + validated: true + }, + null, + 2 + )}\n` + ); + + console.log("Windows runner smoke passed"); +})().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/windows-validation-contract-smoke.js b/scripts/windows-validation-contract-smoke.js new file mode 100755 index 0000000..07b0911 --- /dev/null +++ b/scripts/windows-validation-contract-smoke.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(repo, relativePath), "utf8"); +} + +function expect(source, name, pattern) { + assert.match(source, pattern, `missing Windows validation evidence: ${name}`); +} + +const workflow = read(".forgejo/workflows/windows-validation.yml"); +const runnerSmoke = read("scripts/windows-runner-smoke.js"); +const readme = read("README.md"); +const publicAcceptance = read("scripts/acceptance-public.sh"); +const publicSplit = read("scripts/verify-public-split.sh"); + +expect(workflow, "manual workflow dispatch", /workflow_dispatch/); +expect(workflow, "intermittent Forgejo Windows runner note", /intermittently online/); +expect(workflow, "Windows runner label", /runs-on:\s*windows/); +expect(workflow, "Windows validation acceptance report env", /DISASMER_WINDOWS_VALIDATION:\s*forgejo-windows-runner/); +expect(workflow, "acceptance report step", /node scripts\/acceptance-report\.js windows/); +expect(workflow, "Windows runtime unit coverage", /cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution/); +expect(workflow, "Windows runner smoke step", /node scripts\/windows-runner-smoke\.js/); + +expect(runnerSmoke, "real Windows platform guard", /process\.platform !== "win32"/); +expect(runnerSmoke, "CLI node attach function", /function runWindowsAttach/); +expect(runnerSmoke, "CLI node attach command", /"node"[\s\S]*"attach"[\s\S]*"windows-command-dev"/); +expect(runnerSmoke, "server-generated enrollment grant creation", /create_node_enrollment_grant[\s\S]*attachGrant\.grant/); +expect(runnerSmoke, "CLI enrollment exchange asserted", /used_enrollment_exchange[\s\S]*true/); +expect(runnerSmoke, "server-generated runtime enrollment grant", /const runtimeGrant[\s\S]*create_node_enrollment_grant[\s\S]*runtimeGrant\.grant/); +expect(runnerSmoke, "runtime uses enrollment grant", /"--enrollment-grant"[\s\S]*grant/); +expect(runnerSmoke, "Windows command task runs", /"windows-command-dev"[\s\S]*"cmd"[\s\S]*"echo disasmer-windows-runner"/); +expect(runnerSmoke, "artifact metadata is asserted", /windows-runner-output\.txt[\s\S]*artifact_download_link/); +expect(runnerSmoke, "Windows placement is asserted", /schedule_task[\s\S]*WindowsCommandDev[\s\S]*forgejo-windows-node/); +expect(runnerSmoke, "debugger shows Windows virtual thread", /compile windows/); +expect(runnerSmoke, "validation result artifact", /disasmer_windows_runner_validation/); + +expect(readme, "docs mention manual Windows workflow", /manual `Windows validation`\s+workflow/); +expect(readme, "docs mention intermittent runner", /intermittent Windows runner/); +expect(readme, "docs mention CLI attach in Windows validation", /runs `disasmer node attach`/); +expect(readme, "docs keep Windows unvalidated when report is not-run", /windows_validation: "not-run"[\s\S]*best-effort and unvalidated/); + +for (const [scriptName, script] of [ + ["public acceptance", publicAcceptance], + ["public split", publicSplit], +]) { + assert( + script.includes("node scripts/windows-validation-contract-smoke.js"), + `${scriptName} must run windows-validation-contract-smoke.js` + ); +} + +console.log("Windows validation contract smoke passed"); diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js new file mode 100644 index 0000000..5b7250e --- /dev/null +++ b/vscode-extension/extension.js @@ -0,0 +1,730 @@ +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); +const crypto = require("crypto"); + + +let vscode = null; +try { + vscode = require("vscode"); +} catch (_) { + vscode = null; +} + +function discoverEnvironmentNames(projectRoot) { + const envsDir = path.join(projectRoot, "envs"); + if (!fs.existsSync(envsDir)) return []; + + return fs + .readdirSync(envsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => { + const dir = path.join(envsDir, name); + return ( + fs.existsSync(path.join(dir, "Containerfile")) || + fs.existsSync(path.join(dir, "Dockerfile")) + ); + }) + .sort(); +} + +function findEnvReferences(text) { + const references = []; + const pattern = /env!\(\s*"([^"]+)"\s*\)/g; + let match; + while ((match = pattern.exec(text)) !== null) { + references.push({ + name: match[1], + start: match.index, + end: pattern.lastIndex + }); + } + return references; +} + +function diagnoseEnvReferences(text, environmentNames) { + const known = new Set(environmentNames); + return findEnvReferences(text) + .filter((reference) => !known.has(reference.name)) + .map((reference) => ({ + ...reference, + message: `Missing Disasmer environment "${reference.name}". Add envs/${reference.name}/Containerfile or envs/${reference.name}/Dockerfile.` + })); +} + +function binaryCandidates(projectRoot, name) { + const names = process.platform === "win32" ? [name, `${name}.exe`] : [name]; + return names.map((candidate) => path.join(projectRoot, candidate)); +} + +function executableWorkspaceBinary(projectRoot, name) { + if (!projectRoot) return null; + return binaryCandidates(projectRoot, name).find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch (_) { + return false; + } + }) || null; +} + +function hasCargoWorkspace(root) { + return fs.existsSync(path.join(root, "Cargo.toml")); +} + +function resolveProjectPath(folder, value) { + const workspaceRoot = folder && folder.uri && folder.uri.fsPath; + if (!value || value === "${workspaceFolder}") return workspaceRoot; + if (workspaceRoot && typeof value === "string") { + return value.replace(/\$\{workspaceFolder\}/g, workspaceRoot); + } + return value; +} + +function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dirname, "..")) { + const workspaceCli = executableWorkspaceBinary(projectRoot, "disasmer"); + if (workspaceCli) { + return { + command: workspaceCli, + args: ["bundle", "inspect", "--project", projectRoot, "--json"], + options: { + cwd: projectRoot, + encoding: "utf8" + } + }; + } + + return { + command: "cargo", + args: [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "bundle", + "inspect", + "--project", + projectRoot, + "--json" + ], + options: { + cwd: adapterWorkspace, + encoding: "utf8" + } + }; +} + +function disasmerCliCommand( + projectRoot, + args, + adapterWorkspace = path.resolve(__dirname, "..") +) { + const workspaceCli = executableWorkspaceBinary(projectRoot, "disasmer"); + if (workspaceCli) { + return { + command: workspaceCli, + args, + options: { cwd: projectRoot, encoding: "utf8" } + }; + } + if (!hasCargoWorkspace(adapterWorkspace)) { + return { + command: "disasmer", + args, + options: { cwd: projectRoot, encoding: "utf8" } + }; + } + return { + command: "cargo", + args: ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + options: { cwd: adapterWorkspace, encoding: "utf8" } + }; +} + +function runDisasmerCliJson( + projectRoot, + args, + adapterWorkspace = path.resolve(__dirname, ".."), + runner = childProcess.spawnSync +) { + const invocation = disasmerCliCommand(projectRoot, [...args, "--json"], adapterWorkspace); + const result = runner(invocation.command, invocation.args, invocation.options); + if (result.error) { + throw new Error(`Disasmer CLI failed: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = String(result.stderr || result.stdout || "").trim(); + throw new Error(detail || `Disasmer CLI exited with status ${result.status}`); + } + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error(`Disasmer CLI returned invalid JSON: ${error.message}`); + } +} + +function loadLiveProcesses( + projectRoot, + adapterWorkspace = path.resolve(__dirname, ".."), + runner = childProcess.spawnSync +) { + try { + const report = runDisasmerCliJson( + projectRoot, + ["process", "list"], + adapterWorkspace, + runner + ); + return { + processes: asArray(report.processes), + coordinator: report.coordinator || null, + tenant: report.tenant || null, + project: report.project || null, + user: report.user || null, + error: null + }; + } catch (error) { + return { + processes: [], + coordinator: null, + tenant: null, + project: null, + user: null, + error: error.message + }; + } +} + +function digestFromParts(parts) { + const hasher = crypto.createHash("sha256"); + for (const value of parts) { + const part = Buffer.from(value); + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(part.length)); + hasher.update(length); + hasher.update(part); + } + return hasher.digest("hex"); +} + +function disasmerProcessId(projectRoot, entry = "build") { + return `vp-${digestFromParts(["dap-process:v1", projectRoot, entry]).slice(0, 12)}`; +} + +function refreshBundleBeforeLaunch( + projectRoot, + adapterWorkspace = path.resolve(__dirname, ".."), + runner = childProcess.spawnSync +) { + const command = bundleInspectCommand(projectRoot, adapterWorkspace); + const result = runner(command.command, command.args, command.options); + if (result.error) { + throw new Error(`Disasmer bundle refresh failed: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + throw new Error( + `Disasmer bundle refresh failed before debug launch${detail ? `: ${detail}` : "."}` + ); + } + + let inspection; + try { + inspection = JSON.parse(result.stdout); + } catch (error) { + throw new Error(`Disasmer bundle refresh returned invalid metadata: ${error.message}`); + } + if (!inspection.metadata || !inspection.metadata.identity) { + throw new Error("Disasmer bundle refresh did not return a bundle identity."); + } + return inspection; +} + +function disasmerViewDescriptors() { + return [ + { id: "disasmer.nodes", emptyLabel: "No attached nodes" }, + { id: "disasmer.processes", emptyLabel: "No virtual processes" }, + { id: "disasmer.logs", emptyLabel: "No log streams" }, + { id: "disasmer.artifacts", emptyLabel: "No artifacts" }, + { id: "disasmer.inspector", emptyLabel: "No inspector state" } + ]; +} + +function disasmerViewStatePath(projectRoot) { + return path.join(projectRoot, ".disasmer", "views.json"); +} + +function emptyDisasmerViewState() { + return { + nodes: [], + processes: [], + logs: [], + artifacts: [], + inspector: [] + }; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function loadDisasmerViewState(projectRoot, reader = fs.readFileSync) { + const statePath = disasmerViewStatePath(projectRoot); + if (!fs.existsSync(statePath)) { + return emptyDisasmerViewState(); + } + + let parsed; + try { + parsed = JSON.parse(reader(statePath, "utf8")); + } catch (error) { + return { + ...emptyDisasmerViewState(), + inspector: [ + { + label: "View state error", + value: error.message + } + ] + }; + } + + return { + nodes: asArray(parsed.nodes), + processes: asArray(parsed.processes), + logs: asArray(parsed.logs), + artifacts: asArray(parsed.artifacts), + inspector: asArray(parsed.inspector) + }; +} + +function item(label, description = "", options = {}) { + return { + label: String(label || ""), + description: String(description || ""), + ...options + }; +} + +function disasmerViewItems(state, viewId) { + switch (viewId) { + case "disasmer.nodes": + return state.nodes.map((node) => + item(node.id || node.name || "node", [node.status, node.capabilities].filter(Boolean).join(" ")) + ); + case "disasmer.processes": + return state.processes.map((process) => + item( + process.id || process.process || "process", + [process.state || process.status, process.entry].filter(Boolean).join(" "), + { + processId: process.id || process.process, + processState: process.state || process.status || "unknown", + contextValue: "disasmer.virtualProcess", + command: { + command: "disasmer.process.attach", + title: "Attach to Virtual Process", + arguments: [process.id || process.process] + } + } + ) + ); + case "disasmer.logs": + return state.logs.map((log) => + item(log.task || log.stream || "log", [log.message, log.bytes].filter(Boolean).join(" ")) + ); + case "disasmer.artifacts": + return state.artifacts.map((artifact) => + item(artifact.path || artifact.id || "artifact", [artifact.status, artifact.size].filter(Boolean).join(" ")) + ); + case "disasmer.inspector": + return state.inspector.map((entry) => + item(entry.label || entry.name || "inspector", entry.value || entry.detail || "") + ); + default: + return []; + } +} + +function treeItemsForView(projectRoot, descriptor, liveProcesses = null) { + const state = projectRoot ? loadDisasmerViewState(projectRoot) : emptyDisasmerViewState(); + if (descriptor.id === "disasmer.processes" && liveProcesses) { + if (liveProcesses.error) { + return [ + item("Live process state unavailable", liveProcesses.error, { + contextValue: "disasmer.processError" + }) + ]; + } + state.processes = liveProcesses.processes; + } + const items = disasmerViewItems(state, descriptor.id); + return items.length > 0 ? items : [item(descriptor.emptyLabel)]; +} + +function toVsCodeTreeItem(viewItem) { + const treeItem = new vscode.TreeItem( + viewItem.label, + vscode.TreeItemCollapsibleState.None + ); + treeItem.description = viewItem.description; + treeItem.tooltip = viewItem.description || viewItem.label; + treeItem.contextValue = viewItem.contextValue; + treeItem.command = viewItem.command; + return treeItem; +} + +function resolveDisasmerDebugConfiguration(folder, config = {}) { + const project = resolveProjectPath(folder, config.project); + return { + ...config, + name: + config.name || + (config.request === "attach" + ? "Disasmer: Attach to Virtual Process" + : "Disasmer: Launch Virtual Process"), + type: "disasmer", + request: config.request || "launch", + entry: config.entry || "build", + project, + runtimeBackend: config.runtimeBackend || "local-services" + }; +} + +function debugAdapterExecutableSpec( + projectRoot, + adapterWorkspace = path.resolve(__dirname, "..") +) { + const workspaceAdapter = executableWorkspaceBinary(projectRoot, "disasmer-debug-dap"); + if (workspaceAdapter) { + return { + command: workspaceAdapter, + args: [], + options: { cwd: projectRoot } + }; + } + + if (!hasCargoWorkspace(adapterWorkspace)) { + return { + command: "disasmer-debug-dap", + args: [], + options: { cwd: projectRoot || process.cwd() } + }; + } + + return { + command: "cargo", + args: ["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"], + options: { cwd: adapterWorkspace } + }; +} + +function registerDisasmerViews(context) { + if (!vscode) return null; + const workspaceRoot = + vscode.workspace.workspaceFolders && + vscode.workspace.workspaceFolders[0] && + vscode.workspace.workspaceFolders[0].uri.fsPath; + const emitters = new Map(); + let liveProcesses = null; + const refreshProcesses = () => { + liveProcesses = workspaceRoot ? loadLiveProcesses(workspaceRoot) : null; + const emitter = emitters.get("disasmer.processes"); + if (emitter) emitter.fire(); + return liveProcesses; + }; + for (const descriptor of disasmerViewDescriptors()) { + const emitter = new vscode.EventEmitter(); + emitters.set(descriptor.id, emitter); + context.subscriptions.push( + vscode.window.registerTreeDataProvider(descriptor.id, { + onDidChangeTreeData: emitter.event, + getTreeItem(item) { + return item; + }, + getChildren() { + if (descriptor.id === "disasmer.processes" && liveProcesses === null) { + refreshProcesses(); + } + return treeItemsForView(workspaceRoot, descriptor, liveProcesses).map(toVsCodeTreeItem); + } + }), + emitter + ); + } + if (workspaceRoot && vscode.workspace.createFileSystemWatcher) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(workspaceRoot, ".disasmer/views.json") + ); + const refresh = () => emitters.forEach((emitter) => emitter.fire()); + context.subscriptions.push( + watcher, + watcher.onDidChange(refresh), + watcher.onDidCreate(refresh), + watcher.onDidDelete(refresh) + ); + } + context.subscriptions.push( + vscode.commands.registerCommand("disasmer.refreshProcesses", refreshProcesses) + ); + return { workspaceRoot, refreshProcesses, getLiveProcesses: () => liveProcesses }; +} + +function commandProcessId(value) { + if (typeof value === "string") return value; + return value && (value.processId || value.label); +} + +function liveDebugConfiguration(workspaceRoot, processId, liveProcesses) { + return { + type: "disasmer", + request: "attach", + name: `Disasmer: Attach to ${processId}`, + project: workspaceRoot, + entry: "build", + runtimeBackend: "live-services", + processId + }; +} + +function processAction(projectRoot, action, processId) { + return runDisasmerCliJson(projectRoot, ["process", action, "--process", processId, "--yes"]); +} + +function registerProcessCommands(context, viewController) { + if (!viewController || !viewController.workspaceRoot) return; + const projectRoot = viewController.workspaceRoot; + const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; + + context.subscriptions.push( + vscode.commands.registerCommand("disasmer.process.attach", async (value) => { + const processId = commandProcessId(value); + if (!processId) return; + const live = viewController.refreshProcesses(); + if (live.error) { + vscode.window.showErrorMessage(`Unable to load Disasmer process state: ${live.error}`); + return; + } + await vscode.debug.startDebugging( + folder, + liveDebugConfiguration(projectRoot, processId, live) + ); + }), + vscode.commands.registerCommand("disasmer.process.cancel", async (value) => { + const processId = commandProcessId(value); + if (!processId) return; + const selected = await vscode.window.showWarningMessage( + `Request cooperative cancellation of ${processId}? The process remains active until its code exits.`, + { modal: true }, + "Request Cancellation" + ); + if (selected !== "Request Cancellation") return; + try { + processAction(projectRoot, "cancel", processId); + viewController.refreshProcesses(); + vscode.window.showInformationMessage(`Cancellation requested for ${processId}.`); + } catch (error) { + vscode.window.showErrorMessage(`Disasmer cancellation failed: ${error.message}`); + } + }), + vscode.commands.registerCommand("disasmer.process.abort", async (value) => { + const processId = commandProcessId(value); + if (!processId) return; + const selected = await vscode.window.showWarningMessage( + `Abort ${processId}? This forcibly terminates the virtual process and releases the project slot.`, + { modal: true }, + "Abort Process" + ); + if (selected !== "Abort Process") return; + try { + processAction(projectRoot, "abort", processId); + viewController.refreshProcesses(); + vscode.window.showInformationMessage(`Aborted ${processId}.`); + } catch (error) { + vscode.window.showErrorMessage(`Disasmer abort failed: ${error.message}`); + } + }) + ); +} + +function existingProcessRelationship(activeProcess, intendedProcessId) { + return activeProcess && activeProcess.process === intendedProcessId + ? "same_launch_target" + : "different_launch_target"; +} + +async function resolveExistingProcessBeforeLaunch(folder, config) { + if ( + !folder || + config.request !== "launch" || + config.runtimeBackend !== "live-services" || + !config.project + ) { + return config; + } + const live = loadLiveProcesses(config.project); + if (live.error || live.processes.length === 0) return config; + + const active = live.processes[0]; + const intendedProcessId = + config.processId || disasmerProcessId(config.project, config.entry || "build"); + const relationship = existingProcessRelationship(active, intendedProcessId); + const runningId = active.process; + const detail = `${runningId} is ${active.state || "running"} in Coordinator Project ${ + live.project || "selected by the authenticated CLI session" + }.`; + + const choices = + relationship === "same_launch_target" + ? [ + { label: "Attach", description: "Debug the existing virtual process.", action: "attach" }, + { label: "Restart", description: "Restart this virtual process with the current launch.", action: "restart" }, + ...(active.state === "cancelling" + ? [] + : [{ label: "Request cancellation", description: "Let the process handle cancellation and exit.", action: "cancel" }]), + { label: "Abort and launch", description: "Forcibly terminate it, then start this launch.", action: "abort_launch" }, + { label: "Show details", description: detail, action: "details" }, + { label: "Cancel launch", description: "Leave the running process unchanged.", action: "dismiss" } + ] + : [ + { label: "Show running process", description: detail, action: "details" }, + ...(active.state === "cancelling" + ? [] + : [{ label: "Request cancellation", description: "Ask the other process to exit cooperatively.", action: "cancel" }]), + { label: "Abort it and launch this workspace", description: "Forcibly free this Coordinator Project slot.", action: "abort_launch" }, + { label: "Use another Coordinator Project", description: "Keep the other process and select a different authenticated project.", action: "other_project" }, + { label: "Cancel launch", description: "Leave the running process unchanged.", action: "dismiss" } + ]; + + const selected = await vscode.window.showQuickPick(choices, { + title: + relationship === "same_launch_target" + ? "This Disasmer virtual process is already running" + : "Another launch is using this Coordinator Project", + placeHolder: detail, + ignoreFocusOut: true + }); + if (!selected || selected.action === "dismiss") return undefined; + if (selected.action === "details") { + await vscode.window.showInformationMessage(detail, { modal: true }); + return undefined; + } + if (selected.action === "other_project") { + await vscode.window.showInformationMessage( + `Choose or create another Coordinator Project with the Disasmer CLI, then run \`disasmer project select\` from this workspace. The current project is ${ + live.project || "the project in the authenticated CLI session" + }.`, + { modal: true } + ); + return undefined; + } + if (selected.action === "cancel") { + processAction(config.project, "cancel", runningId); + vscode.commands.executeCommand("disasmer.refreshProcesses"); + return undefined; + } + if (selected.action === "abort_launch") { + processAction(config.project, "abort", runningId); + vscode.commands.executeCommand("disasmer.refreshProcesses"); + return config; + } + if (selected.action === "attach") { + return { + ...config, + request: "attach", + name: `Disasmer: Attach to ${runningId}`, + processId: runningId + }; + } + if (selected.action === "restart") { + return { ...config, processId: runningId, restartExisting: true }; + } + return undefined; +} + +function activate(context) { + if (!vscode) return; + + const diagnostics = vscode.languages.createDiagnosticCollection("disasmer"); + context.subscriptions.push(diagnostics); + const viewController = registerDisasmerViews(context); + registerProcessCommands(context, viewController); + + const refresh = (document) => { + if (document.languageId !== "rust") return; + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!folder) return; + const environmentNames = discoverEnvironmentNames(folder.uri.fsPath); + const items = diagnoseEnvReferences(document.getText(), environmentNames).map((diagnostic) => { + const start = document.positionAt(diagnostic.start); + const end = document.positionAt(diagnostic.end); + return new vscode.Diagnostic( + new vscode.Range(start, end), + diagnostic.message, + vscode.DiagnosticSeverity.Error + ); + }); + diagnostics.set(document.uri, items); + }; + + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(refresh), + vscode.workspace.onDidChangeTextDocument((event) => refresh(event.document)), + vscode.workspace.onDidSaveTextDocument(refresh), + vscode.debug.registerDebugAdapterDescriptorFactory("disasmer", { + createDebugAdapterDescriptor(session) { + const folder = session.workspaceFolder; + const project = session.configuration.project || (folder && folder.uri.fsPath); + if (!project) { + throw new Error("Disasmer debug launch requires a workspace folder or project path."); + } + const adapterWorkspace = path.resolve(__dirname, ".."); + refreshBundleBeforeLaunch(project, adapterWorkspace); + const spec = debugAdapterExecutableSpec(project, adapterWorkspace); + return new vscode.DebugAdapterExecutable( + spec.command, + spec.args, + spec.options + ); + } + }), + vscode.debug.registerDebugConfigurationProvider("disasmer", { + async resolveDebugConfiguration(folder, config) { + const resolved = resolveDisasmerDebugConfiguration(folder, config); + return resolveExistingProcessBeforeLaunch(folder, resolved); + } + }) + ); + + for (const document of vscode.workspace.textDocuments) { + refresh(document); + } +} + +function deactivate() {} + +module.exports = { + activate, + deactivate, + bundleInspectCommand, + debugAdapterExecutableSpec, + diagnoseEnvReferences, + disasmerCliCommand, + disasmerProcessId, + disasmerViewDescriptors, + disasmerViewItems, + disasmerViewStatePath, + discoverEnvironmentNames, + findEnvReferences, + existingProcessRelationship, + loadLiveProcesses, + loadDisasmerViewState, + registerDisasmerViews, + refreshBundleBeforeLaunch, + runDisasmerCliJson, + resolveDisasmerDebugConfiguration +}; diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..1521bc0 --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,234 @@ +{ + "name": "disasmer-vscode", + "displayName": "Disasmer", + "description": "VS Code support for Disasmer build programs, environments, and debugging.", + "version": "0.1.0", + "publisher": "disasmer", + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Debuggers", + "Other" + ], + "activationEvents": [ + "onLanguage:rust", + "onDebugResolve:disasmer", + "onView:disasmer.nodes", + "onView:disasmer.processes", + "onView:disasmer.logs", + "onView:disasmer.artifacts", + "onView:disasmer.inspector", + "workspaceContains:envs/*/Containerfile", + "workspaceContains:envs/*/Dockerfile" + ], + "main": "./extension.js", + "files": [ + "extension.js", + "resources/**" + ], + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "disasmer", + "title": "Disasmer", + "icon": "resources/disasmer.svg" + } + ] + }, + "debuggers": [ + { + "type": "disasmer", + "label": "Disasmer", + "languages": [ + "rust" + ], + "configurationAttributes": { + "launch": { + "type": "object", + "required": [ + "request", + "type", + "name" + ], + "properties": { + "name": { + "type": "string", + "default": "Disasmer: Launch Virtual Process" + }, + "type": { + "type": "string", + "default": "disasmer" + }, + "request": { + "type": "string", + "enum": [ + "launch" + ], + "default": "launch" + }, + "entry": { + "type": "string", + "description": "Disasmer entrypoint such as build, test, package, or release." + }, + "project": { + "type": "string", + "description": "Project directory. Defaults to the current workspace folder." + }, + "sourcePath": { + "type": "string", + "description": "Source file shown in the Disasmer virtual stack. Defaults to src/build.rs or src/main.rs." + }, + "runtimeBackend": { + "type": "string", + "enum": [ + "local-services", + "live-services", + "simulated" + ], + "default": "local-services", + "description": "Runtime backend used by the debug adapter. F5 defaults to local coordinator/node services; live-services uses the configured coordinator and an attached node." + }, + "coordinatorEndpoint": { + "type": "string", + "description": "Optional coordinator assertion. Normally inferred from the authenticated CLI session." + }, + "processId": { + "type": "string", + "description": "Explicit virtual process identity. Normally derived from the workspace and entrypoint." + } + } + }, + "attach": { + "type": "object", + "required": [ + "request", + "type", + "name", + "processId" + ], + "properties": { + "name": { + "type": "string", + "default": "Disasmer: Attach to Virtual Process" + }, + "type": { + "type": "string", + "default": "disasmer" + }, + "request": { + "type": "string", + "enum": [ + "attach" + ], + "default": "attach" + }, + "processId": { + "type": "string", + "description": "Virtual process to attach to." + }, + "entry": { + "type": "string", + "description": "Entrypoint represented by the virtual process." + }, + "project": { + "type": "string", + "description": "Local workspace used for source and bundle metadata." + }, + "sourcePath": { + "type": "string", + "description": "Source file shown in the Disasmer virtual stack." + }, + "runtimeBackend": { + "type": "string", + "enum": [ + "local-services", + "live-services", + "simulated" + ], + "default": "live-services" + }, + "coordinatorEndpoint": { + "type": "string", + "description": "Optional coordinator assertion. Normally inferred from the authenticated CLI session." + } + } + } + } + } + ], + "commands": [ + { + "command": "disasmer.refreshProcesses", + "title": "Disasmer: Refresh Virtual Processes", + "icon": "$(refresh)" + }, + { + "command": "disasmer.process.attach", + "title": "Disasmer: Attach to Virtual Process", + "icon": "$(debug-alt)" + }, + { + "command": "disasmer.process.cancel", + "title": "Disasmer: Request Process Cancellation", + "icon": "$(debug-stop)" + }, + { + "command": "disasmer.process.abort", + "title": "Disasmer: Abort Virtual Process", + "icon": "$(error)" + } + ], + "menus": { + "view/title": [ + { + "command": "disasmer.refreshProcesses", + "when": "view == disasmer.processes", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "disasmer.process.attach", + "when": "view == disasmer.processes && viewItem == disasmer.virtualProcess", + "group": "inline@1" + }, + { + "command": "disasmer.process.cancel", + "when": "view == disasmer.processes && viewItem == disasmer.virtualProcess", + "group": "process@2" + }, + { + "command": "disasmer.process.abort", + "when": "view == disasmer.processes && viewItem == disasmer.virtualProcess", + "group": "process@3" + } + ] + }, + "views": { + "disasmer": [ + { + "id": "disasmer.nodes", + "name": "Disasmer Nodes" + }, + { + "id": "disasmer.processes", + "name": "Disasmer Processes" + }, + { + "id": "disasmer.logs", + "name": "Disasmer Logs" + }, + { + "id": "disasmer.artifacts", + "name": "Disasmer Artifacts" + }, + { + "id": "disasmer.inspector", + "name": "Disasmer Inspector" + } + ] + } + } +} diff --git a/vscode-extension/resources/disasmer.svg b/vscode-extension/resources/disasmer.svg new file mode 100644 index 0000000..79ac9b6 --- /dev/null +++ b/vscode-extension/resources/disasmer.svg @@ -0,0 +1,3 @@ + + +