Public dry run dryrun-309831e1e021

Source commit: 309831e1e021f962c118452336776fd9a94025f9

Public tree identity: sha256:6fa95c1745579bd6256dbeb3d476db0b07c2f24aa9213b0f7783ce1adfc8aca5
This commit is contained in:
Disasmer release dry run 2026-07-03 16:07:13 +02:00
commit f22d0a5791
113 changed files with 39348 additions and 0 deletions

View file

@ -0,0 +1,84 @@
name: Public release dry run assets
on:
workflow_dispatch:
inputs:
release_name:
description: Forgejo Release name or tag for the dry run.
required: false
public_repo_url:
description: Public Forgejo repository URL at git.michelpaulissen.com.
required: false
jobs:
linux-assets:
runs-on: docker
container:
image: rust:1-bookworm
timeout-minutes: 60
env:
DISASMER_PUBLIC_RELEASE_NAME: ${{ inputs.release_name }}
DISASMER_PUBLIC_REPO_URL: ${{ inputs.public_repo_url }}
DISASMER_DNS_PUBLICATION_STATE: not-published
DISASMER_RESOLVER_OVERRIDE: required-for-dry-run
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
run: |
apt-get update
apt-get install --yes nodejs npm
- name: Tool versions
run: |
rustc --version
cargo --version
node --version
npm --version
tar --version
- name: Prepare public release assets
run: node scripts/prepare-public-release-dryrun.js
- name: Upload dry-run assets
uses: actions/upload-artifact@v4
with:
name: public-release-dryrun-linux-assets
path: |
target/public-release-dryrun/assets/*
target/public-release-dryrun/public-release-manifest.json
windows-assets:
# Manual on purpose: the Forgejo Windows runner is intermittently online.
runs-on: windows
timeout-minutes: 60
env:
DISASMER_PUBLIC_RELEASE_NAME: ${{ inputs.release_name }}
DISASMER_PUBLIC_REPO_URL: ${{ inputs.public_repo_url }}
DISASMER_DNS_PUBLICATION_STATE: not-published
DISASMER_RESOLVER_OVERRIDE: required-for-dry-run
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Tool versions
shell: pwsh
run: |
rustc --version
cargo --version
node --version
npm --version
tar --version
- name: Prepare public release assets
shell: pwsh
run: node scripts/prepare-public-release-dryrun.js
- name: Upload dry-run assets
uses: actions/upload-artifact@v4
with:
name: public-release-dryrun-windows-assets
path: |
target/public-release-dryrun/assets/*
target/public-release-dryrun/public-release-manifest.json

View file

@ -0,0 +1,39 @@
name: Windows validation
on:
workflow_dispatch:
jobs:
windows-runner:
# Manual on purpose: the Forgejo Windows runner is intermittently online.
# Run this workflow for release validation when a runner with this label is available.
runs-on: windows
timeout-minutes: 45
env:
DISASMER_WINDOWS_VALIDATION: forgejo-windows-runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Tool versions
shell: pwsh
run: |
rustc --version
cargo --version
node --version
- name: Acceptance environment report
shell: pwsh
run: node scripts/acceptance-report.js windows
- name: Format check
shell: pwsh
run: cargo fmt --all --check
- name: Windows runtime unit coverage
shell: pwsh
run: cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution
- name: Windows runner smoke
shell: pwsh
run: node scripts/windows-runner-smoke.js

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target/
/.disasmer/
**/.disasmer/
/vscode-extension/node_modules/
/private/*/Cargo.lock
/private/*/target/

2597
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

36
Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[workspace]
resolver = "2"
members = [
"crates/disasmer-cli",
"crates/disasmer-coordinator",
"crates/disasmer-core",
"crates/disasmer-dap",
"crates/disasmer-macros",
"crates/disasmer-node",
"crates/disasmer-sdk",
"examples/launch-build-demo",
]
[workspace.package]
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://example.invalid/disasmer"
[workspace.dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
futures-executor = "0.3"
hex = "0.4"
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"
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"] }

13
DISASMER_PUBLIC_TREE.json Normal file
View file

@ -0,0 +1,13 @@
{
"kind": "disasmer-filtered-public-tree",
"source_commit": "309831e1e021f962c118452336776fd9a94025f9",
"release_name": "dryrun-309831e1e021",
"filtered_out": [
"private/**",
"experiments/**",
".git",
"target"
],
"forgejo_host": "git.michelpaulissen.com",
"default_operator_endpoint": "https://disasmer.michelpaulissen.com:9443"
}

3559
MVP.md Normal file

File diff suppressed because it is too large Load diff

447
README.md Normal file
View file

@ -0,0 +1,447 @@
# 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_sdk::env;
let linux = env!("linux");
```
`envs/<name>/Containerfile` or `envs/<name>/Dockerfile` defines an environment named `<name>`. 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 flagship demo lives in `examples/launch-build-demo`. Its build workflow is Rust source, not CI YAML, and its tasks return artifact/source handles rather than copying large bytes through task arguments.
## 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
disasmer node attach --coordinator 127.0.0.1:7999 --enrollment-grant local-dev --public-key local-node-key
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 <host:port>` to force local coordinator mode.
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. 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 <host:port>` or check the Disasmer Nodes view.
- Missing environments: add `envs/<name>/Containerfile` or `envs/<name>/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.
## Public Release Dry Run
The public release dry run uses `https://disasmer.michelpaulissen.com:9443` as the
default operator endpoint for public CLI login and VS Code hosted/dry-run
configuration. The deployment should be a real externally reachable service
that can be shared with selected users, not a loopback-only, local-only, or mock
deployment. During the dry run, the name is intentionally not published in
public DNS until the `disasmer.michelpaulissen.com` DNS record is deployed; once
that record is live, no resolver override is required.
The default operator at `disasmer.michelpaulissen.com:9443` is the private hosted
coordinator from `private/hosted-policy`, with private hosted modules enabled.
The word `public` in this dry run describes the public Forgejo repository,
release assets, selected-user network reachability, and the public client
protocol used by released binaries. It does not mean deploying the standalone
open-source/public coordinator as the hosted operator. The public coordinator is
for local/self-hosted use; the dry-run operator must remain the private hosted
coordinator.
The dry-run acceptance validates both coordinator implementations. The private
hosted coordinator is validated through the live default-operator deployment,
service smoke, and public-repository e2e. The standalone public/open-source
coordinator is validated separately from the filtered public repository and
public release binaries in a local/self-hosted coordinator smoke; that validation
does not make it the hosted default operator.
Human browser login uses the nginx-served
`https://disasmer.michelpaulissen.com/auth/browser/start` path, which redirects
to the hosted Authentik account flow and returns to the CLI's localhost callback.
The `disasmer.michelpaulissen.com` website is deliberately barebones HTML with
no CSS for this dry run; layout and UX optimization belong to a later acceptance
phase.
The dry-run public repository is a real public repository on the Forgejo
instance at `git.michelpaulissen.com`, produced from the filtered public tree
without `private/**` or `experiments/**`. Its Forgejo Release publishes compiled
assets for the supported public binaries and the VS Code extension package, so a
user can download the appropriate artifacts from Forgejo and start without
building from source. Repeating the release later on a fresh public domain and a
public GitHub release should be mechanically straightforward, but that
fresh-domain/GitHub publication is outside this dry run.
Prepare the filtered public tree, release binary archive, VS Code extension VSIX,
checksums, and evidence manifest locally with:
```bash
node scripts/prepare-public-release-dryrun.js
```
To embed controlled-resolution fallback instructions in the selected-user assets,
set one of these when DNS is not live yet:
```bash
DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS=<instructions>
DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY="<ip-address> disasmer.michelpaulissen.com"
DISASMER_PUBLIC_RELEASE_DRYRUN_IP=<ip-address>
```
To publish the filtered snapshot to the Forgejo public repository, provide the
explicit remote and opt in to the push:
```bash
DISASMER_PUBLIC_REPO_REMOTE=ssh://git.michelpaulissen.com:2222/<owner>/<public-repo>.git \
DISASMER_PUBLISH_PUBLIC_TREE=1 \
node scripts/prepare-public-release-dryrun.js
```
The manual `Public release dry run assets` Forgejo workflow runs the same
preparation path for Linux and, when the intermittent `windows` runner is
online, for Windows. Upload the produced assets, including
`disasmer-vscode-*.vsix`, and
`public-release-manifest.json` to the Forgejo Release for the public repository.
The generated `DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-*.md` asset is the
selected-user quickstart, and `DISASMER_PUBLIC_DRYRUN_INVITE-*.md` is the short
invite that can be shared with selected external users for installing the
binaries, resolving the public operator DNS name, and connecting to the default
operator.
The workflow artifacts are staging evidence; the actual Forgejo Release is only
accepted once those assets are attached to the public repository's Release.
Publish the Forgejo Release through the API after the filtered public tree has
been pushed:
```bash
DISASMER_FORGEJO_TOKEN=<token> \
node scripts/publish-public-release-dryrun.js
```
The publisher infers the Forgejo owner and repository name from
`public-release-manifest.json`. Set `DISASMER_PUBLIC_REPO_OWNER=<owner>` and
`DISASMER_PUBLIC_REPO_NAME=<public-repo>` only to override that manifest value.
Before publishing, the non-e2e preflight can verify that the manifest matches
the current commit, the filtered public branch points at the prepared tree, all
local assets exist, and checksums match:
```bash
node scripts/public-release-dryrun-preflight.js
```
Selected external users, such as friends invited to the dry run, should be able
to download those assets from Forgejo, resolve
`disasmer.michelpaulissen.com` through public DNS or the supplied fallback
instructions, and get a realistic product experience without a source build or
private repo access.
Once the real hosted service is deployed, record private deployment evidence
against that externally reachable service with:
```bash
node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js
DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer.michelpaulissen.com:9443 \
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL=<test-issuer-url> \
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE=<authorization-code> \
node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js
```
The deployment bundle includes `disasmer-hosted-service`, a systemd unit that
binds `0.0.0.0:9443`, and a deployment manifest. Installing and starting that
bundle on the external host is required before the service smoke can be accepted
as real dry-run evidence.
After the public Forgejo Release, deployment, service smoke, public-operator
compatibility smoke, and standalone public-coordinator smoke have produced
evidence, run the public-repository dry-run e2e against the deployed operator:
```bash
DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1 \
DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer.michelpaulissen.com:9443 \
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL=<test-issuer-url> \
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE=<authorization-code> \
node scripts/public-release-dryrun-e2e.js
```
After the public-repository e2e dry run has produced evidence, run the final
consistency verifier:
```bash
DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL=1 \
node scripts/public-release-dryrun-final-evidence.js
```
The public-repository e2e evidence is
`target/acceptance/public-release-dryrun-e2e.json`. It must record the Forgejo
public repository URL, release name, source commit, filtered public tree
identity, default operator endpoint, controlled service address, commands, tool
versions, and true checks for downloaded release assets, checksum verification,
clean public checkout, build or install from the public repo/assets, default
operator selection, browser or CLI login, user node attachment, flagship
workflow run, VS Code debugger behavior, logs, artifact metadata, and artifact
download or explicit export.
## 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.
## 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 Wasmtime task can invoke a native command through the node `disasmer.cmd_run` host import 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 <agent-public-key>
DISASMER_AGENT_PUBLIC_KEY=<agent-public-key> disasmer run build
disasmer node attach --enrollment-grant <grant> --public-key <node-public-key>
disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant <tenant> --project-id <project> --node <node-id> --public-key <node-public-key> --enrollment-grant <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 user's browser, sends the user through the hosted account flow, and completes through a local callback. `disasmer login --browser --plan` is the diagnostic JSON login plan path. 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_PUBLIC_KEY`; the CLI records the agent public-key
fingerprint in its run plan instead of starting a browser flow.
`disasmer node attach` auto-detects OS, architecture, command/container,
VFS/artifact, source-provider, and environment capabilities. `--cap <name>` 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 <host:port> --project examples/launch-build-demo
```
The local smoke starts those as separate processes, keeps one node-to-coordinator JSON-line session open for attach, heartbeat, process start, reconnect, and completion, has the node run the demo test command, stages stdout as a VFS artifact, and records task completion metadata with the coordinator. The cancellation smoke keeps a node session open while a separate client asks the coordinator to cancel the task; the node polls task control, records a cancelled terminal event, and exits without reporting a false success.
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. The adapter presents one virtual process with virtual threads/tasks, all-stop breakpoint and pause behavior, thread stacks, task args, handles, command status, recent output, continue, and selected-task restart controls. Compatible source edits restart the selected task from a VFS checkpoint; incompatible edits reject selected-task restart with a whole virtual-process restart message. The F5 and DAP smokes run the adapter in local-services launch mode, so the debug variables include task completion recorded through the local coordinator/node process boundary. Disasmer-specific side views expose nodes, virtual processes, logs, artifacts, and inspector state alongside the normal debugger UI.
In live-services mode, the DAP adapter starts the coordinator-side virtual
process without requiring a node at launch. When that process reaches a virtual
task with `Command` or container capability requirements, the coordinator places
that task on a capable node; an explicitly attached/running worker node polls
the coordinator for that assignment.
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
```
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/local-services-smoke.js
node scripts/cancellation-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 `public-operator-compat-smoke.js`, which starts
the hosted dry-run service locally and verifies that the public CLI and public
node runtime can attach, launch work through coordinator task assignment, and
publish debug/log/artifact metadata through the hosted operator protocol bridge.
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.

489
acceptance_criteria.md Normal file
View file

@ -0,0 +1,489 @@
# Disasmer MVP Acceptance Criteria
Every criterion below must be covered by automated verification; when behavior crosses coordinator, node, CLI, DAP, browser, storage, or network boundaries, that verification should include integration coverage. The final product should be exercised as much as possible as if it were live infrastructure: separate services, real process boundaries, real auth boundaries, real quota decisions, realistic networking, realistic retention/GC, and realistic failure behavior. Because the main environment is NixOS, the live-infra shape can be credibly simulated with NixOS-based service and VM setups.
Windows-specific runtime behavior does not need to be validated by the main acceptance suite for the MVP. The Windows implementation should still be best-effort correct, kept behind clean interfaces, avoid false sandboxing claims, and be structured so it can be validated and fixed independently later.
These criteria intentionally avoid implementation detail. Architecture, protocol, and milestone details live in the MVP and feasibility documents.
Important reading note: an item marked **Partial** or **Open** does not automatically mean new product code, a new feature, or even actual implementation work is required. It means the item must become a proven fact about the system. Many items describe facts that may already be true, or should have been fundamental invariants from the start, but still need stronger evidence at the required boundary. Before adding implementation, first check whether the fact is already true in the current system and prefer proving, documenting, or tightening that existing behavior over duplicating capability or overengineering a parallel mechanism.
**Status Prefixes**
- **Passed:** implemented with focused automated/source-scan coverage or a narrow source-level guard in the current tree; this is not final MVP acceptance until the full release gates run.
- **Partial:** modeled or covered at unit/smoke/source-scan level, but missing live boundary coverage, packaging coverage, hosted coverage, or end-to-end acceptance.
- **Open:** not implemented, not yet wired through the required boundary, or not yet covered by an appropriate acceptance gate.
For release-blocker lists, **Passed** means the listed blocker condition is currently guarded as absent; **Open** means absence has not been proven by the release gate yet.
---
## 1. Public product bar
- [x] **Passed:** A user can run Disasmer locally with the open-source runtime, coordinator, node runtime, SDK, CLI, and VS Code extension.
- [x] **Passed:** A user can use the hosted coordinator as a control plane without receiving arbitrary hosted compute.
- [x] **Passed:** Public-release dry-run wording must not imply that `disasmer.michelpaulissen.com:9443` runs the standalone public/open-source coordinator. The default operator is the private hosted coordinator from `private/hosted-policy`; `public` refers to public repo/assets, selected-user reachability, and the public client protocol used by released binaries.
- [ ] **Partial:** Public-release dry-run evidence validates both coordinator implementations: the private hosted coordinator as the live default operator, and the standalone public/open-source coordinator as the local/self-hosted coordinator shipped in the public release assets. Current public-tree/assets preparation is fresh for the current acceptance commit, but the live service, Forgejo Release, and e2e evidence must be rerun for that commit.
- [x] **Passed:** A user can attach their own node and run actual build work on that node.
- [x] **Passed:** The flagship build workflow is expressed as Rust source code, not CI YAML.
- [x] **Passed:** Public-facing docs and repo text avoid naming any external launch forum or traffic source as a product goal.
- [x] **Passed:** User-facing docs, UI, CLI, and plan language use `community tier` consistently.
- [x] **Passed:** The public story remains: one virtual process, many virtual threads/tasks, ordinary source-level debugging, and local-first distributed execution.
---
## 2. Repository and release split
- [x] **Passed:** Public source can be produced by excluding `private/**` without breaking the open-source runtime, local coordinator, node runtime, CLI, SDK, or VS Code extension.
- [x] **Passed:** Hosted-only Authentik/OIDC, community quota enforcement, hosted zero-capability limits, abuse tooling, and admin controls live in `private/**` or equivalent filterable modules.
- [x] **Passed:** Public crates expose clear policy/auth interfaces that private hosted modules implement.
- [x] **Passed:** The open-source coordinator remains useful for local clouds, trusted team use, and VPN deployments without hosted-service-only modules.
- [x] **Passed:** Hosted and self-hosted nodes use the same node runtime code path; hosted restrictions are policy modules, not a separate node implementation.
---
## 3. Coordinator state model
- [x] **Passed:** Postgres is used only for data that must survive coordinator restart.
- [x] **Passed:** Tenants, users, projects, node identities, credentials, source-provider configuration, and durable service policy records persist across coordinator restarts.
- [x] **Passed:** Active virtual processes, live virtual threads, live scheduler state, debug epochs, ephemeral VFS manifests, and transient artifact locations may live in RAM for MVP.
- [x] **Passed:** Coordinator restart requires active virtual processes to restart rather than pretending live execution survived.
- [x] **Passed:** Nodes reconnect cleanly after coordinator restart and cannot continue an old virtual process under stale coordinator state.
- [x] **Passed:** Persistent project state remains intact after coordinator restart.
---
## 4. Auth and identities
- [x] **Passed:** Browser login works through the hosted identity flow.
- [ ] **Partial:** Public released binaries default to a real human browser/account flow for `disasmer login --browser`: source and local binary smoke cover the browser path, account/login handoff, and local callback behavior, and raw JSON login-plan output is opt-in or diagnostic. This remains partial until the current Forgejo Release binaries are published and rerun through the hosted service flow.
- [x] **Passed:** CLI login works for human users without manually copying long-lived secrets.
- [x] **Passed:** Agent/worker authentication supports public-key identity without requiring browser interaction on every run.
- [x] **Passed:** Node enrollment converts a short-lived enrollment grant into a scoped long-lived node identity.
- [x] **Passed:** User OAuth/session tokens are never passed to nodes as task credentials.
- [x] **Passed:** Node identity, user identity, project identity, and task identity are distinct in coordinator authorization decisions.
---
## 5. CLI behavior
- [x] **Passed:** `disasmer run` defaults to the current project directory.
- [x] **Passed:** `disasmer run --project <path>` runs a project other than the current directory.
- [x] **Passed:** `disasmer run` uses the hosted coordinator implicitly when the user is logged in and no local override is selected.
- [x] **Passed:** `disasmer run [entry]` selects a named entrypoint while preserving a sensible default when omitted.
- [x] **Passed:** One project can define multiple entrypoints such as build, test, package, release, watch, or custom workflows.
- [x] **Passed:** `disasmer node attach` auto-detects OS, architecture, environment backends, source providers, and ordinary node capabilities.
- [x] **Passed:** `disasmer node attach --cap ...` remains available for explicit overrides and advanced cases.
- [x] **Passed:** CLI commands are usable non-interactively by agents once their public-key identity is enrolled.
---
## 6. Project and bundle model
- [x] **Passed:** A project works without requiring a hand-written configuration file for the common case.
- [x] **Passed:** `envs/<name>/Containerfile` or `envs/<name>/Dockerfile` defines an environment named `<name>`.
- [x] **Passed:** Source code can reference discovered environments with `env!("name")`.
- [x] **Passed:** Missing environment references produce clear build/editor diagnostics.
- [x] **Passed:** Bundle identity changes when Wasm code, task ABI, environment recipe, source-provider manifest, or selected inputs change.
- [x] **Passed:** Bundles do not embed full container images by default.
- [x] **Passed:** Generated environment and bundle metadata are inspectable by the user.
---
## 7. Source-provider modularity
- [x] **Passed:** Git support is an optional source-provider module included by default.
- [x] **Passed:** The VFS core does not require Git as a hard dependency.
- [x] **Passed:** Users can add non-Git source providers without rewriting the VFS core.
- [x] **Passed:** Source snapshot creation can run as a node task instead of requiring coordinator filesystem or Git access.
- [x] **Passed:** Coordinator-run code remains zero-capability and does not directly inspect the user checkout.
- [x] **Passed:** A task can wait for any capable node to pick up source-preparation work rather than failing immediately when the coordinator lacks source access.
---
## 8. Wasmtime and task execution
- [x] **Passed:** Disasmer programs compile to Wasm and run under Wasmtime on node runtimes.
- [x] **Passed:** `#[disasmer::main]` defines a virtual-process entrypoint.
- [x] **Passed:** `#[disasmer::task]` defines named, remotely startable task entrypoints.
- [x] **Passed:** `spawn::task(...).start().await` creates a debugger-visible virtual thread/task.
- [x] **Passed:** `TaskHandle::join().await` returns small serialized results or runtime handles.
- [x] **Passed:** Task arguments reject host-only values such as raw pointers, references, local files, sockets, and process handles.
- [x] **Passed:** Large data crosses task boundaries through handles such as `SourceSnapshot`, `Blob`, `Artifact`, or VFS references.
- [x] **Passed:** Small data can move into and out of tasks without awkward user ceremony.
---
## 9. Native command execution
- [x] **Passed:** Native commands are invoked from Wasm tasks through node host capabilities.
- [x] **Passed:** A virtual process remains Wasmtime-backed even when a task calls into the node to run shell/native commands.
- [x] **Passed:** Hosted coordinator control-plane code cannot run native commands or containers.
- [x] **Passed:** Open-source/private deployments can allow normal local-node command capabilities by default.
- [x] **Passed:** Command stdout and stderr are associated with the virtual thread that started the command.
- [x] **Passed:** Native command execution is denied when the selected node or task lacks the required capability.
---
## 10. Linux execution backend
- [x] **Passed:** Linux nodes can materialize Containerfile-based environments with rootless Podman.
- [x] **Passed:** Linux nodes can run command tasks inside the selected environment.
- [x] **Passed:** Linux command outputs can be staged into the VFS artifact namespace.
- [x] **Passed:** Local Linux source checkouts stay local when the local node can run the task.
- [x] **Passed:** Linux command tasks participate in virtual process lifecycle, cancellation, logging, and debug freeze/resume behavior.
---
## 11. Windows execution backend
- [x] **Passed:** Windows node support is represented by the same node protocol and capability model as Linux.
- [x] **Passed:** Windows command execution is clearly labeled as user-attached development execution, not production-grade untrusted sandboxing.
- [x] **Passed:** Windows sandboxing exists behind an explicit backend interface or stub.
- [x] **Passed:** Windows-specific code does not infect the Linux backend or generic node runtime model.
- [x] **Passed:** Windows tasks can publish artifacts through the same VFS/artifact APIs once the backend is enabled.
---
## 12. Scheduler and placement
- [x] **Passed:** Scheduler placement is based on logical environment requirements, not hard-coded runner names.
- [x] **Passed:** Scheduler prefers nodes with the source snapshot, environment cache, dependency cache, and required artifacts already local.
- [x] **Passed:** Scheduler avoids network transfer when a compatible local/warm node exists.
- [x] **Passed:** Scheduler can place a task on any capable node when the program asks for capability rather than a specific node.
- [x] **Passed:** Scheduler failure messages explain which capability, environment, source, or connectivity constraint could not be satisfied.
- [x] **Passed:** The scheduler is a replaceable module with a simple default implementation.
---
## 13. QUIC transport and networking
- [x] **Passed:** QUIC transport is behind a transport abstraction.
- [x] **Passed:** The MVP default uses a Rust-native QUIC implementation.
- [x] **Passed:** Node-to-node bulk transfer uses authenticated direct connections when possible.
- [x] **Passed:** The coordinator can aid rendezvous but does not silently relay bulk artifact/source/blob data.
- [x] **Passed:** Failed direct connectivity results in a clear placement/export/sync error.
- [x] **Passed:** Each data-plane transfer is scoped to tenant, project, process, object, and authorization context.
---
## 14. VFS and local-first behavior
- [x] **Passed:** `flush()` publishes metadata and visibility information without uploading large bytes by default.
- [x] **Passed:** `sync()` is explicit and may move bytes according to user code or configured policy.
- [x] **Passed:** A local build from an existing checkout does not create a full-repo tarball by default.
- [x] **Passed:** Coordinator-routed compiler file reads are not part of the normal local-first path.
- [x] **Passed:** Source bytes, build-cache bytes, and artifact bytes remain node-local unless a consumer/export/sync/download path requires movement.
- [x] **Passed:** VFS overlays provide task-local writes and published manifest epochs.
- [x] **Passed:** Parent/downstream tasks can consume artifacts after the producing task flushes them.
- [x] **Passed:** Same-node artifact reuse avoids unnecessary copying where the platform supports it.
---
## 15. Artifact lifecycle and downloads
- [x] **Passed:** Artifacts and other large blobs are best-effort retained on nodes by default, not durable coordinator objects.
- [x] **Passed:** Artifact metadata can exist after `flush()` even when artifact bytes are only present on a retaining node.
- [x] **Passed:** Artifact bytes may be garbage collected according to node retention policy unless explicitly exported or stored by user code.
- [x] **Passed:** Loss of a node-local unsynced artifact is surfaced as artifact unavailability, not silent recovery.
- [x] **Passed:** The operator/control panel can expose a download button for a best-effort retained artifact.
- [x] **Passed:** Artifact download streams from a retaining node or explicit user-provided storage path, not from default durable coordinator artifact storage.
- [x] **Passed:** A download action is not created when the artifact cannot be downloaded within current retention, location, size, authorization, or community tier limits.
- [x] **Passed:** A failed download-link creation returns a clear error before showing a user-facing link.
- [x] **Passed:** Artifact download links are authenticated, authorized, scoped, and not usable merely by guessing a URL.
- [x] **Passed:** Cross-tenant and cross-project artifact downloads are denied even if an artifact ID or link-like value is known.
- [x] **Passed:** Download usage is accounted against the relevant limits before and during streaming.
---
## 16. Checkpoint and restart semantics
- [x] **Passed:** Task restart is based on task entrypoint, serialized args, environment handle, and VFS/artifact checkpoint boundaries.
- [x] **Passed:** The MVP does not claim live stack migration, live socket checkpointing, or arbitrary hot code replacement.
- [x] **Passed:** Restarting a failed or selected task after source edit works when task compatibility checks pass.
- [x] **Passed:** Compatibility failure requires a whole virtual-process restart with a clear message.
- [x] **Passed:** Unflushed task-local filesystem changes are discarded or explicitly preserved according to documented policy.
- [x] **Passed:** Best-effort retained artifacts can help restart, but restart never depends on pretending ephemeral artifacts are durable.
---
## 17. Debugging and DAP
- [x] **Passed:** VS Code F5 can launch a Disasmer virtual process.
- [ ] **Partial:** F5/live-services starts the coordinator-side virtual process without requiring a node at launch; when that process reaches a virtual task with capability/environment requirements, the coordinator places that task on a capable node. The DAP adapter must not secretly start a worker node or make node availability a prerequisite for starting the coordinator-side process. Missing capable nodes must surface as normal task placement/unavailable-capability state. This is covered by local coordinator/worker DAP smoke, quick-test binary smoke, and live private-hosted-coordinator protocol smoke against `disasmer.michelpaulissen.com:9443`, but remains partial until the released public-repo assets and VS Code/DAP path are rerun end to end against the live private hosted coordinator.
- [x] **Passed:** The debugger presents one process with multiple virtual threads/tasks.
- [x] **Passed:** Breakpoints in `main` and spawned task code stop the virtual process.
- [x] **Passed:** A breakpoint in one virtual thread creates a Debug Epoch for the whole virtual process.
- [x] **Passed:** Wasm tasks and controlled native-command tasks participate in all-stop debug behavior.
- [x] **Passed:** If a node cannot freeze a controlled task, the debugger reports failure instead of claiming all-stop succeeded.
- [x] **Passed:** The debugger shows coherent virtual process identity, virtual thread names, stack frames, task args, Disasmer handles, command status, recent output, and artifact references for the current flagship demo.
- [x] **Passed:** The normal debugger Variables pane exposes launchable MVP variable categories: task arguments, task return values, target structured fields, selected Rust locals around Disasmer API calls, runtime-captured Wasmtime frame locals, `Artifact`, `SourceSnapshot`, and `Blob` handles, current command spec/status, VFS mounts, and recent stdout/stderr tail.
- [x] **Passed:** Top-level source locals around Disasmer API calls are visible from virtual-thread runtime state in the shippable DAP path. This is implemented outside `experiments/**` and is not satisfied by hardcoded experiment-local variable mapping.
- [x] **Passed:** The shippable node/DAP path captures real Wasmtime frame-local slots from guest-debug runtime state and exposes them through a normal DAP Variables scope. This is implemented outside `experiments/**`; the experiment remains proof only, not implementation basis.
- [x] **Passed:** Arbitrary Rust locals use best-effort source metadata when full value snapshots are unavailable, and variables that cannot be inspected are reported clearly while task args and handles remain visible.
- [x] **Passed:** The debugger exposes per-task stdout/stderr tails, VFS/artifact state, and command status in normal scopes or optional Disasmer views without requiring users to understand node IDs, transport links, VFS manifests, or environment-cache internals.
- [x] **Passed:** Continue resumes every frozen participant in the Debug Epoch.
- [x] **Passed:** Pause requests stop the whole virtual process, not just the currently selected thread.
- [x] **Passed:** Restart selected/failed task is available from the debugging workflow.
- [x] **Passed:** The DAP adapter targets VS Code first while remaining ordinary enough for other DAP clients to integrate later.
- [x] **Passed:** The launch-critical DAP surface required by the MVP is implemented and acceptance-tested: `initialize`, `launch`/`attach`, `setBreakpoints`, `configurationDone`, `threads`, `stackTrace`, `scopes`, `variables`, `continue`, `pause`, `next`/step-over at probe granularity, `disconnect`, `stopped` events, and `continued` events.
---
## 18. VS Code extension
- [x] **Passed:** The extension discovers environment resources and provides useful diagnostics for `env!(...)` references.
- [x] **Passed:** The extension builds or refreshes the Disasmer bundle before launch/debug when needed.
- [x] **Passed:** Normal VS Code thread, breakpoint, stack, output, continue, pause, restart, and Variables-pane flows work for the flagship demo, including selected source-local values around Disasmer API calls.
- [x] **Passed:** Disasmer-specific views show nodes, virtual processes, logs, artifacts, and inspector state without replacing the normal debugger UX.
- [x] **Passed:** Error messages guide the user toward missing nodes, missing environments, quota limits, unavailable artifacts, or failed debug freezes.
---
## 19. Operator/control panel
- [x] **Passed:** The MVP includes protocol and state foundations for immediate-mode operator panels.
- [x] **Passed:** Program-defined panels use built-in typed widgets only.
- [x] **Passed:** User-provided panel content cannot inject custom HTML or JavaScript.
- [x] **Passed:** Panel state is scoped to tenant, project, and virtual process.
- [x] **Passed:** Panel events are typed, rate-limited, and delivered back to the virtual process only when allowed.
- [x] **Passed:** A stopped debug process shows the last rendered panel state without executing program UI logic.
- [x] **Passed:** Control-plane actions such as restart, cancel, debug, and artifact download can remain available while program UI events are disabled.
---
## 20. Hosted/community service policy
- [x] **Passed:** Community hosted mode can create or use a project, attach user nodes, run tasks on those nodes, debug, and view logs/artifact metadata.
- [x] **Passed:** Community hosted mode does not provide arbitrary hosted native commands or hosted containers.
- [x] **Passed:** Hosted zero-capability Wasm has strict fuel, memory, wall-clock, state, log, and event limits.
- [x] **Passed:** Hosted community policies are enforced before work starts, not only after resource usage is observed.
- [x] **Passed:** Quota failures are explicit and actionable.
- [x] **Passed:** Public hosted endpoints treat users, nodes, programs, logs, artifacts, UI events, source manifests, and capabilities as hostile input.
---
## 21. Authorization and tenant isolation
- [x] **Passed:** Every user-owned or project-owned object has tenant ownership.
- [x] **Passed:** Users cannot list, inspect, debug, download, or mutate another tenant's nodes, processes, logs, artifacts, projects, or panel state.
- [x] **Passed:** Nodes cannot claim tasks or publish artifacts outside their authorized tenant/project/process scope.
- [x] **Passed:** Debug attach requires explicit project permission.
- [x] **Passed:** Debug memory/variable/handle reads are authorized and scoped.
- [x] **Passed:** Artifact provenance includes producer task, process, project, and node identity.
- [x] **Passed:** Tenant isolation failures are treated as release blockers.
---
## 22. Abuse and resource limits
- [x] **Passed:** API calls, spawns, logs, metadata, debug reads, UI events, rendezvous attempts, and artifact downloads are subject to resource policy.
- [x] **Passed:** Log output is capped, backpressured, and associated with the producing virtual thread.
- [x] **Passed:** Large task arguments are rejected or warned before causing accidental distributed copies.
- [x] **Passed:** Community hosted tasks cannot use network, secrets, host filesystem, native commands, containers, inbound ports, or arbitrary syscalls.
- [x] **Passed:** Operator panels cannot collect passwords or initiate OAuth-like flows inside user-defined widgets.
- [x] **Passed:** Admin controls can suspend abusive tenants, revoke node credentials, and stop active hosted processes.
---
## 23. Documentation and examples
- [x] **Passed:** The README shows the build-system workflow with Rust source, environments, attached nodes, debugging, and artifacts.
- [x] **Passed:** Docs explain `flush()` versus `sync()` without implying default durability.
- [x] **Passed:** Docs explain that artifacts are best-effort retained unless explicitly exported or stored.
- [x] **Passed:** Docs explain node trust, user-attached execution, and Windows sandbox limitations honestly.
- [x] **Passed:** Docs explain hosted/community limits without framing them as a compute giveaway.
- [x] **Passed:** Docs explain how agents authenticate with public keys.
- [x] **Passed:** Docs explain how to add non-Git source-provider support.
- [x] **Passed:** Example code avoids coordinator-side filesystem, Git, shell, or container assumptions.
---
## 24. Flagship demo acceptance
- [x] **Passed:** A clean developer setup can install the CLI and VS Code extension.
- [x] **Passed:** The user can sign in, create or select a project, and attach at least one Linux node.
- [x] **Passed:** The demo project recognizes `env!("linux")` and, when available, `env!("windows")`.
- [x] **Passed:** Pressing F5 starts one virtual process through the Disasmer debugger.
- [ ] **Partial:** The flagship demo requires an explicitly attached/running worker node for command/container-style work; the coordinator-side process remains the limited orchestration path and worker placement is implicit in task launch requirements, not a user-facing direct scheduler call. Local quick-test binaries prove this against a fresh coordinator, and the live private hosted coordinator accepts the current `launch_task` / worker-assignment protocol; hosted dry-run acceptance still requires the released public-repo assets and VS Code/DAP path to be rerun end to end.
- [x] **Passed:** The Linux build task runs as a node-hosted command from a Wasmtime-backed virtual task.
- [x] **Passed:** A second task can run concurrently as another virtual thread when a capable node is available.
- [x] **Passed:** A breakpoint in a spawned task all-stops the virtual process.
- [x] **Passed:** Task args, command status, logs, and artifact handles are inspectable through synthetic runtime scopes or Disasmer views.
- [x] **Passed:** The flagship demo lets the user inspect selected locals around Disasmer API calls, task args, command status, logs, stdout/stderr tails, VFS mounts, and artifact handles from the normal VS Code debugging experience.
- [x] **Passed:** A failed task can be restarted after a source edit when compatibility permits.
- [x] **Passed:** Final artifacts can be exported, downloaded from retained nodes, or otherwise handled explicitly by user code.
- [x] **Passed:** The coordinator does not route bulk source or artifact bytes by default during the demo.
---
## 25. Non-goals preserved
- [x] **Passed:** The MVP does not imply transparent raw remote pointers.
- [x] **Passed:** The MVP does not imply global consensus on every memory access.
- [x] **Passed:** The MVP does not imply live native process migration.
- [x] **Passed:** The MVP does not imply durable artifacts without explicit export/storage policy.
- [x] **Passed:** The MVP does not imply public hosted compute for arbitrary workloads.
- [x] **Passed:** The MVP does not imply secure managed Windows compute.
- [x] **Passed:** The MVP does not require users to understand distributed internals for the normal debug/build path.

View file

@ -0,0 +1,385 @@
# Disasmer MVP Release Acceptance Criteria
**Status:** phase 2 superset of `acceptance_criteria.md`
**Purpose:** define the strict release gates for the MVP described by the current MVP plan and supporting design notes.
This document is intentionally stricter than a milestone checklist. The MVP is accepted only when the product behaves like live infrastructure from a clean checkout, not when the codebase merely contains the right traits, models, mocks, or experiment outputs.
Important reading note: an item marked **Partial** or **Open** does not automatically mean new product code, a new feature, or even actual implementation work is required. It means the item must become a proven fact about the system. Many items describe facts that may already be true, or should have been fundamental invariants from the start, but still need stronger evidence at the required boundary. Before adding implementation, first check whether the fact is already true in the current system and prefer proving, documenting, or tightening that existing behavior over duplicating capability or overengineering a parallel mechanism.
**Status Prefixes**
- **Passed:** implemented with focused automated/source-scan coverage or a narrow source-level guard in the current tree; this is not final MVP acceptance until the full release gates run.
- **Partial:** modeled or covered at unit/smoke/source-scan level, but missing live boundary coverage, packaging coverage, hosted coverage, or end-to-end acceptance.
- **Open:** not implemented, not yet wired through the required boundary, or not yet covered by an appropriate acceptance gate.
For release-blocker lists, **Passed** means the listed blocker condition is currently guarded as absent; **Open** means absence has not been proven by the release gate yet.
## Verification contract
- [ ] **Partial:** Every release-blocking criterion in this document is satisfied by automated acceptance checks or an equivalent reproducible release gate. Current source, local smoke, public split, and public-tree preparation gates pass, but the current Forgejo Release upload, live service smoke, public-operator compatibility smoke, and full public dry-run e2e verification still need to be rerun from the fresh released assets.
- [x] **Passed:** Release-blocking behavior is exercised across real service/process boundaries whenever the feature crosses a coordinator, node, DAP, CLI, browser, database, VFS, or network boundary.
- [x] **Passed:** The acceptance environment is treated as live infrastructure as much as possible; NixOS may be used to make Auth, Postgres, coordinator, nodes, browser-facing services, Podman, networking, and test projects reproducible.
- [x] **Passed:** A criterion cannot be accepted solely because a type, trait, schema, mock, or unit-level model exists.
- [x] **Passed:** The acceptance report records commit SHA, public/private mode, OS/kernel, Rust toolchain, Node.js version, Podman version, Postgres version when used, browser/VS Code harness version, and whether Windows validation was run.
- [x] **Passed:** Windows criteria are best-effort unless explicitly marked release-blocking by the current release target. Windows docs and UI must not imply validated production-grade sandboxing before that validation exists.
- [x] **Passed:** Existing `acceptance_criteria.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts.
---
## 1. Public release split
- [x] **Passed:** Excluding `private/**` produces a public source tree that builds the SDK, CLI, coordinator, node runtime, DAP adapter, VS Code extension, protocol crates, and examples.
- [x] **Passed:** Public coordinator/local mode remains useful without private hosted modules.
- [x] **Passed:** Private hosted modules are isolated under `private/**` or equivalent publish-filterable paths.
- [x] **Passed:** Public code does not import private modules except through explicit feature-gated boundaries that are absent from public release builds.
- [x] **Passed:** Hosted deployment works when private modules are included.
- [x] **Passed:** OIDC/Auth hosted integration, community tier quota policy, zero-cap hosted enforcement, hosted admin controls, and service-specific policy modules are private-hosted features.
- [x] **Passed:** The node runtime is not forked between public and hosted modes.
- [x] **Passed:** Hosted restrictions are policy/module decisions, not a separate incompatible runtime.
- [x] **Passed:** Public/self-hosted coordinators do not silently apply hosted community tier restrictions to owner-controlled deployments.
- [x] **Passed:** `experiments/**` is excluded from release artifacts and is not imported by runtime, coordinator, node, DAP, CLI, SDK, extension, or hosted private code.
- [x] **Passed:** Release source scans reject known demo-only credentials, placeholder device codes, hard-coded artifact links, hidden local paths, and experiment-specific process names.
---
## 2. Public local MVP workflow
- [x] **Passed:** From a clean public checkout, a developer can build or install the CLI using documented commands.
- [x] **Passed:** From a clean public checkout, a developer can build or locally install the VS Code extension using documented commands.
- [x] **Passed:** A local coordinator and local node run as separate OS processes.
- [x] **Passed:** `disasmer node attach` enrolls the node through the real identity path.
- [x] **Passed:** `disasmer node attach` auto-detects OS, architecture, backend, source-provider, and environment capabilities.
- [x] **Passed:** `disasmer node attach --cap ...` remains available as an override, not as required normal configuration.
- [x] **Passed:** `disasmer run` defaults to the current project directory.
- [x] **Passed:** `disasmer run --project <path>` overrides the project directory.
- [x] **Passed:** `disasmer run` uses hosted mode implicitly when the CLI has a hosted login and no local coordinator override is selected.
- [x] **Passed:** `disasmer run --local` or equivalent forces local coordinator mode when both local and hosted contexts exist.
- [x] **Passed:** `disasmer run [entry]` can select an entrypoint from the same project.
- [x] **Passed:** Multiple project entrypoints can be listed and run without duplicating project configuration.
- [x] **Passed:** The flagship local workflow crosses the CLI, coordinator, and node boundary rather than running all work inside the CLI process.
- [x] **Passed:** The local node performs real build work and reports task state, logs, VFS metadata, and artifact metadata to the coordinator.
- [x] **Passed:** The local workflow remains usable after excluding private hosted modules.
---
## 3. Hosted/community control plane
- [x] **Passed:** A hosted coordinator starts with private hosted modules enabled and public runtime crates unchanged.
- [x] **Passed:** Browser login completes through OIDC against Authentik or a live-compatible test OIDC provider.
- [x] **Passed:** CLI browser login receives a scoped CLI session without exposing provider tokens to nodes.
- [ ] **Partial:** Public released binaries default to a real human browser/account flow for `disasmer login --browser`: source and local binary smoke cover the browser path, account/login handoff, and local callback behavior, and raw JSON login-plan output is opt-in or diagnostic. This remains partial until the current Forgejo Release binaries are published and rerun through the hosted service flow.
- [x] **Passed:** Agent authentication works through public-key identity without requiring an interactive browser session.
- [x] **Passed:** Agent public keys can be registered, listed, rotated, and revoked.
- [x] **Passed:** A signed-in user can create or select a project through hosted API or UI.
- [x] **Passed:** A signed-in user can create a short-lived node enrollment token for a project.
- [x] **Passed:** A user-attached Linux node exchanges the enrollment token for a scoped long-lived node identity.
- [x] **Passed:** The hosted coordinator accepts the node's authenticated persistent session and heartbeat.
- [x] **Passed:** Hosted scheduling targets only authorized user-attached, team-shared, or managed nodes.
- [x] **Passed:** Community tier users can run real work on their own attached nodes.
- [x] **Passed:** Community tier users cannot run arbitrary hosted native commands, hosted containers, host filesystem access, secrets, inbound ports, outbound network, or arbitrary syscalls.
- [x] **Passed:** Hosted zero-capability Wasm is bounded by policy before start: fuel, memory, wall-clock, state, logs, metadata, UI events, and API calls.
- [x] **Passed:** Quota failures are returned before dispatch with a specific actionable reason.
- [x] **Passed:** Admin controls can suspend a tenant, revoke a node credential, and stop an active hosted process.
- [x] **Passed:** Hosted UI or API exposes node status, process status, logs, artifact metadata, and debug-session status.
---
## 4. Auth, identity, and tenant isolation
- [x] **Passed:** Browser sessions, CLI sessions, agent public-key identities, node identities, task identities, project identities, process identities, and artifact identities are represented distinctly.
- [x] **Passed:** User OAuth/browser/session tokens are never sent to nodes in task payloads, environments, logs, debug variables, panel events, or credential fields.
- [x] **Passed:** Node credentials are scoped to tenant, project or enrollment scope, node, and capability policy.
- [x] **Passed:** A node cannot claim a task for another tenant or project.
- [x] **Passed:** A node cannot publish logs, artifacts, VFS events, debug events, or panel events for an unauthorized process.
- [x] **Passed:** A user cannot list, inspect, debug, download, mutate, or infer another tenant's projects, nodes, processes, logs, artifacts, panels, source manifests, capabilities, or credentials.
- [x] **Passed:** Debug attach requires explicit project permission.
- [x] **Passed:** Debug memory, variables, handles, recent output, and artifact references are scoped to tenant, project, process, and authorized actor.
- [x] **Passed:** Artifact download authorization is checked at link creation and during streaming.
- [x] **Passed:** Negative tenant-isolation checks use the same public/private APIs used by normal clients.
---
## 5. Coordinator persistence and restart semantics
- [x] **Passed:** The coordinator has an abstract store boundary for durable product state.
- [x] **Passed:** Hosted deployment persists durable product state in Postgres or the configured production store.
- [x] **Passed:** Local development can use an in-memory store when durable projects are not required.
- [x] **Passed:** Durable state is limited to objects that must survive restart: users, tenants, projects, memberships, node identities, credentials, source-provider configuration, durable policy records, audit/security records, and private hosted configuration.
- [x] **Passed:** Active virtual processes, live virtual threads, debug epochs, node sessions, scheduler queues, in-flight task state, transient logs, VFS manifests, and artifact locations are ephemeral unless explicitly promoted to durable product/audit state.
- [x] **Passed:** Coordinator restart invalidates live virtual processes rather than pretending to recover them.
- [x] **Passed:** After coordinator restart, nodes reconnect with their persistent node identity.
- [x] **Passed:** After coordinator restart, nodes cannot continue stale virtual processes under the old coordinator epoch.
- [x] **Passed:** After coordinator restart, a user can re-run the process while retaining project, source-provider, and node identity state.
- [x] **Passed:** Migrations do not add durable tables for live process state without an explicit durability justification.
---
## 6. Node runtime and coordinator session
- [x] **Passed:** The node runtime opens a persistent authenticated session after enrollment.
- [x] **Passed:** The same session carries registration, heartbeat, capability updates, task assignments, debug commands, log events, VFS metadata, artifact metadata, and completion/failure events.
- [x] **Passed:** Session reconnect preserves node identity but not stale process ownership.
- [x] **Passed:** Coordinator-requested cancellation reaches the node and produces a terminal task state.
- [x] **Passed:** Debug freeze/resume reaches Wasm tasks through the node runtime.
- [x] **Passed:** Controlled native command tasks participate in freeze/resume when the backend supports it.
- [x] **Passed:** If a backend cannot freeze a controlled task, the node reports failure rather than pretending success.
- [x] **Passed:** Hosted and self-hosted nodes execute through the same node runtime code path.
- [x] **Passed:** Node capability claims are reported, authorized, and visible in coordinator/inspector state.
---
## 7. Wasmtime, SDK, task ABI, and spawn/join
- [x] **Passed:** A Disasmer program compiles to Wasm using the documented SDK and macros.
- [x] **Passed:** `#[disasmer::main]` registers at least one virtual-process entrypoint.
- [x] **Passed:** Multiple `#[disasmer::main]` or named entrypoints can coexist in one project and be selected by `disasmer run [entry]`.
- [x] **Passed:** `#[disasmer::task]` registers named task entrypoints that the runtime can start remotely.
- [x] **Passed:** `spawn::task(...).start().await` creates a debugger-visible virtual thread through the runtime.
- [x] **Passed:** `TaskHandle::join().await` returns small serialized values or runtime handles across a task boundary.
- [x] **Passed:** Raw pointers, borrowed references, local file handles, sockets, native process handles, and unbounded byte buffers are rejected before dispatch.
- [x] **Passed:** Large task inputs and outputs cross boundaries through `SourceSnapshot`, `Blob`, `Artifact`, `VirtualFile`, or VFS handles.
- [x] **Passed:** The flagship workflow uses the public SDK and runtime APIs, not custom demo-only APIs.
- [x] **Passed:** Task compatibility hashes distinguish restartable implementation changes from schema/capability/ABI changes requiring process restart.
---
## 8. Native commands and Linux Podman backend
- [x] **Passed:** Native command execution is invoked from a Wasmtime-backed virtual task through node host capabilities.
- [x] **Passed:** Native commands are exposed as node host imports or equivalent runtime calls, not as coordinator-side shell execution.
- [x] **Passed:** Hosted coordinator control-plane code cannot run native commands or containers for community users.
- [x] **Passed:** Native command execution is denied when the selected node or task lacks the required capability.
- [x] **Passed:** Linux nodes materialize `envs/<name>/Containerfile` or `envs/<name>/Dockerfile` environments with rootless Podman.
- [x] **Passed:** Linux command tasks run inside the selected materialized environment.
- [x] **Passed:** Command stdout/stderr are associated with the virtual thread that started the command.
- [x] **Passed:** Command output can be staged into `/vfs/artifacts`.
- [x] **Passed:** Local Linux source checkouts remain local when the local node can run the task.
- [x] **Passed:** The coordinator does not create a full-repo tarball, serve compiler reads, or receive source bytes in the normal local Linux path.
- [x] **Passed:** Linux Podman behavior is marked incomplete if Podman is unavailable in the release gate environment.
---
## 9. Windows attached node best-effort track
- [x] **Passed:** Windows node support uses the same coordinator session, task protocol, capability model, VFS/artifact API, and debug model as Linux.
- [x] **Passed:** Windows command execution is labeled as user-attached development execution, not production-grade managed sandboxing.
- [x] **Passed:** Windows sandboxing remains behind an explicit backend interface until independently validated.
- [x] **Passed:** The scheduler can represent `env!("windows")` requirements and select a Windows-capable node when one is available.
- [x] **Passed:** Windows tasks publish logs and artifact metadata through the same protocol as Linux.
- [x] **Passed:** VS Code/coordinator views can represent Windows tasks as virtual threads in the same virtual process.
- [x] **Passed:** Windows-specific code does not leak into Linux backend code or generic node runtime abstractions.
- [x] **Passed:** If real Windows validation is not run, docs and UI state that Windows support is unvalidated for the release.
- [x] **Passed:** Real Windows validation is not enabled for this release unless the manual Forgejo `Windows validation` workflow succeeds on the intermittent `windows` runner; that workflow runs `disasmer node attach`, executes a simple `windows-command-dev` command, publishes artifact metadata, checks placement, and verifies the debugger shows the Windows virtual thread.
---
## 10. Source providers and local-first source behavior
- [x] **Passed:** Git support is an optional source-provider module included by default.
- [x] **Passed:** VFS core builds and runs without depending on Git internals.
- [x] **Passed:** A non-Git source provider can be implemented through the public source-provider interface without changing VFS core code.
- [x] **Passed:** Source preparation can run as node work when the coordinator lacks checkout or provider access.
- [x] **Passed:** Coordinator-hosted capless orchestration can await “any capable node” for source preparation instead of failing immediately.
- [x] **Passed:** The default build example does not require coordinator-side Git, filesystem, shell, or container capability.
- [x] **Passed:** Local source bytes remain local for local capable builds.
- [x] **Passed:** Remote source transfer moves only required content or explicit snapshot chunks, not a default full-repo tarball.
- [x] **Passed:** Locality regressions are release blockers when the coordinator receives source bytes on the default local path.
---
## 11. Scheduler and placement
- [x] **Passed:** Placement uses logical environment requirements, node capabilities, source locality, environment cache state, dependency cache state, artifact locality, connectivity, and quota/policy state.
- [x] **Passed:** A compatible local/warm node is preferred over remote transfer unless policy or capability requirements prevent it.
- [x] **Passed:** Work can be placed on any capable node when the program asks for a capability rather than a specific node.
- [x] **Passed:** Scheduler errors explain missing capability, missing environment, source unavailability, quota denial, artifact unavailability, or connectivity failure.
- [x] **Passed:** Scheduler behavior crosses at least two node identities in the release gate environment.
- [x] **Passed:** The scheduler remains replaceable through a public module boundary.
- [x] **Passed:** The default scheduler never silently falls back to coordinator bulk relay to make placement succeed.
---
## 12. QUIC, rendezvous, and bulk transfer
- [x] **Passed:** QUIC transport is behind a transport abstraction.
- [x] **Passed:** The default MVP data-plane implementation uses a Rust-native QUIC stack.
- [x] **Passed:** Public docs and examples do not require a non-Rust QUIC implementation for the MVP default path.
- [x] **Passed:** Two nodes can establish an authenticated direct node-to-node connection when network conditions allow.
- [x] **Passed:** Coordinator-aided rendezvous exchanges endpoint candidates without becoming a silent bulk relay.
- [x] **Passed:** A node can request an authorized artifact, source, blob, or VFS chunk from another node over the data plane.
- [x] **Passed:** Each data-plane stream is scoped to tenant, project, process where applicable, object id, and authorization context.
- [x] **Passed:** Failed direct connectivity produces a clear placement/export/sync/download error.
- [x] **Passed:** The coordinator does not receive bulk source, artifact, blob, or build-cache bytes by default.
- [x] **Passed:** Any explicit coordinator-proxied download path is visible, authorized, metered, and never treated as default artifact storage.
---
## 13. VFS, artifacts, retention, and downloads
- [x] **Passed:** `flush()` publishes metadata and visibility information without uploading large bytes by default.
- [x] **Passed:** `sync()` is explicit and moves bytes only according to user code or configured policy.
- [x] **Passed:** Task-local VFS writes remain isolated until `flush()`.
- [x] **Passed:** Downstream tasks can consume flushed artifacts through handles.
- [x] **Passed:** Same-node artifact reuse avoids unnecessary copying where the platform supports it.
- [x] **Passed:** Artifact metadata includes tenant, project, process, producer task/thread, producer node, digest, size, retention/location, and flush epoch.
- [x] **Passed:** Artifact bytes are best-effort retained on nodes by default.
- [x] **Passed:** The coordinator has no default durable artifact store for large outputs.
- [x] **Passed:** Loss of an unsynced node-local artifact is surfaced as unavailable.
- [x] **Passed:** Artifact garbage collection is configurable and documented as best-effort retention, not durability.
- [x] **Passed:** Final artifact export works through an attached receiver node or explicit user-provided storage integration.
- [x] **Passed:** Control-panel artifact downloads stream from a retaining node or explicit user-provided storage path.
- [x] **Passed:** Download link creation fails before showing a link when retention, location, size, authorization, quota, connectivity, or community tier limits make the download impossible.
- [x] **Passed:** Download links are authenticated, authorized, scoped, expiring, revocable, and not usable merely by guessing a URL.
- [x] **Passed:** Download links are bound to the tenant, project, process, artifact, actor, and policy context.
- [x] **Passed:** Cross-tenant and cross-project download attempts are denied through the real download endpoint or API.
- [x] **Passed:** Download bytes are accounted before link creation and during streaming.
- [x] **Passed:** A download that becomes impossible mid-stream fails honestly and does not imply durability.
---
## 14. Debugging, DAP, and VS Code
- [x] **Passed:** VS Code F5 launches a Disasmer virtual process through the extension and DAP adapter.
- [ ] **Partial:** Live/debug launch starts the coordinator-side virtual process without requiring a node at launch; when that process reaches a virtual task with capability/environment requirements, the coordinator places that task on a capable node. The DAP adapter does not secretly spawn a worker node or expose a direct scheduler action as the programming model. Missing capable nodes produce normal placement/unavailable-capability state. Local coordinator/worker DAP smoke and quick-test binary smoke pass, and the deployed private hosted coordinator accepts the current `launch_task` / worker-assignment protocol; this remains partial until the released public-repo assets and VS Code/DAP path are rerun end to end against the live private hosted coordinator.
- [x] **Passed:** The DAP adapter presents one debug target with multiple virtual threads/tasks.
- [x] **Passed:** Ordinary VS Code breakpoints in `main` and spawned task code bind to real runtime stop points.
- [x] **Passed:** A breakpoint in one virtual thread creates a Debug Epoch for the whole virtual process.
- [x] **Passed:** Wasm tasks participate in all-stop behavior.
- [x] **Passed:** Controlled native-command tasks participate in all-stop behavior when supported by the backend.
- [x] **Passed:** If a participant cannot freeze, the debugger reports failure and does not claim all-stop succeeded.
- [x] **Passed:** Continue resumes every frozen participant in the Debug Epoch.
- [x] **Passed:** Pause stops the whole virtual process, not only the selected thread.
- [x] **Passed:** The debugger shows coherent virtual process identity, virtual thread names, stack frames, task args, Disasmer handles, command status, recent output, and artifact references for the current flagship demo.
- [x] **Passed:** The normal debugger Variables pane exposes the MVP launch variable set: task arguments, task return values, target structured fields, selected Rust locals around Disasmer API calls, runtime-captured Wasmtime frame locals, `Artifact`, `SourceSnapshot`, and `Blob` handles, current VFS mounts, current command spec/status, and recent stdout/stderr tail.
- [x] **Passed:** Selected top-level locals around Disasmer API calls are visible in the normal debugger Variables pane from virtual-thread runtime state. This is implemented outside `experiments/**`; experiment-local hardcoded variable mapping does not satisfy this criterion.
- [x] **Passed:** The shippable node/DAP path captures real Wasmtime frame-local slots from guest-debug runtime state and exposes them through a normal DAP Variables scope. This is implemented outside `experiments/**`; the experiment remains proof only, not implementation basis.
- [x] **Passed:** Arbitrary Rust locals use best-effort source metadata when full value snapshots are unavailable, and variables that cannot be inspected are reported clearly while task args and handles remain visible.
- [x] **Passed:** Per-task stdout/stderr tails, VFS/artifact state, and command status are visible through normal scopes or optional Disasmer views without requiring users to understand node IDs, transport links, VFS manifests, or environment-cache internals.
- [x] **Passed:** The debugger does not claim to debug arbitrary native child process internals unless that support exists.
- [x] **Passed:** Restart selected or failed task works after a compatible source edit.
- [x] **Passed:** Incompatible edits require whole-process restart with a clear message.
- [x] **Passed:** The DAP adapter remains a standard DAP server so non-VS Code DAP clients can integrate later without a proprietary protocol.
- [x] **Passed:** The launch-critical DAP surface required by the MVP is implemented and release-tested: `initialize`, `launch`/`attach`, `setBreakpoints`, `configurationDone`, `threads`, `stackTrace`, `scopes`, `variables`, `continue`, `pause`, `next`/step-over at probe granularity, `disconnect`, `stopped` events, and `continued` events.
- [x] **Passed:** VS Code views show nodes, virtual processes, logs, artifacts, and inspector state without replacing the normal debugger UX.
---
## 15. Operator/control panel foundations
- [x] **Passed:** Protocol and state foundations exist for immediate-mode operator panels.
- [x] **Passed:** The MVP exposes a built-in control panel or panel preview for process status, tasks, logs, artifacts, and control-plane actions.
- [x] **Passed:** Program-defined panel content uses only built-in typed widgets.
- [x] **Passed:** User-provided panel content cannot inject custom HTML or JavaScript.
- [x] **Passed:** Panel state is scoped to tenant, project, and virtual process.
- [x] **Passed:** Panel events are typed, rate-limited, and delivered to the virtual process only when allowed.
- [x] **Passed:** Operator panels cannot collect passwords, tokens, secrets, OAuth codes, or OAuth-like credentials.
- [x] **Passed:** A stopped debug process shows the last rendered panel state without executing program UI logic.
- [x] **Passed:** Control-plane actions such as restart, cancel, debug, and artifact download remain available while program UI events are disabled.
- [x] **Passed:** Artifact download buttons use the same secure download semantics as the artifact API.
---
## 16. Abuse controls and resource limits
- [x] **Passed:** API calls, spawns, logs, metadata, debug reads, UI events, rendezvous attempts, artifact downloads, and hosted zero-capability Wasm resources are metered through policy before work starts.
- [x] **Passed:** Log output is capped, backpressured, and associated with the producing virtual thread.
- [x] **Passed:** Large task arguments are rejected or warned before dispatch can cause accidental distributed copies.
- [x] **Passed:** Community hosted tasks cannot use network, secrets, host filesystem, native commands, containers, inbound ports, or arbitrary syscalls.
- [x] **Passed:** Quota counters cannot be bypassed by reconnecting a node, restarting a CLI, retrying an API call, or switching tenants/projects without authorization.
- [x] **Passed:** Public hosted endpoints validate users, nodes, programs, logs, artifacts, UI events, source manifests, capabilities, debug requests, and download requests as hostile input.
- [x] **Passed:** Abuse controls are enforced at the running hosted service boundary, not only in internal model code.
- [x] **Passed:** Community tier language is used consistently in user-facing docs and UI.
- [x] **Passed:** Public-facing docs and repository text avoid naming a specific launch forum or traffic source as a product goal.
---
## 17. Packaging, docs, and first-run UX
- [x] **Passed:** README quickstart covers install/build, local coordinator, node attach, demo run, VS Code debug, artifacts, and cleanup.
- [x] **Passed:** Docs explain `flush()` versus `sync()` without implying default durability.
- [x] **Passed:** Docs explain best-effort artifact retention and explicit export/storage.
- [x] **Passed:** Docs explain secure artifact downloads and why download links may fail before creation.
- [x] **Passed:** Docs explain node trust, user-attached execution, and Windows sandbox limitations honestly.
- [x] **Passed:** Docs explain hosted/community limits without framing community tier as arbitrary hosted compute.
- [x] **Passed:** Docs explain browser login and public-key authentication for agents.
- [x] **Passed:** Docs explain `disasmer run [entry]`, implicit hosted mode, local override, and project override.
- [x] **Passed:** Docs explain auto-detected node capabilities and `--cap` override behavior.
- [x] **Passed:** Docs explain how to add non-Git source-provider support.
- [x] **Passed:** VS Code extension packaging or documented local install works from a clean checkout.
- [x] **Passed:** First-run diagnostics guide users toward missing nodes, missing environments, quota limits, unavailable artifacts, auth failures, failed debug freezes, and source-provider capability gaps.
---
## 18. Flagship release matrix
- [x] **Passed:** Public local Linux demo: clean checkout, build/install CLI and extension, attach Linux node, run flagship build, debug in VS Code, inspect logs/artifacts, export or download final artifact.
- [x] **Passed:** Hosted/community Linux demo: browser login, project creation, node enrollment, task run on user node, debug, logs, artifact metadata, secure artifact download, and no hosted native/container compute.
- [x] **Passed:** Public split demo: same public local workflow succeeds after excluding `private/**`.
- [x] **Passed:** Hosted private demo: hosted workflow succeeds with private OIDC/policy/admin modules included.
- [x] **Passed:** Breakpoint in main all-stops the virtual process.
- [x] **Passed:** Breakpoint in a Linux task all-stops the virtual process.
- [ ] **Partial:** The release demo lets the user inspect task args, selected locals around Disasmer API calls, runtime-captured Wasmtime frame locals, command status, logs, stdout/stderr tail, VFS mounts, and artifact handles from the normal VS Code debugging experience. Current DAP/F5 evidence covers these debugger behaviors, including selected Disasmer API source-local values and Wasmtime frame-local slots; the public release dry-run e2e still needs to be rerun later with the released public-repo assets.
- [x] **Passed:** Failed task restart works after compatible source edit.
- [x] **Passed:** Incompatible edit requires whole-process restart with a clear message.
- [x] **Passed:** Local-first no-upload assertions pass during the release demo.
- [x] **Passed:** Community tier quota enforcement and cross-tenant forbidden-access checks pass during hosted acceptance.
- [x] **Passed:** Demo and docs avoid coordinator-side filesystem, Git, shell, container, durable artifact store, or hard-coded machine assumptions.
- [x] **Passed:** Windows demo steps are included only when the current release target enables Windows validation.
---
## 18a. Public release dry run
- [ ] **Partial:** A public release dry run deploys the real platform on a public network path so it is reachable as `disasmer.michelpaulissen.com:9443`; when the DNS record is live, no resolver override is required, and before propagation access may use an explicit test resolver, hosts entry, or equivalent controlled resolution path, but the service is not a loopback-only, local-only, or mock deployment. Earlier service evidence exists, but current-release acceptance remains partial until the live service smoke is rerun for the current acceptance commit.
- [ ] **Partial:** The dry run can be shared with selected external users, such as friends, by giving them the public repository/release assets and either public DNS access or controlled fallback resolution instructions for `disasmer.michelpaulissen.com:9443`; the platform and public repo are live, but current Forgejo Release assets are prepared locally and not yet published.
- [x] **Passed:** The deployed `disasmer.michelpaulissen.com:9443` platform is the default operator endpoint used by the public CLI/extension for the dry run, not a mock, local-only coordinator, or documentation-only placeholder.
- [ ] **Partial:** The deployed default operator is explicitly the private hosted coordinator from `private/hosted-policy`, with private hosted modules enabled. The phrase `public release dry run` means public Forgejo repository, public release assets, selected-user network reachability, and public client protocol compatibility; it does not mean deploying the standalone public/open-source coordinator as the hosted operator. Current-release proof still needs a fresh live service smoke for the current acceptance commit.
- [ ] **Partial:** The dry run validates both coordinator implementations: the private hosted coordinator through the live default-operator deployment and service/e2e evidence, and the standalone public/open-source coordinator through the filtered public repository and public release binaries in a local/self-hosted coordinator smoke. The public tree and local assets are current, but release/e2e evidence is stale and must be rerun from the fresh Forgejo Release.
- [ ] **Partial:** The dry-run platform runs the real hosted/control-plane service path with private hosted modules included and can coordinate browser or CLI login, project selection, node enrollment, task execution, debug state, logs, artifact metadata, and secure artifact download/export. Current-release proof requires rerunning the hosted service smoke.
- [x] **Passed:** The dry run includes a real browser-facing account/login website served from `disasmer.michelpaulissen.com` through the VPS nginx configuration, woven into the same public deployment path rather than hosted as a separate mock site or local-only helper.
- [x] **Passed:** The dry-run website is deliberately barebones functional HTML with no CSS and no layout or UX optimization work hidden in this phase; a later acceptance pass may add explicit good-UX criteria.
- [x] **Passed:** The dry-run public repository is a real public repository on the Forgejo instance at `git.michelpaulissen.com`, produced by filtering out private release-only material, including `private/**` and `experiments/**`, not only a local temporary tree.
- [ ] **Partial:** The Forgejo public repository can build/install the public SDK, CLI, coordinator/node runtime, DAP adapter, VS Code extension, protocol crates, and examples, and can use the deployed default operator without importing private source. The filtered public tree for the current acceptance commit is pushed and local release assets build, but default-operator use still needs current released-asset e2e proof.
- [ ] **Partial:** A Forgejo Release for the dry-run public repository publishes compiled release assets for the supported public binaries and extension package, so a user can download the appropriate artifacts from `git.michelpaulissen.com` and start without building from source. The current source archive, platform binary archive, VS Code extension VSIX, invite, getting-started guide, and checksums are prepared locally for the current acceptance commit, but not yet uploaded as Forgejo Release assets.
- [ ] **Partial:** A full e2e dry-run test uses that filtered public repository against the deployed `disasmer.michelpaulissen.com:9443` service, covering build/install, default operator selection, auth, attaching a user node, running the flagship workflow, VS Code/debugger behavior, logs, artifact metadata, and artifact download or explicit export. Existing e2e evidence is stale; the current release must be verified end to end from the Forgejo Release assets.
- [ ] **Partial:** The dry run records the public source commit, filtered public tree identity, deployed service commit/version, default operator endpoint, DNS-publication state, resolver requirement or lack of required override, environment/tool versions, and acceptance commands/results. Current local preparation records the current source and filtered public tree identities, but final evidence must be regenerated after Forgejo Release upload, deployment/service smoke, and e2e verification.
- [x] **Passed:** The dry-run instructions make a later fresh-domain and public GitHub-release reproduction mechanically straightforward, but actually performing that fresh-domain/public-GitHub release is explicitly out of scope for this dry run.
---
## 19. Non-goals preserved
- [x] **Passed:** The MVP does not imply transparent raw remote pointers.
- [x] **Passed:** The MVP does not imply global consensus on every memory access.
- [x] **Passed:** The MVP does not imply live native process migration.
- [x] **Passed:** The MVP does not imply live stack migration, live socket checkpointing, or arbitrary hot code replacement.
- [x] **Passed:** The MVP does not imply durable artifacts without explicit export/storage policy.
- [x] **Passed:** The MVP does not imply public hosted compute for arbitrary workloads.
- [x] **Passed:** The MVP does not imply secure managed Windows compute.
- [x] **Passed:** The MVP does not require users to understand distributed internals for the normal build/debug path.
- [x] **Passed:** The MVP does not require the coordinator to own source bytes, artifact bytes, build caches, or command execution in the default path.
---
## 20. Release blockers
Any item below blocks MVP acceptance until resolved:
- [x] **Passed (blocker absent):** Public or private runtime behavior depends on `experiments/**` implementation code.
- [x] **Passed (blocker absent):** Public source cannot build and run the public local workflow after excluding `private/**`.
- [x] **Passed (blocker absent):** Hosted coordinator can run arbitrary native commands or containers for a community tier user.
- [x] **Passed (blocker absent):** Hosted capless Wasm can obtain network, host filesystem, secrets, native commands, containers, inbound ports, or arbitrary syscalls.
- [x] **Passed (blocker absent):** Coordinator receives bulk source, artifact, blob, or build-cache bytes on a default path where the MVP says bytes stay node-local or move directly.
- [x] **Passed (blocker absent):** Cross-tenant access succeeds for projects, nodes, processes, logs, artifacts, downloads, debug state, panels, capabilities, source manifests, credentials, or metadata.
- [x] **Passed (blocker absent):** A node can claim or publish outside its authorized tenant, project, process, or task scope.
- [x] **Passed (blocker absent):** A debugger reports all-stop success when any controlled participant failed to freeze.
- [x] **Passed (blocker absent):** Artifact download link creation succeeds when the artifact cannot be downloaded under current retention, location, authorization, connectivity, size, or quota constraints.
- [x] **Passed (blocker absent):** Artifact download links are usable by guessing, sharing to an unauthorized actor, or bypassing tenant/project/process authorization.
- [x] **Passed (blocker absent):** The default system behaves as if a durable coordinator artifact store exists when the user did not explicitly configure one.
- [x] **Passed (blocker absent):** Coordinator restart pretends to preserve live virtual processes or stale node-owned execution state.
- [x] **Passed (blocker absent):** `disasmer run` or the flagship example requires coordinator-side Git, shell, container, or source-checkout access.
- [x] **Passed (blocker absent):** Windows docs or UI imply production-grade managed Windows sandboxing before such sandboxing exists and has been validated.
- [x] **Passed (blocker absent):** The flagship demo requires undocumented manual state, hard-coded local paths, demo-only credentials, or hidden setup.
- [x] **Passed (blocker absent):** User-facing docs, plans, examples, or UI name a specific external launch forum as a product goal.
- [ ] **Partial (blocker absence source-proven, current-release proof pending):** Public released binaries only print a raw JSON login plan for normal human browser login, or the deployed default operator lacks a real browser/account login web surface served through `disasmer.michelpaulissen.com`. Source and local smoke coverage prove the intended browser path, but current Forgejo Release binaries and deployed nginx/Auth evidence must be rerun for the current acceptance commit.

View file

@ -0,0 +1,20 @@
[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
clap.workspace = true
disasmer-core = { path = "../disasmer-core" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
[package]
name = "disasmer-coordinator"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
disasmer-core = { path = "../disasmer-core" }
postgres.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,709 @@
use std::collections::{BTreeMap, BTreeSet};
use disasmer_core::{
Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant,
NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub mod postgres_store;
pub mod service;
pub use postgres_store::{
PostgresDurableStore, PostgresStoreError, PostgresTable, POSTGRES_DURABLE_TABLES,
};
pub use service::{
CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError,
SourcePreparationDisposition, SourcePreparationStatus, TaskAssignment, TaskCompletionEvent,
TaskTerminalState,
};
#[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<ProjectId>,
pub kind: CredentialKind,
pub public_key_fingerprint: Option<Digest>,
}
#[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 ProjectPermissionRecord {
pub tenant: TenantId,
pub project: ProjectId,
pub user: UserId,
pub can_debug: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DurableState {
pub tenants: BTreeMap<TenantId, TenantRecord>,
pub users: BTreeMap<UserId, UserRecord>,
pub projects: BTreeMap<ProjectId, ProjectRecord>,
pub node_identities: BTreeMap<NodeId, NodeIdentityRecord>,
pub credentials: BTreeMap<String, CredentialRecord>,
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 trait DurableStore {
fn load(&self) -> DurableState;
fn save(&mut self, state: DurableState);
}
pub trait FallibleDurableStore {
type Error;
fn load_state(&mut self) -> Result<DurableState, Self::Error>;
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;
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActiveProcess {
pub id: ProcessId,
pub tenant: TenantId,
pub project: ProjectId,
pub connected_nodes: BTreeSet<NodeId>,
pub coordinator_epoch: u64,
}
#[derive(Clone, Debug)]
pub struct Coordinator {
durable: DurableState,
active_processes: BTreeMap<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<S: FallibleDurableStore>(
store: &mut S,
coordinator_epoch: u64,
) -> Result<Self, S::Error> {
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<S: FallibleDurableStore>(&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<String>) {
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<String>,
enrollment_scope: impl Into<String>,
) {
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<String>,
scope: impl Into<String>,
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<NodeCredential, CoordinatorError> {
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<String>,
digest: Digest,
) {
let name = name.into();
self.durable.service_policy_records.insert(
(tenant.clone(), name.clone()),
ServicePolicyRecord {
tenant,
name,
digest,
},
);
}
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 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(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(),
));
}
let Some(active) = self.active_processes.get(process) else {
return Err(CoordinatorError::Unauthorized(
"virtual process is not active in coordinator memory".to_owned(),
));
};
if &active.tenant != tenant || &active.project != project {
return Err(CoordinatorError::Unauthorized(
"node cannot claim tasks or publish artifacts outside its process scope".to_owned(),
));
}
Ok(())
}
pub fn reconnect_node(
&mut self,
node: &NodeId,
process: Option<(&ProcessId, u64)>,
) -> Result<(), CoordinatorError> {
if !self.durable.node_identities.contains_key(node) {
return Err(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,
});
}
if let Some(active) = self.active_processes.get_mut(process_id) {
active.connected_nodes.insert(node.clone());
}
}
Ok(())
}
pub fn list_projects(&self, context: &AuthContext) -> Vec<ProjectRecord> {
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_processes.get(process) else {
return Authorization::deny("virtual process is not active");
};
if active.tenant != context.tenant || active.project != context.project {
return Authorization::deny("tenant or project mismatch");
}
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, id: &ProcessId) -> Option<&ActiveProcess> {
self.active_processes.get(id)
}
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 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(&process)
.unwrap()
.connected_nodes
.contains(&NodeId::from("node")));
}
#[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 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 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);
}
}

View file

@ -0,0 +1,22 @@
use std::io::Write;
use disasmer_coordinator::{service::bind_listener, CoordinatorService};
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut listen = "127.0.0.1:0".to_owned();
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")?;
}
}
let (listener, addr) = bind_listener(&listen)?;
println!("{}", json!({ "listen": addr.to_string() }));
std::io::stdout().flush()?;
let service = CoordinatorService::new(1);
service.serve_tcp(listener)?;
Ok(())
}

View file

@ -0,0 +1,459 @@
use postgres::{Client, NoTls};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
use crate::{
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_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<Self, PostgresStoreError> {
let mut store = Self {
client: Client::connect(connection_string, NoTls)?,
};
store.migrate()?;
Ok(store)
}
pub fn from_client(client: Client) -> Result<Self, PostgresStoreError> {
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<T: DeserializeOwned>(
&mut self,
sql: &str,
) -> Result<Vec<T>, 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<serde_json::Value, PostgresStoreError> {
Ok(serde_json::to_value(record)?)
}
}
impl FallibleDurableStore for PostgresDurableStore {
type Error = PostgresStoreError;
fn load_state(&mut self) -> Result<DurableState, Self::Error> {
let mut state = DurableState::default();
for record in self.query_records::<TenantRecord>(
"SELECT record FROM disasmer_tenants ORDER BY tenant_id",
)? {
state.tenants.insert(record.id.clone(), record);
}
for record in
self.query_records::<UserRecord>("SELECT record FROM disasmer_users ORDER BY user_id")?
{
state.users.insert(record.id.clone(), record);
}
for record in self.query_records::<ProjectRecord>(
"SELECT record FROM disasmer_projects ORDER BY project_id",
)? {
state.projects.insert(record.id.clone(), record);
}
for record in self.query_records::<NodeIdentityRecord>(
"SELECT record FROM disasmer_node_identities ORDER BY node_id",
)? {
state.node_identities.insert(record.id.clone(), record);
}
for record in self.query_records::<CredentialRecord>(
"SELECT record FROM disasmer_credentials ORDER BY subject",
)? {
state.credentials.insert(record.subject.clone(), record);
}
for record in self.query_records::<SourceProviderConfigRecord>(
"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::<ServicePolicyRecord>(
"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::<ProjectPermissionRecord>(
"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_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 ((_, _, 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_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::<Vec<_>>();
assert_eq!(names.len(), 8);
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_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<DurableState, Self::Error> {
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(),
},
);
store.save_state(&state).unwrap();
let loaded = store.load_state().unwrap();
assert!(loaded.projects.contains_key(&ProjectId::from("project")));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
[package]
name = "disasmer-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
hex.workspace = true
sha2.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -0,0 +1,939 @@
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, TaskId, TenantId,
};
#[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, 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 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: TaskId,
pub producer_node: NodeId,
pub digest: Digest,
pub size: u64,
pub flushed_epoch: u64,
pub retaining_nodes: BTreeSet<NodeId>,
pub explicit_locations: Vec<String>,
pub coordinator_has_large_bytes: bool,
}
#[derive(Clone, Debug, Default)]
pub struct ArtifactRegistry {
artifacts: BTreeMap<ArtifactId, ArtifactMetadata>,
issued_download_links: BTreeMap<Digest, IssuedDownloadLink>,
next_epoch: u64,
}
impl ArtifactRegistry {
pub fn flush_metadata(
&mut self,
id: ArtifactId,
tenant: TenantId,
project: ProjectId,
process: ProcessId,
producer_task: TaskId,
retaining_node: NodeId,
digest: Digest,
size: u64,
) -> ArtifactMetadata {
self.next_epoch += 1;
let metadata = ArtifactMetadata {
id: id.clone(),
tenant,
project,
process,
producer_task,
producer_node: retaining_node.clone(),
digest,
size,
flushed_epoch: self.next_epoch,
retaining_nodes: BTreeSet::from([retaining_node]),
explicit_locations: Vec::new(),
coordinator_has_large_bytes: false,
};
self.artifacts.insert(id, metadata.clone());
metadata
}
pub fn sync_to_explicit_store(
&mut self,
artifact: &ArtifactId,
location: impl Into<String>,
) -> 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 metadata(&self, artifact: &ArtifactId) -> Option<&ArtifactMetadata> {
self.artifacts.get(artifact)
}
pub fn download_action(
&self,
context: &AuthContext,
artifact: &ArtifactId,
policy: &DownloadPolicy,
) -> Result<DownloadAction, DownloadError> {
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<u64, DownloadError> {
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<DownloadLink, DownloadError> {
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(),
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<DownloadLink, DownloadError> {
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,
context: &AuthContext,
artifact: &ArtifactId,
policy: &DownloadPolicy,
presented_token_digest: &Digest,
now_epoch_seconds: u64,
limits: &ResourceLimits,
meter: &mut ResourceMeter,
) -> Result<ArtifactDownloadStream, DownloadError> {
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)
}
}
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(
ArtifactId::from("artifact"),
TenantId::from("tenant"),
ProjectId::from("project"),
ProcessId::from("process"),
TaskId::from("task"),
NodeId::from("node"),
Digest::sha256("bytes"),
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, TaskId::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 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(
&other_actor,
&ArtifactId::from("artifact"),
&policy,
&link.scoped_token_digest,
11,
&limits,
&mut meter,
)
.unwrap_err(),
DownloadError::InvalidToken
);
assert_eq!(
registry
.open_download_stream(
&context,
&ArtifactId::from("artifact"),
&DownloadPolicy { max_bytes: 99 },
&link.scoped_token_digest,
11,
&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(
&context,
&ArtifactId::from("artifact"),
&policy,
&expired.scoped_token_digest,
16,
&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(
&context,
&ArtifactId::from("artifact"),
&policy,
&active.scoped_token_digest,
21,
&limits,
&mut meter,
)
.unwrap_err();
assert_eq!(error, DownloadError::Revoked);
}
#[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(
&context,
&ArtifactId::from("artifact"),
&policy,
&link.scoped_token_digest,
11,
&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(
&context,
&ArtifactId::from("artifact"),
&policy,
&link.scoped_token_digest,
11,
&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(
&context,
&ArtifactId::from("artifact"),
&DownloadPolicy { max_bytes: 100 },
&Digest::sha256("guessed"),
11,
&limits,
&mut meter,
)
.unwrap_err();
assert_eq!(error, DownloadError::InvalidToken);
}
}

View file

@ -0,0 +1,369 @@
use serde::{Deserialize, Serialize};
use crate::{AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Actor {
User(UserId),
Agent(AgentId),
Node(NodeId),
Task(TaskId),
}
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 CliLoginFlow {
pub verification_url: String,
pub user_code: String,
pub device_code: String,
pub expires_in_seconds: u64,
pub yields_long_lived_secret_directly: bool,
}
#[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 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<NodeCredential, EnrollmentError> {
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(),
])
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Scope {
pub tenant: TenantId,
pub project: ProjectId,
pub process: Option<ProcessId>,
pub task: Option<TaskId>,
pub node: Option<NodeId>,
pub artifact: Option<ArtifactId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Authorization {
pub allowed: bool,
pub reason: String,
}
impl Authorization {
pub fn allow(reason: impl Into<String>) -> Self {
Self {
allowed: true,
reason: reason.into(),
}
}
pub fn deny(reason: impl Into<String>) -> 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 task_credentials_reject_user_session_tokens() {
for credential in [
CredentialKind::BrowserSession,
CredentialKind::CliDeviceSession,
] {
let authz = task_credentials_do_not_contain_user_session(
&Actor::Task(TaskId::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(TaskId::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(TaskId::from("task")).kind(), IdentityKind::Task);
let scope = Scope {
tenant: TenantId::from("tenant"),
project: ProjectId::from("project"),
process: Some(ProcessId::from("process")),
task: Some(TaskId::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
);
}
}

View file

@ -0,0 +1,117 @@
use serde::{Deserialize, Serialize};
use crate::{Digest, EnvironmentResource};
#[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 environments: Vec<EnvironmentResource>,
pub source_provider_manifest: Digest,
pub selected_inputs: Vec<SelectedInput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleMetadata {
pub identity: Digest,
pub environments: Vec<EnvironmentResource>,
pub selected_inputs: Vec<SelectedInput>,
pub embeds_full_container_images: 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(),
];
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 {
BundleMetadata {
identity: self.identity(),
environments: self.environments.clone(),
selected_inputs: self.selected_inputs.clone(),
embeds_full_container_images: false,
}
}
}
#[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"),
environments: vec![env("recipe-a")],
source_provider_manifest: Digest::sha256("source"),
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"),
environments: vec![env("recipe")],
source_provider_manifest: Digest::sha256("source"),
selected_inputs: vec![SelectedInput {
path: "inputs/config.json".to_owned(),
digest: Digest::sha256("config"),
}],
};
let metadata = inputs.inspectable_metadata();
assert_eq!(metadata.environments.len(), 1);
assert!(!metadata.embeds_full_container_images);
}
}

View file

@ -0,0 +1,189 @@
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,
Wasmtime,
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<Capability>,
pub environment_backends: BTreeSet<EnvironmentBackend>,
pub source_providers: BTreeSet<String>,
}
#[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,
Capability::Wasmtime,
]);
let mut environment_backends = BTreeSet::new();
match os {
Os::Linux => {
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<Capability>) -> 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(())
}
}
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()
))
);
}
}

View file

@ -0,0 +1,282 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{Digest, EnvironmentResource, TaskId, 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: TaskId,
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: TaskId,
pub entrypoint: String,
pub serialized_args: Digest,
pub environment: EnvironmentResource,
pub task_abi: Digest,
pub source_edited: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RestartDecision {
RestartTask {
task: TaskId,
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<CompatibilityFailure> {
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, 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(TaskId::from("task"), NodeId::from("node"));
overlay.write(
VfsPath::new("/vfs/artifacts/app").unwrap(),
Digest::sha256("app"),
3,
);
let manifest = overlay.flush();
(
TaskCheckpoint {
task: TaskId::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: TaskId::from("task"),
entrypoint: "compile_linux".to_owned(),
serialized_args: Digest::sha256("args"),
environment,
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: TaskId::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");
}
}

View file

@ -0,0 +1,276 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{ProcessId, TaskId};
#[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: TaskId,
pub name: String,
pub kind: DebugParticipantKind,
pub can_freeze: bool,
pub state: DebugRuntimeState,
pub stack_frames: Vec<String>,
pub local_values: Vec<(String, String)>,
pub task_args: Vec<(String, String)>,
pub handles: Vec<(String, String)>,
pub command_status: Option<String>,
pub recent_output: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DebugStopReason {
Breakpoint { task: TaskId, line: u32 },
PauseRequest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreadInspection {
pub task: TaskId,
pub name: String,
pub stack_frames: Vec<String>,
pub local_values: Vec<(String, String)>,
pub task_args: Vec<(String, String)>,
pub handles: Vec<(String, String)>,
pub command_status: Option<String>,
pub recent_output: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugEpoch {
pub process: ProcessId,
pub epoch: u64,
pub reason: DebugStopReason,
participants: BTreeMap<TaskId, DebugParticipant>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum DebugEpochError {
#[error("participant `{task}` cannot freeze, so all-stop failed")]
CannotFreeze { task: TaskId },
#[error("participant `{0}` is not part of this debug epoch")]
UnknownParticipant(TaskId),
}
impl DebugEpoch {
pub fn all_stop(
process: ProcessId,
epoch: u64,
reason: DebugStopReason,
participants: Vec<DebugParticipant>,
) -> Result<Self, DebugEpochError> {
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<DebugParticipant>,
) -> Result<Self, DebugEpochError> {
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: &TaskId) -> Result<ThreadInspection, DebugEpochError> {
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: &TaskId) -> Option<&DebugRuntimeState> {
self.participants
.get(task)
.map(|participant| &participant.state)
}
pub fn thread_names(&self) -> Vec<String> {
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: TaskId::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: TaskId::from("compile-linux"),
line: 42,
},
vec![
participant("main", DebugParticipantKind::WasmTask, true),
participant(
"compile-linux",
DebugParticipantKind::ControlledNativeCommand,
true,
),
],
)
.unwrap();
assert_eq!(
epoch.participant_state(&TaskId::from("main")),
Some(&DebugRuntimeState::Frozen)
);
assert_eq!(
epoch.participant_state(&TaskId::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(&TaskId::from("main")),
Some(&DebugRuntimeState::Running)
);
assert_eq!(
epoch.participant_state(&TaskId::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(&TaskId::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"]);
}
}

View file

@ -0,0 +1,56 @@
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<Item = impl AsRef<[u8]>>) -> 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 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());
}
}

View file

@ -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<Os>,
pub arch: Option<String>,
pub capabilities: BTreeSet<Capability>,
}
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<Vec<EnvironmentResource>, 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<EnvironmentDiagnostic> {
let known = environments
.iter()
.map(|environment| environment.name.as_str())
.collect::<BTreeSet<_>>();
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<Option<EnvironmentResource>, 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<EnvironmentReference> {
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));
}
}

View file

@ -0,0 +1,90 @@
use serde::{Deserialize, Serialize};
use crate::{Capability, EnvironmentResource};
#[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)]
pub struct CommandInvocation {
pub program: String,
pub args: Vec<String>,
pub env: Option<EnvironmentResource>,
}
#[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),
SourceSnapshot(crate::Digest),
Blob(crate::Digest),
Artifact(crate::ArtifactId),
VfsManifest(crate::Digest),
}
impl TaskBoundaryValue {
pub fn reject_host_only(type_name: &str) -> Result<Self, String> {
Err(format!(
"task boundary value `{type_name}` is host-only; use small serialized data or handles"
))
}
}
#[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"));
}
}

View file

@ -0,0 +1,44 @@
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<String>) -> 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!(TaskId);
id_type!(TenantId);
id_type!(UserId);

View file

@ -0,0 +1,75 @@
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 use artifact::{
ArtifactDownloadStream, ArtifactMetadata, ArtifactRegistry, ArtifactUnavailable,
DownloadAction, DownloadError, DownloadLink, DownloadPolicy, RetentionPolicy, StorageLocation,
};
pub use auth::{
node_capability_policy_digest, Action, Actor, AuthContext, Authorization, BrowserLoginFlow,
CliLoginFlow, CredentialKind, EnrollmentError, EnrollmentGrant, IdentityKind, NodeCredential,
PublicKeyIdentity, Scope,
};
pub use bundle::{BundleIdentityInputs, BundleMetadata, 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, CommandPlan, GuestRuntimeKind, NativeCommandPolicy,
TaskBoundaryValue,
};
pub use ids::{AgentId, ArtifactId, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId};
pub use limits::{
LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits,
ResourceMeter, TaskArgumentBudget,
};
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,
};

View file

@ -0,0 +1,255 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::TaskId;
#[derive(Clone, 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<LimitKind, u64>,
}
impl ResourceLimits {
pub fn community_tier_defaults() -> Self {
Self {
limits: BTreeMap::from([
(LimitKind::ApiCall, 10_000),
(LimitKind::Spawn, 64),
(LimitKind::LogBytes, 256 * 1024),
(LimitKind::MetadataBytes, 512 * 1024),
(LimitKind::DebugReadBytes, 128 * 1024),
(LimitKind::UiEvent, 1_000),
(LimitKind::RendezvousAttempt, 128),
(LimitKind::ArtifactDownloadBytes, 256 * 1024 * 1024),
(LimitKind::HostedFuel, 10_000_000),
(LimitKind::HostedMemoryBytes, 64 * 1024 * 1024),
(LimitKind::HostedWallClockMs, 30_000),
(LimitKind::HostedStateBytes, 1024 * 1024),
]),
}
}
pub fn limit(&self, kind: &LimitKind) -> u64 {
*self.limits.get(kind).unwrap_or(&0)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceMeter {
used: BTreeMap<LimitKind, u64>,
}
#[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.clone(), 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: TaskId,
pub bytes: Vec<u8>,
pub truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogBuffer {
max_bytes: usize,
used_bytes: usize,
records: Vec<LogRecord>,
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: TaskId, 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<Option<String>, 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(TaskId::from("task-a"), b"abcdef");
assert!(logs.backpressured());
assert_eq!(logs.records()[0].task, TaskId::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"));
}
}

View file

@ -0,0 +1,375 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskId, 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<String>,
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(TaskId),
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<String, PanelWidget>,
pub program_ui_events_enabled: bool,
pub control_plane_actions: Vec<ControlPlaneAction>,
}
#[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<String>,
label: impl Into<String>,
action: Result<DownloadAction, DownloadError>,
) -> 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<ControlPlaneAction>) {
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("<script>alert(1)</script>").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(TaskId::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(_)));
}
}

View file

@ -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<Capability>,
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<T> 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);
}
}

View file

@ -0,0 +1,125 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
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<EnvironmentResource>,
pub entrypoints: BTreeMap<String, Entrypoint>,
pub default_entrypoint: String,
pub required_config_file: Option<PathBuf>,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ProjectModelError {
#[error("environment discovery failed: {0}")]
Environment(String),
#[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")]
UnknownEntrypoint {
name: String,
available: Vec<String>,
},
}
impl ProjectModel {
pub fn discover_without_config(root: &Path) -> Result<Self, ProjectModelError> {
let environments = discover_environments(root).map_err(|err| {
ProjectModelError::Environment(match err {
EnvironmentError::Read { path, source } => {
format!("failed to read {}: {source}", path.display())
}
})
})?;
Ok(Self {
root: root.to_path_buf(),
environments,
entrypoints: default_entrypoints(),
default_entrypoint: "build".to_owned(),
required_config_file: None,
})
}
pub fn select_entrypoint(&self, name: Option<&str>) -> Result<&Entrypoint, ProjectModelError> {
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 default_entrypoints() -> BTreeMap<String, Entrypoint> {
["build", "test", "package", "release", "watch"]
.into_iter()
.map(|name| {
(
name.to_owned(),
Entrypoint {
name: name.to_owned(),
function: format!("{name}_main"),
},
)
})
.collect()
}
#[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::write(
temp.path().join("envs/linux/Containerfile"),
"FROM alpine\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();
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
assert_eq!(
model.select_entrypoint(Some("test")).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();
let model = ProjectModel::discover_without_config(temp.path()).unwrap();
let error = model.select_entrypoint(Some("deploy")).unwrap_err();
assert!(matches!(error, ProjectModelError::UnknownEntrypoint { .. }));
}
}

View file

@ -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<Digest>,
pub dependency_caches: BTreeSet<Digest>,
pub source_snapshots: BTreeSet<Digest>,
pub artifact_locations: BTreeSet<ArtifactId>,
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<EnvironmentRequirements>,
pub environment_digest: Option<Digest>,
pub required_capabilities: BTreeSet<Capability>,
pub dependency_cache: Option<Digest>,
pub source_snapshot: Option<Digest>,
pub required_artifacts: BTreeSet<ArtifactId>,
pub quota_available: bool,
pub policy_allowed: bool,
pub prefer_node: Option<NodeId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Placement {
pub node: NodeId,
pub score: i64,
pub reasons: Vec<String>,
}
#[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<Placement, PlacementError>;
}
#[derive(Clone, Debug, Default)]
pub struct DefaultScheduler;
impl Scheduler for DefaultScheduler {
fn place(
&self,
nodes: &[NodeDescriptor],
request: &PlacementRequest,
) -> Result<Placement, PlacementError> {
let mut scored = Vec::new();
let mut rejection_counts = BTreeMap::<String, usize>::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::<Vec<_>>()
.join("; "),
})
}
}
fn compatibility(
node: &NodeDescriptor,
request: &PlacementRequest,
) -> Result<Placement, Vec<String>> {
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"));
}
}

View file

@ -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<SourceTransferMode>,
}
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<String>) -> 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::<Vec<_>>();
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<Capability>,
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()
))
);
}
}

View file

@ -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<String>,
) -> Result<DirectBulkTransferPlan, TransportError> {
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"));
}
}

View file

@ -0,0 +1,241 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{Digest, NodeId, TaskId};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct VfsPath(String);
impl VfsPath {
pub fn new(path: impl Into<String>) -> Result<Self, VfsError> {
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: TaskId,
pub node: NodeId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VfsManifest {
pub epoch: u64,
pub producer: TaskId,
pub node: NodeId,
pub objects: BTreeMap<VfsPath, VfsObject>,
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: TaskId,
node: NodeId,
epoch: u64,
pending: BTreeMap<VfsPath, VfsObject>,
published: BTreeMap<VfsPath, VfsObject>,
}
impl VfsOverlay {
pub fn new(task: TaskId, 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(TaskId::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(TaskId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
let empty = VfsManifest {
epoch: 0,
producer: TaskId::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(TaskId::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(TaskId::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(TaskId::from("task"), NodeId::from("node-a"));
overlay.write(path(), Digest::sha256("binary"), 6);
overlay.discard_unflushed();
assert_eq!(overlay.pending_len(), 0);
}
}

View file

@ -0,0 +1,16 @@
[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
disasmer-core = { path = "../disasmer-core" }
disasmer-node = { path = "../disasmer-node" }
serde_json.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
[package]
name = "disasmer-macros"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2.workspace = true
quote.workspace = true
syn.workspace = true

View file

@ -0,0 +1,77 @@
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{parse::Parser, parse_macro_input, Expr, ItemFn, Lit, Meta, Token};
#[proc_macro_attribute]
pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream {
let function = parse_macro_input!(item as ItemFn);
let function_name = function.sig.ident.to_string();
let entrypoint_name = descriptor_name(
attr,
function_name
.strip_suffix("_main")
.unwrap_or(&function_name),
);
let descriptor = format_ident!(
"__DISASMER_ENTRYPOINT_{}",
function_name.to_ascii_uppercase()
);
quote! {
#function
#[doc(hidden)]
pub const #descriptor: ::disasmer::EntrypointDescriptor = ::disasmer::EntrypointDescriptor {
name: #entrypoint_name,
function: #function_name,
};
}
.into()
}
#[proc_macro_attribute]
pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream {
let function = parse_macro_input!(item as ItemFn);
let function_name = function.sig.ident.to_string();
let task_name = descriptor_name(attr, &function_name);
let descriptor = format_ident!("__DISASMER_TASK_{}", function_name.to_ascii_uppercase());
quote! {
#function
#[doc(hidden)]
pub const #descriptor: ::disasmer::TaskDescriptor = ::disasmer::TaskDescriptor {
name: #task_name,
function: #function_name,
remotely_startable: true,
};
}
.into()
}
fn descriptor_name(attr: TokenStream, default: &str) -> String {
if attr.is_empty() {
return default.to_owned();
}
let parser = syn::punctuated::Punctuated::<Meta, Token![,]>::parse_terminated;
let Ok(args) = parser.parse(attr) else {
return default.to_owned();
};
for meta in args {
let Meta::NameValue(name_value) = meta else {
continue;
};
if !name_value.path.is_ident("name") {
continue;
}
if let Expr::Lit(expr) = name_value.value {
if let Lit::Str(name) = expr.lit {
return name.value();
}
}
}
default.to_owned()
}

View file

@ -0,0 +1,16 @@
[package]
name = "disasmer-node"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
disasmer-core = { path = "../disasmer-core" }
quinn.workspace = true
rcgen.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
wasmtime.workspace = true

View file

@ -0,0 +1,84 @@
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use disasmer_core::{
CommandInvocation, Digest, EnvironmentKind, EnvironmentRequirements, EnvironmentResource,
NodeId, ProcessId, TaskId, VfsOverlay, VfsPath,
};
use disasmer_node::{LinuxRootlessPodmanBackend, LocalSourceCheckout, StdProcessRunner};
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
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(),
],
env: Some(env),
};
let checkout = LocalSourceCheckout {
host_path: workspace.clone(),
snapshot: Digest::sha256("phase2-podman-smoke-checkout"),
};
let task = TaskId::from("podman-smoke");
let mut overlay = VfsOverlay::new(task.clone(), NodeId::from("node-podman-smoke"));
let mut runner = StdProcessRunner;
let output = LinuxRootlessPodmanBackend.execute_local_checkout_task(
ProcessId::from("vp-podman-smoke"),
task,
&invocation,
checkout,
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<PathBuf, std::io::Error> {
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)
}

View file

@ -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<dyn std::error::Error + Send + Sync>> {
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::<usize, Box<dyn std::error::Error + Send + Sync>>(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<dyn std::error::Error + Send + Sync>,
> {
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")),
}
}

View file

@ -0,0 +1,133 @@
use std::fs;
use std::path::PathBuf;
use disasmer_core::{CommandInvocation, NodeId, TaskId, VfsPath};
use disasmer_node::{LocalCommandExecutor, VirtualThreadCommand, WasmtimeTaskRuntime};
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
if args.len() == 2 && args[1] == "--host-command" {
return run_host_command_smoke();
}
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() != 5 {
return Err(
"usage: disasmer-wasmtime-smoke <module.wasm> <export> <i32-arg> <expected-i32> | --host-command | --debug-freeze-resume <module.wasm> <export> <i32-arg> <expected-i32>".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_debug_freeze_resume_smoke(
module: &str,
export: &str,
arg: &str,
expected: &str,
) -> Result<(), Box<dyn std::error::Error>> {
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() -> Result<(), Box<dyn std::error::Error>> {
let runtime = WasmtimeTaskRuntime::new()?;
let result = runtime.run_i32_export_with_command_import(
r#"
(module
(import "disasmer" "cmd_run" (func $cmd_run (result i32)))
(func (export "compile-linux") (result i32)
call $cmd_run))
"#,
"compile-linux",
LocalCommandExecutor {
node: NodeId::from("node-wasmtime"),
hosted_control_plane: false,
has_command_capability: true,
},
VirtualThreadCommand {
virtual_thread: TaskId::from("compile-linux"),
invocation: CommandInvocation {
program: "sh".to_owned(),
args: vec!["-c".to_owned(), "printf linux-build-artifact".to_owned()],
env: None,
},
stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/linux/app.tar.zst")?),
},
)?;
println!(
"{}",
serde_json::to_string(&json!({
"type": "wasmtime_host_command_smoke",
"export": "compile-linux",
"export_result": result.export_result,
"virtual_thread": result.command_output.virtual_thread,
"stdout": result.command_output.stdout,
"staged_artifact": result.command_output.staged_artifact,
"large_bytes_uploaded": result.manifest.large_bytes_uploaded,
"manifest_objects": result.manifest.objects.len(),
"node_host_import": "disasmer.cmd_run",
"flagship_linux_build_task": true,
"node_executed_host_command": true,
"hosted_control_plane_ran_command": false,
}))?
);
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,637 @@
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use disasmer_core::{
Capability, CommandInvocation, NodeCapabilities, NodeId, TaskId, VfsOverlay, VfsPath,
};
use disasmer_node::{CommandOutput, LocalCommandExecutor, VirtualThreadCommand};
use serde_json::{json, Value};
#[derive(Debug)]
struct Args {
coordinator: String,
tenant: String,
project: String,
node: String,
process: String,
task: String,
command: String,
command_args: Vec<String>,
artifact: String,
enrollment_grant: Option<String>,
public_key: Option<String>,
control_poll_ms: u64,
assignment_poll_ms: u64,
emit_ready: bool,
worker: bool,
}
#[derive(Clone, Debug)]
struct RuntimeTask {
process: String,
task: String,
command: String,
command_args: Vec<String>,
artifact: String,
epoch: Option<u64>,
task_assignment_response: Value,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = parse_args()?;
let mut session = CoordinatorSession::connect(&args.coordinator)?;
let registration = register_node(&mut session, &args)?;
let heartbeat = session.request(json!({
"type": "node_heartbeat",
"node": &args.node,
}))?;
let capability_report = session.request(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": [],
"artifact_locations": [],
"direct_connectivity": true,
"online": true,
}))?;
if args.worker {
return worker_loop(
&args,
&mut session,
registration,
heartbeat,
capability_report,
);
}
let task_assignment = session.request(json!({
"type": "schedule_task",
"tenant": &args.tenant,
"project": &args.project,
"environment": null,
"environment_digest": null,
"required_capabilities": [Capability::Command],
"dependency_cache": null,
"source_snapshot": null,
"required_artifacts": [],
"quota_available": true,
"policy_allowed": true,
"prefer_node": &args.node,
}))?;
let runtime_task = RuntimeTask {
process: args.process.clone(),
task: args.task.clone(),
command: args.command.clone(),
command_args: args.command_args.clone(),
artifact: args.artifact.clone(),
epoch: None,
task_assignment_response: task_assignment,
};
let report = run_runtime_task(
&args,
&mut session,
runtime_task,
registration,
heartbeat,
capability_report,
)?;
println!("{}", serde_json::to_string(&report)?);
Ok(())
}
fn worker_loop(
args: &Args,
session: &mut CoordinatorSession,
registration: Value,
heartbeat: Value,
capability_report: Value,
) -> Result<(), Box<dyn std::error::Error>> {
if args.emit_ready {
println!(
"{}",
serde_json::to_string(&json!({
"node_status": "ready",
"mode": "worker",
"node": &args.node,
}))?
);
std::io::stdout().flush()?;
}
loop {
let response = session.request(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)?;
let report = run_runtime_task(
args,
session,
runtime_task,
registration.clone(),
heartbeat.clone(),
capability_report.clone(),
)?;
println!("{}", serde_json::to_string(&report)?);
std::io::stdout().flush()?;
}
}
fn runtime_task_from_assignment(value: &Value) -> Result<RuntimeTask, Box<dyn std::error::Error>> {
Ok(RuntimeTask {
process: required_string(value, "process")?,
task: required_string(value, "task")?,
command: required_string(value, "command")?,
command_args: value
.get("command_args")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
artifact: required_string(value, "artifact_path")?,
epoch: value.get("epoch").and_then(Value::as_u64),
task_assignment_response: value.clone(),
})
}
fn required_string(value: &Value, field: &str) -> Result<String, Box<dyn std::error::Error>> {
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,
) -> Result<Value, Box<dyn std::error::Error>> {
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(json!({
"type": "reconnect_node",
"node": &args.node,
"process": &task.process,
"epoch": epoch,
}))?;
let debug_command = session.request(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 && wait_for_cancellation(session, args, &task)? {
let recorded = session.request(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,
"artifact_path": null,
"artifact_digest": null,
"artifact_size_bytes": null,
}))?;
return Ok(cancelled_node_report(
args,
&task,
registration,
heartbeat,
capability_report,
task.task_assignment_response.clone(),
debug_command,
recorded,
session.requests(),
));
}
let executor = LocalCommandExecutor {
node: NodeId::new(args.node.clone()),
hosted_control_plane: false,
has_command_capability: true,
};
let task_id = TaskId::new(task.task.clone());
let mut overlay = VfsOverlay::new(task_id.clone(), NodeId::new(args.node.clone()));
let output = executor.run(
VirtualThreadCommand {
virtual_thread: task_id,
invocation: CommandInvocation {
program: task.command.clone(),
args: task.command_args.clone(),
env: None,
},
stage_stdout_as: Some(VfsPath::new(task.artifact.clone())?),
},
&mut overlay,
)?;
let manifest = overlay.flush();
let staged = output.staged_artifact.as_ref();
let artifact_digest = staged.map(|artifact| artifact.digest.clone());
let artifact_path = staged.map(|artifact| artifact.path.as_str().to_owned());
let artifact_size_bytes = staged.map(|artifact| artifact.size);
let log_event = session.request(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(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(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": staged.map(|artifact| artifact.path.as_str().to_owned()),
"artifact_digest": staged.map(|artifact| artifact.digest.clone()),
"artifact_size_bytes": artifact_size_bytes,
}))?;
Ok(node_report(
output,
manifest.large_bytes_uploaded,
registration,
heartbeat,
capability_report,
task.task_assignment_response,
debug_command,
log_event,
vfs_metadata,
recorded,
session.requests(),
))
}
fn 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,
})
}
fn cancelled_node_report(
_args: &Args,
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,
})
}
fn register_node(
session: &mut CoordinatorSession,
args: &Args,
) -> Result<Value, Box<dyn std::error::Error>> {
let public_key = args
.public_key
.clone()
.unwrap_or_else(|| format!("{}-public-key", args.node));
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,
"now_epoch_seconds": 0,
}))
} else {
session.request(json!({
"type": "attach_node",
"tenant": &args.tenant,
"project": &args.project,
"node": &args.node,
"public_key": public_key,
}))
}
}
fn wait_for_cancellation(
session: &mut CoordinatorSession,
args: &Args,
task: &RuntimeTask,
) -> Result<bool, Box<dyn std::error::Error>> {
let deadline = Instant::now() + Duration::from_millis(args.control_poll_ms);
loop {
let control = session.request(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)
{
return Ok(true);
}
let now = Instant::now();
if now >= deadline {
return Ok(false);
}
std::thread::sleep((deadline - now).min(Duration::from_millis(100)));
}
}
struct CoordinatorSession {
writer: TcpStream,
reader: BufReader<TcpStream>,
requests: usize,
}
impl CoordinatorSession {
fn connect(addr: &str) -> Result<Self, Box<dyn std::error::Error>> {
let transport_addr = json_line_transport_addr(addr);
let writer = TcpStream::connect(&transport_addr)?;
let reader = BufReader::new(writer.try_clone()?);
Ok(Self {
writer,
reader,
requests: 0,
})
}
fn request(&mut self, value: Value) -> Result<Value, Box<dyn std::error::Error>> {
serde_json::to_writer(&mut self.writer, &value)?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
let mut line = String::new();
if self.reader.read_line(&mut line)? == 0 {
return Err("coordinator closed session without a response".into());
}
let response: Value = serde_json::from_str(&line)?;
self.requests += 1;
if response.get("type").and_then(Value::as_str) == Some("error") {
return Err(format!("coordinator error: {response}").into());
}
Ok(response)
}
fn requests(&self) -> usize {
self.requests
}
}
fn json_line_transport_addr(endpoint: &str) -> String {
let endpoint = endpoint.trim();
for (scheme, default_port) in [("https://", 443), ("http://", 80)] {
if let Some(rest) = endpoint.strip_prefix(scheme) {
let authority = rest.split('/').next().unwrap_or(rest);
if authority.contains(':') {
return authority.to_owned();
}
return format!("{authority}:{default_port}");
}
}
endpoint.to_owned()
}
fn parse_args() -> Result<Args, Box<dyn std::error::Error>> {
let mut coordinator = None;
let mut tenant = "tenant".to_owned();
let mut project = "project".to_owned();
let mut node = "node".to_owned();
let mut process = "process".to_owned();
let mut task = "compile-linux".to_owned();
let mut command = None;
let mut command_args = Vec::new();
let mut artifact = "/vfs/artifacts/node-output.txt".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")?,
"--process" => process = args.next().ok_or("--process requires a value")?,
"--task" => task = args.next().ok_or("--task requires a value")?,
"--command" => command = args.next(),
"--arg" => command_args.push(args.next().ok_or("--arg requires a value")?),
"--artifact" => artifact = args.next().ok_or("--artifact 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 = args
.next()
.ok_or("--assignment-poll-ms requires a value")?
.parse()?
}
"--emit-ready" => emit_ready = true,
"--worker" => worker = true,
"--project" => {
let project_path = PathBuf::from(args.next().ok_or("--project requires a path")?);
command_args.extend([
"test".to_owned(),
"--manifest-path".to_owned(),
project_path
.join("Cargo.toml")
.to_string_lossy()
.into_owned(),
]);
}
other => return Err(format!("unknown argument: {other}").into()),
}
}
Ok(Args {
coordinator: coordinator.ok_or("--coordinator is required")?,
tenant,
project,
node,
process,
task,
command: command.unwrap_or_else(|| "cargo".to_owned()),
command_args,
artifact,
enrollment_grant,
public_key,
control_poll_ms,
assignment_poll_ms,
emit_ready,
worker,
})
}
#[cfg(test)]
mod tests {
use super::json_line_transport_addr;
#[test]
fn hosted_operator_url_maps_to_json_line_transport_address() {
assert_eq!(
json_line_transport_addr("https://disasmer.michelpaulissen.com:9443"),
"disasmer.michelpaulissen.com:9443"
);
assert_eq!(
json_line_transport_addr("https://disasmer.michelpaulissen.com:9443/auth/device"),
"disasmer.michelpaulissen.com:9443"
);
assert_eq!(
json_line_transport_addr("http://operator.example.test"),
"operator.example.test:80"
);
assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999");
}
}

View file

@ -0,0 +1,17 @@
[package]
name = "disasmer-sdk"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[lib]
name = "disasmer"
[dependencies]
disasmer-macros = { path = "../disasmer-macros" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
futures-executor.workspace = true

View file

@ -0,0 +1,521 @@
use serde::{Deserialize, Serialize};
pub use disasmer_macros::{main, task};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntrypointDescriptor {
pub name: &'static str,
pub function: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskDescriptor {
pub name: &'static str,
pub function: &'static str,
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<EntrypointDescriptor> {
self.entrypoints
.iter()
.copied()
.find(|entrypoint| entrypoint.name == name)
}
pub fn task(&self, name: &str) -> Option<TaskDescriptor> {
self.tasks.iter().copied().find(|task| task.name == name)
}
}
#[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,
}
#[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,
Handle,
}
pub trait TaskArg: Serialize {
fn task_arg_kind(&self) -> TaskArgKind {
TaskArgKind::SmallSerialized
}
}
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
}
}
impl TaskArg for Blob {
fn task_arg_kind(&self) -> TaskArgKind {
TaskArgKind::Handle
}
}
impl TaskArg for Artifact {
fn task_arg_kind(&self) -> TaskArgKind {
TaskArgKind::Handle
}
}
impl<T> TaskArg for Option<T> where T: TaskArg {}
impl<T> TaskArg for Vec<T> where T: TaskArg {}
#[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<T>(
value: &T,
budget: TaskArgBudget,
) -> Result<TaskArgValidation, TaskArgError>
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::SmallSerialized && 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<T: ?Sized>() -> TaskArgError {
TaskArgError::HostOnly {
type_name: std::any::type_name::<T>(),
}
}
pub mod spawn {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use crate::{validate_task_arg, EnvRef, TaskArg, TaskArgBudget, TaskArgError};
static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1);
static RUNTIME_THREADS: OnceLock<Mutex<Vec<RuntimeSpawnEvent>>> = OnceLock::new();
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeSpawnEvent {
pub virtual_thread_id: u64,
pub name: &'static str,
pub env: Option<EnvRef>,
pub debugger_visible: bool,
}
fn runtime_threads() -> &'static Mutex<Vec<RuntimeSpawnEvent>> {
RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new()))
}
fn register_runtime_thread(
virtual_thread_id: u64,
name: &'static str,
env: Option<EnvRef>,
) -> 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<RuntimeSpawnEvent> {
runtime_threads().lock().unwrap().drain(..).collect()
}
pub fn runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
runtime_threads().lock().unwrap().clone()
}
pub fn task<F, R>(entry: F) -> TaskBuilder<F, R>
where
F: FnOnce() -> R,
R: TaskArg,
{
TaskBuilder {
entry: Some(entry),
env: None,
name: "task",
}
}
pub fn task_with_arg<A, F, R>(arg: A, entry: F) -> TaskWithArgBuilder<A, F, R>
where
A: TaskArg,
F: FnOnce(A) -> R,
R: TaskArg,
{
TaskWithArgBuilder {
arg: Some(arg),
entry: Some(entry),
env: None,
name: "task",
arg_budget: TaskArgBudget::default(),
}
}
pub struct TaskBuilder<F, R>
where
F: FnOnce() -> R,
R: TaskArg,
{
entry: Option<F>,
env: Option<EnvRef>,
name: &'static str,
}
impl<F, R> TaskBuilder<F, R>
where
F: FnOnce() -> R,
R: TaskArg,
{
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 async fn start(mut self) -> TaskHandle<R> {
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
let runtime_event = register_runtime_thread(id, self.name, self.env);
let entry = self.entry.take().expect("task entry used once");
TaskHandle {
virtual_thread_id: id,
name: self.name,
env: self.env,
debugger_visible: runtime_event.debugger_visible,
result: Some(entry()),
}
}
}
pub struct TaskWithArgBuilder<A, F, R>
where
A: TaskArg,
F: FnOnce(A) -> R,
R: TaskArg,
{
arg: Option<A>,
entry: Option<F>,
env: Option<EnvRef>,
name: &'static str,
arg_budget: TaskArgBudget,
}
impl<A, F, R> TaskWithArgBuilder<A, F, R>
where
A: TaskArg,
F: FnOnce(A) -> R,
R: TaskArg,
{
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 arg_budget(mut self, arg_budget: TaskArgBudget) -> Self {
self.arg_budget = arg_budget;
self
}
pub async fn start(mut self) -> Result<TaskHandle<R>, TaskArgError> {
let arg = self.arg.take().expect("task argument used once");
validate_task_arg(&arg, self.arg_budget)?;
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
let runtime_event = register_runtime_thread(id, self.name, self.env);
let entry = self.entry.take().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)),
})
}
}
pub struct TaskHandle<R> {
virtual_thread_id: u64,
name: &'static str,
env: Option<EnvRef>,
debugger_visible: bool,
result: Option<R>,
}
impl<R> TaskHandle<R>
where
R: TaskArg,
{
pub fn virtual_thread_id(&self) -> u64 {
self.virtual_thread_id
}
pub fn name(&self) -> &'static str {
self.name
}
pub fn env(&self) -> Option<EnvRef> {
self.env
}
pub fn debugger_visible(&self) -> bool {
self.debugger_visible
}
pub async fn join(mut self) -> R {
self.result.take().expect("task joined once")
}
}
}
#[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;
assert_eq!(handle.name(), "compile linux");
assert_eq!(handle.env().unwrap().name, "linux");
assert!(handle.debugger_visible());
handle.join().await
});
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
});
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()), 7);
}
#[test]
fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() {
let artifact = crate::Artifact {
id: "artifact://build/app".to_owned(),
};
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::TaskArgError::TooLarge { .. })));
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(),
};
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
});
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"));
}
}

View file

@ -0,0 +1,80 @@
use futures_executor::block_on;
#[disasmer::main]
fn build_main() -> u32 {
1
}
#[disasmer::main(name = "release")]
fn release_main() -> u32 {
2
}
#[disasmer::task(name = "compile-linux")]
fn compile_linux() -> u32 {
41
}
#[disasmer::task]
fn package_release() -> disasmer::Artifact {
disasmer::Artifact {
id: "artifact://package/release.tar.zst".to_owned(),
}
}
#[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"
);
assert!(program.task("compile-linux").unwrap().remotely_startable);
let result = block_on(async {
let handle = disasmer::spawn::task(|| compile_linux() + build_main())
.name("compile linux")
.env(disasmer::env!("linux"))
.start()
.await;
assert!(handle.virtual_thread_id() > 0);
handle.join().await
});
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);
}

1437
disasmer_spec.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
[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" }
serde.workspace = true
[dev-dependencies]
futures-executor.workspace = true

View file

@ -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
```

View file

@ -0,0 +1,3 @@
FROM docker.io/library/alpine:3.20
RUN apk add --no-cache build-base tar zstd
WORKDIR /workspace

View file

@ -0,0 +1,2 @@
# User-attached Windows development execution contract for the MVP.
# This is not a managed untrusted Windows sandbox.

View file

@ -0,0 +1,97 @@
use disasmer::{Artifact, EnvRef, SourceSnapshot};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildReport {
pub linux_thread: u64,
pub package_thread: u64,
pub linux_artifact: Artifact,
pub package_artifact: Artifact,
pub source: SourceSnapshot,
}
pub fn linux_env() -> EnvRef {
disasmer::env!("linux")
}
pub fn windows_env() -> EnvRef {
disasmer::env!("windows")
}
#[disasmer::task]
pub fn compile_linux() -> Artifact {
Artifact {
id: "artifact://linux/app.tar.zst".to_owned(),
}
}
#[disasmer::task]
#[unsafe(no_mangle)]
pub extern "C" fn task_add_one(input: i32) -> i32 {
input + 1
}
#[disasmer::task]
pub fn package_release() -> Artifact {
Artifact {
id: "artifact://package/release.tar.zst".to_owned(),
}
}
#[disasmer::main]
pub fn build_main() -> &'static str {
"launch-build-demo"
}
pub async fn run_build_workflow() -> BuildReport {
let linux = disasmer::spawn::task(compile_linux)
.name("compile linux")
.env(linux_env())
.start()
.await;
let package = disasmer::spawn::task(package_release)
.name("package artifacts")
.env(linux_env())
.start()
.await;
let linux_thread = linux.virtual_thread_id();
let package_thread = package.virtual_thread_id();
let linux_artifact = linux.join().await;
let package_artifact = package.join().await;
BuildReport {
linux_thread,
package_thread,
linux_artifact,
package_artifact,
source: SourceSnapshot {
digest: "source://local-checkout".to_owned(),
},
}
}
#[cfg(test)]
mod tests {
use futures_executor::block_on;
use super::*;
#[test]
fn flagship_workflow_is_rust_source_with_spawned_virtual_tasks() {
assert_eq!(build_main(), "launch-build-demo");
assert_eq!(task_add_one(41), 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, "artifact://linux/app.tar.zst");
assert_eq!(
report.package_artifact.id,
"artifact://package/release.tar.zst"
);
assert_eq!(report.source.digest, "source://local-checkout");
}
}

145
latency_notes.md Normal file
View file

@ -0,0 +1,145 @@
# Disasmer Latency Notes
Low latency is a core design goal. Disasmer should avoid copying or sending data unless it is required.
## Core Rule
```text
Move metadata eagerly.
Move bytes lazily.
Run work where the bytes already are.
```
The coordinator should track locations, hashes, ownership, and versions. It should not sit in the middle of large file transfers.
## Local-First Execution
For a simple build, such as building a Linux binary from a Git repo that already exists locally, Disasmer should behave almost like a local build:
```text
/src -> bind mount existing checkout
/work -> local writable scratch space
/cache -> local persistent dependency cache
/out -> local artifact/output directory
```
No repo upload, no tarballing, no coordinator-routed file transfer, and no automatic artifact upload.
## Placement Policy
Task placement should prioritize data locality:
```text
prefer node with source checkout
prefer node with container/Nix environment cached
prefer node with dependency/build cache
prefer node with previous outputs needed by this task
avoid expected network transfer
```
If the local machine can run the task and already has the repo, it should usually win placement.
## VFS Semantics
The virtual filesystem should be copy-on-write and metadata-driven.
```text
flush()
Publishes filesystem metadata and artifact references.
Makes outputs visible to Disasmer.
Does not necessarily upload bytes.
sync()
Makes selected outputs durable or replicated.
May upload or transfer bytes.
```
This distinction is important: `flush()` should be cheap; `sync()` may be expensive.
## Git Inputs
A Git repo should be treated as a source provider, not as a directory to blindly copy.
A source snapshot is:
```text
commit hash
submodule state
dirty-file overlay
included input files
ignored-file policy
```
If the task runs locally, `/src` can be a direct bind mount. If it runs remotely, only missing or changed content should be sent.
## Artifacts
Publishing a large output should register metadata first:
```text
artifact id
path
node
size
digest/version
```
The actual bytes move only when needed:
```text
another node consumes the artifact
user exports/downloads it
sync() requires durability
replication policy requires it
```
Same-node reuse should use bind mounts, hardlinks, reflinks, or copy-on-write where possible.
## Environments
Containerfiles, Dockerfiles, and Nix flakes should be part of the Disasmer bundle as environment definitions.
Resolved container layers, built images, Nix store paths, and dependency caches should live in node-local caches and be reused by digest.
## Coordinator Responsibility
The coordinator should answer questions like:
```text
which node has source snapshot X?
which node has artifact Y?
which node has environment digest Z cached?
where should this task run?
```
It should not handle normal compiler file reads, linker writes, cache reads, or large artifact transfers.
## MVP Defaults
Disasmer should default to:
```text
local-first task placement
lazy byte transfer
metadata-only flush
explicit sync for durability
node-to-node bulk transfer
content-addressed storage
copy-on-write VFS overlays
no coordinator-routed bulk data
```
## Summary
For low latency, Disasmer should make the common local case stay local.
A local Linux build from a local Git repo should add only small runtime overhead:
```text
placement decision
debug/session metadata
VFS epoch metadata
artifact registration
```
It should not add unnecessary source copying, artifact uploading, image rebuilding, or network filesystem traffic.

661
market_plan.md Normal file
View file

@ -0,0 +1,661 @@
# Disasmer Market Plan and Abuse Notes
## Positioning
Disasmer is an open-source distributed Wasm runtime.
Its initial primary use case is as a build system.
The product promise:
```text
real source file
normal debugging
local-first execution
distributed builds when needed
operator UI instead of CI YAML
self-hosted nodes when users want control
managed coordination when users want convenience
```
The hosted service is primarily the **control plane**:
```text
identity
coordinator state
virtual process registry
node discovery
debug sessions
operator panels
artifact metadata
optional managed nodes later
```
The coordinator should not be a free general-purpose compute platform.
---
## Open-source strategy
Disasmer should be open source by default:
```text
runtime
SDK
CLI
VS Code extension
node runtime
local coordinator
protocol specs
example build programs
```
The hosted product should monetize convenience, scale, reliability, and managed infrastructure rather than hiding the core runtime.
Recommended split:
```text
Open source:
run Disasmer locally
run your own coordinator
attach your own nodes
debug programs
use the build-system features
Hosted service:
managed coordinator
identity/login
public endpoint
node rendezvous
team management
hosted operator panels
artifact metadata/storage
managed build nodes later
uptime/support/compliance later
```
---
## Community hosted tier
Community tier users get:
```text
1 active virtual process
zero-capability hosted Wasm control loop
tiny memory/state limit
tiny CPU/fuel limit
tiny log limit
tiny artifact/state retention
operator panel access
ability to attach their own node(s) so these limits don't matter
```
The hosted free Wasm process has **zero capabilities**:
```text
no network
no host filesystem
no secrets
no containers
no native commands
no outbound HTTP
no inbound ports
no arbitrary syscalls
no access to other users
```
It exists only to let users try the system and orchestrate their own attached nodes.
Real work in the community tier should run on user-provided nodes.
---
## Paid products
Likely paid tiers:
```text
Pro coordinator:
more virtual processes
more state/log retention
private projects
longer-running processes
more operator panels
artifact storage
Team coordinator:
shared nodes
roles/permissions
audit logs
team secrets
organization billing
Managed nodes:
Linux builders
Windows builders
macOS builders if feasible
GPU/specialized nodes later
warm caches
reserved capacity
Enterprise:
private coordinator deployment
SSO/SAML/OIDC
compliance controls
support/SLA
custom retention
on-prem/hybrid support
Marketplace later:
resold node capacity
verified builder pools
specialized environments
```
Primary revenue should come from:
```text
managed coordinators
managed nodes
team features
artifact/log retention
support
enterprise deployments
```
Avoid depending on community tier compute economics.
---
## Main abuse principle
Assume every public input is hostile:
```text
users
programs
nodes
operator panels
logs
artifacts
OAuth identities
build files
container images
Git repos
```
The coordinator must coordinate untrusted systems without trusting them.
---
## Abuse paths to derisk
### 1. Community tier compute abuse
Risks:
```text
crypto mining
brute force jobs
scraping loops
infinite loops
account farming to multiply community tier resources
```
Controls:
```text
fuel metering
wall-clock limits
memory limits
wake-up limits
per-account quotas
per-IP signup/rate limits
no hosted containers on community tier
```
---
### 2. Coordinator denial of service
Risks:
```text
too many API calls
too many virtual process events
too many logs
too many UI updates
too many node heartbeats
too many task spawn attempts
huge metadata objects
expensive queries
```
Controls:
```text
quota every API
rate-limit event streams
cap log size
cap UI update frequency
cap metadata size
cap process lifetime
use backpressure everywhere
admin kill switch
```
OWASP API Security lists unrestricted resource consumption as a major API risk, including CPU, memory, storage, bandwidth, and paid provider resources.
---
### 3. Network abuse
Risks:
```text
port scanning
DDoS coordination
spam/proxy behavior
credential stuffing
webhook abuse
using Disasmer as rendezvous/C2 infrastructure
```
Controls:
```text
free hosted tasks have no network
node traffic requires authenticated sessions
rate-limit rendezvous APIs
no public inbound ports by default
restrict relays
monitor suspicious fan-out
block known-abusive behavior
```
---
### 4. Artifact and log abuse
Risks:
```text
malware hosting
phishing pages
illegal content
oversized binaries
secret leakage in logs
using logs as data exfiltration
using artifacts as free storage/CDN
```
Controls:
```text
tiny free retention
size limits
download limits
content reporting/removal
malware scanning where practical
secret redaction tools
private-by-default artifacts
no public hosting by default
```
---
### 5. Secret theft
Risks:
```text
malicious build scripts stealing tokens
operator panel phishing
exfiltration through logs/artifacts
self-hosted node compromise
repo credentials leaking into tasks
```
Controls:
```text
no secrets in free hosted tasks
scoped short-lived task tokens
explicit secret grants
secrets never shown in UI/logs
audit access to secrets
separate coordinator identity from node identity
```
---
### 6. OAuth and account abuse
Risks:
```text
fake account farms
stolen OAuth sessions
bad redirect URI handling
token leakage
OAuth app phishing
provider token overreach
```
Controls:
```text
OIDC/OAuth with PKCE
strict redirect URI matching
verified email where possible
short-lived sessions
minimal scopes
anti-abuse signup checks
manual suspension tools
```
OAuth security best current practice is covered by RFC 9700.
---
### 7. Malicious user-provided nodes
Risks:
```text
lying about results
faking logs
returning malicious artifacts
claiming cache hits incorrectly
exfiltrating inputs/secrets
attacking other nodes
using coordinator for discovery of targets
```
Controls:
```text
never mix tenants by default
explicit node sharing only
signed node identity
per-node capability policy
artifact provenance
optional reproducible/repeated builds
trust labels for nodes
```
Default rule:
```text
A user's work runs only on that user's nodes, managed paid nodes, or explicitly shared team nodes.
```
---
### 8. Cross-tenant isolation bugs
Risks:
```text
user sees another user's process
user sees another user's logs
user accesses another user's node
artifact metadata leak
operator panel leak
bad authorization check on debug endpoint
```
Controls:
```text
tenant ID on every object
authorization checks on every API
negative tests for cross-tenant access
separate storage namespaces
least-privilege service tokens
security review for debug APIs
```
---
### 9. Debugger abuse
Risks:
```text
reading secrets through debugger
modifying task state maliciously
attaching to another user's process
debug endpoint used as data exfiltration path
huge memory reads causing DoS
```
Controls:
```text
attach requires owner/team permission
debug memory reads are quota-limited
debug sessions are audited
no cross-tenant debug access
hosted free tasks have no secrets
```
---
### 10. Operator panel phishing
Risks:
```text
fake login forms
fake GitHub authorization prompts
malicious links
misleading buttons
HTML/script injection
```
Controls:
```text
built-in widgets only
no custom HTML/JS in MVP
escape all text
label panels as user-provided
no password fields initially
restricted external links
no OAuth flows inside user panels
```
---
### 11. Container/runtime abuse
Risks:
```text
container escape
privileged container misuse
Docker socket exposure
host filesystem mount abuse
kernel attack surface
supply-chain malware
```
Controls:
```text
no free hosted containers
managed containers only on hardened paid nodes
rootless where practical
seccomp/AppArmor/SELinux
no privileged containers by default
no Docker socket mounts
read-only mounts where possible
network egress policy
```
NIST SP 800-190 covers container security concerns; Docker documents rootless mode and seccomp profiles as important hardening tools.
---
### 12. Supply-chain abuse
Risks:
```text
malicious Disasmer examples
malicious Containerfiles
malicious flakes
dependency confusion
poisoned build caches
untrusted public templates
```
Controls:
```text
signed official examples
template review
cache namespace separation
provenance metadata
dependency lockfiles
clear trust warnings before running third-party projects
```
---
### 13. Cost amplification
Risks:
```text
API calls that trigger expensive work
artifact sync causing storage costs
log spam causing storage costs
OAuth/webhook/email costs
relay bandwidth costs
```
Controls:
```text
hard spend caps
per-user cost budgets
quota before work starts
paid-only expensive features
separate flush from sync
bulk data never routed through coordinator by default
```
---
## Free-tier product rule
Free hosted mode should prove the product, not subsidize heavy builds.
It should support:
```text
creating a virtual process
opening an operator panel
testing the SDK
attaching a user's own node
running tiny control logic
spawning tasks onto the user's node
using the debugger flow
```
It should not support:
```text
hosted CI workloads
hosted arbitrary containers
large artifact storage
public file hosting
network scanning
long-running compute
```
---
## MVP commercial rollout
Phase 1:
```text
open-source local runtime
free hosted coordinator
one zero-capability hosted process
attach-your-own-node flow
VS Code extension
operator panel
basic abuse controls
```
Phase 2:
```text
paid coordinator limits
team projects
more processes
longer retention
private artifacts
better logs
```
Phase 3:
```text
managed Linux nodes
warm build caches
paid artifact storage
organization billing
```
Phase 4:
```text
Windows/macOS builders
enterprise/private coordinators
SSO/audit/SLA
node resale or marketplace
```
---
## Success metric
The free product should let someone do this quickly:
```text
sign in
create one Disasmer program (through the website, terminal or vscode for example)
open its operator panel
attach their own machine as a node and verify this in the coordinator UI
spawn a build task onto that node
debug it from VS Code
see artifacts/logs in the coordinator UI
```
That proves the platform without giving away expensive compute.
---
## References
```text
OWASP API Security Top 10 2023: Unrestricted Resource Consumption
https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/
OAuth 2.0 Security Best Current Practice, RFC 9700
https://datatracker.ietf.org/doc/rfc9700/
NIST SP 800-190: Application Container Security Guide
https://csrc.nist.gov/pubs/sp/800/190/final
Docker Rootless Mode
https://docs.docker.com/engine/security/rootless/
Docker Seccomp Security Profiles
https://docs.docker.com/engine/security/seccomp/
```

208
operator_panel_notes.md Normal file
View file

@ -0,0 +1,208 @@
# Disasmer Operator Panel Notes
Disasmer programs should be able to expose a small human-facing UI through the coordinator web page.
This is useful for long-running build systems, repo watchers, task dashboards, and manual controls.
## Core idea
```text
Disasmer program renders UI state
Coordinator displays it in a webpage
Human clicks/types/selects
Coordinator sends typed events back to the program
Program reacts by spawning, stopping, restarting, or debugging tasks
```
The browser is only a view. The Disasmer program owns the state and logic.
## Terminology
```text
Operator Panel
A small immediate-mode UI exposed by a virtual process.
Widget
A built-in UI element such as a button, textbox, dropdown, progress bar, or task table.
UI Event
A typed event sent from the coordinator webpage back into the virtual process.
```
## Example use case
A long-running build program can expose:
```text
repo picker
branch dropdown
parallelism input
start/stop buttons
running task table
progress bars
log tail
artifact links
debug/restart/cancel buttons per task
```
This makes the build system controllable without external YAML, dashboards, or proprietary CI UI.
## Rust sketch
```rust
use disasmer::prelude::*;
#[disasmer::main]
async fn main() -> Result<()> {
let panel = ui::panel("Repo builder").await?;
let mut cfg = BuildConfig {
repo: None,
branch: "main".into(),
parallelism: 4,
};
let mut builds = BuildState::load().await?;
loop {
let action = panel.draw(|ui| {
ui.heading("Repo builder");
cfg.repo = ui.git_repo_picker("Repository", cfg.repo.clone());
cfg.branch = ui.git_branch_dropdown("Branch", cfg.repo.as_ref(), &cfg.branch);
cfg.parallelism = ui.number("Parallel builds", cfg.parallelism);
if ui.button("Start watcher").clicked() {
ui.emit(Action::Start(cfg.clone()));
}
if ui.button("Stop").clicked() {
ui.emit(Action::Stop);
}
ui.progress("Current build", builds.current_progress());
ui.task_table("Running tasks", builds.running_tasks());
ui.log_tail("Recent output", builds.log_stream());
}).await?;
match action {
Some(Action::Start(cfg)) => builds.start_watcher(cfg).await?,
Some(Action::Stop) => builds.stop_watcher().await?,
Some(Action::RestartTask(id)) => builds.restart_task(id).await?,
Some(Action::CancelTask(id)) => builds.cancel_task(id).await?,
None => {}
}
builds.poll().await?;
}
}
```
## What this proves
A Disasmer build system can be expressed as one normal program containing:
```text
build logic
repo watching
branch selection
parallel task spawning
human controls
progress reporting
logs
artifact links
debug hooks
```
The build loop is just normal program logic. Build tasks are virtual threads.
## Task controls
A task table should support simple per-task actions:
```text
Debug
Restart
Cancel
View logs
Open artifacts
```
Example row:
```text
commit abc123 | linux-x64 | running | 72% | Debug | Restart | Cancel
```
## Debugging behavior
When the virtual process is running:
```text
operator panel is interactive
button clicks become UI events
program handles events normally
```
When the virtual process is stopped in a debugger:
```text
panel shows last rendered state
program-level UI events are disabled or queued
control-plane actions may remain available
```
For the MVP, prefer:
```text
running process -> interactive panel
stopped process -> read-only panel plus control-plane actions
```
This avoids executing program code while the debugger says the process is stopped.
## MVP widget set
Start with:
```text
text
heading
button
textbox
number input
checkbox
dropdown
progress bar
task table
log tail
artifact link
git repo picker
git branch picker
```
Avoid initially:
```text
custom HTML
custom JavaScript
complex layout
live charts
drag/drop workflow editors
browser-side business logic
```
## Design rule
The operator panel should stay boring, typed, and immediate-mode.
```text
One source file can define:
distributed execution
build orchestration
environment selection
filesystem/artifact behavior
debugging behavior
human operator UI
```

View file

@ -0,0 +1,111 @@
#!/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 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)(?: \([^)]+\))?:\*\*/,
`${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`);
}
const phase2 = read("acceptance_criteria_phase2.md");
const base = read("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 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"
);
for (const [source, name] of [
[base, "acceptance_criteria.md"],
[phase2, "acceptance_criteria_phase2.md"],
]) {
assertEveryCriterionHasStatus(source, name);
assertNoOpenCriteria(source, name);
}
for (const file of ["MVP.md", "acceptance_criteria.md", "acceptance_criteria_phase2.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],
["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");

View file

@ -0,0 +1,244 @@
#!/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 publicOperatorCompatSmoke = maybeRead("private/hosted-policy/scripts/public-operator-compat-smoke.js");
const hostedService = maybeRead("private/hosted-policy/src/bin/disasmer-hosted-service.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/local-services-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 community smoke", "node private/hosted-policy/scripts/hosted-community-smoke.js");
expectIncludes(privateAcceptance, "private gate runs public operator compatibility smoke", "node private/hosted-policy/scripts/public-operator-compat-smoke.js");
expectIncludes(privateAcceptance, "private gate runs standalone public 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 OIDC test issuer", /DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL/],
["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 performs OIDC login", /type: "oidc_browser_login"/],
["service smoke creates project", /type: "create_project"/],
["service smoke enrolls node", /type: "create_node_enrollment_token"[\s\S]*exchange_node_enrollment_token/],
["service smoke starts user node process", /type: "start_user_node_process"/],
["service smoke records task", /type: "record_user_node_task_completion"/],
["service smoke reads debug state", /type: "debug_process"/],
["service smoke reads artifact metadata", /type: "artifact_metadata"/],
["service smoke creates download link", /type: "create_artifact_download_link"/],
["service smoke records observability", /type: "observability_snapshot"/],
["service smoke records private hosted coordinator", /operator_implementation:[\s\S]*"private-hosted-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, /operator_implementation:[\s\S]*"private-hosted-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 binds public TCP 9443", publicDryrunSystemd, /--listen 0\.0\.0\.0:9443/],
["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, /<deployment-ip> disasmer\.michelpaulissen\.com/],
]) {
expect(source, name, pattern);
}
}
if (publicOperatorCompatSmoke && hostedService) {
for (const [name, pattern] of [
["hosted service embeds private coordinator runtime", /private_coordinator: CoordinatorService/],
["hosted service parses public client protocol requests", /serde_json::from_str::<CoordinatorRequest>/],
["hosted service delegates public requests", /handle_public_request/],
["hosted service marks public protocol sessions", /ConnectionProtocol::Public/],
["hosted service seeds public projects from hosted auth", /seed_public_project/],
["hosted service seeds public enrollment grants", /seed_public_node_enrollment_grant/],
]) {
expect(hostedService, 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 hosted enrollment grant", /type: "create_node_enrollment_token"/],
["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 node runtime", /"disasmer-node"/],
["compat smoke launches through coordinator assignment", /type: "launch_task"/],
["compat smoke verifies assignment polling", /poll_task_assignment/],
["compat smoke verifies public node metadata", /vfs_metadata_recorded/],
["compat smoke writes evidence report", /public-operator-compat\.json/],
]) {
expect(publicOperatorCompatSmoke, name, pattern);
}
}
for (const [name, pattern] of [
["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/],
["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 operator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/],
["e2e runner completes browser login", /--complete-browser-code/],
["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 through coordinator assignment", /type: "launch_task"/],
["e2e runner verifies public assignment polling", /worker_assignment_poll_protocol/],
["e2e runner validates standalone public coordinator", /validateStandalonePublicCoordinator/],
["e2e runner records standalone public coordinator", /public_coordinator_operator_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 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 public operator compatibility evidence", /public-operator-compat\.json/],
["final verifier requires public coordinator compatibility evidence", /public-coordinator-compat\.json/],
["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");

View file

@ -0,0 +1,236 @@
#!/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 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 publicSplit = read("scripts/verify-public-split.sh");
const localServicesSmoke = read("scripts/local-services-smoke.js");
const nodeAttachSmoke = read("scripts/node-attach-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 selfHostedCoordinatorSmoke = read("scripts/self-hosted-coordinator-smoke.js");
const publicLocalDemoMatrix = read("scripts/public-local-demo-matrix-smoke.js");
const publicOperatorCompatSmoke = fs.existsSync(
path.join(repo, "private/hosted-policy/scripts/public-operator-compat-smoke.js")
)
? read("private/hosted-policy/scripts/public-operator-compat-smoke.js")
: null;
const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js");
const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js");
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],
["public split", publicSplit],
]) {
expectGate(script, gateName);
}
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/local-services-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-community-smoke.js",
"private/hosted-policy/scripts/public-operator-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 public coordinator smoke"
);
for (const [name, source, patterns] of [
[
"local services",
localServicesSmoke,
[/cp\.spawn/, /disasmer-coordinator/, /type: "create_node_enrollment_grant"/, /disasmer-node/],
],
[
"node attach",
nodeAttachSmoke,
[/cp\.spawn/, /"node"[\s\S]*"attach"/, /used_enrollment_exchange/, /runAttachedNodeWork/],
],
[
"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/,
/coordinator_task_events[\s\S]*value === 1/,
/Source Locals/,
/Task Args and Handles/,
/unavailable-local-diagnostic/,
/TaskHandle/,
/linux_thread/,
/linux_artifact/,
/command_spec/,
/stdout_tail/,
/stderr_tail/,
],
],
[
"DAP",
dapSmoke,
[
/runtimeBackend: "local-services"/,
/threads[\s\S]*compile linux/,
/send\("attach"/,
/Source Locals/,
/return_value/,
/TaskHandle/,
/linux_thread/,
/linux_artifact/,
/vfs_mounts/,
/command_spec/,
/stdout_tail/,
/stderr_tail/,
],
],
[
"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 public coordinator",
selfHostedCoordinatorSmoke,
[/disasmer-coordinator/, /standalone-public-coordinator/, /public-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 (publicOperatorCompatSmoke) {
for (const pattern of [
/disasmer-hosted-service/,
/"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/,
/node_enrollment_exchanged/,
/"disasmer-node"/,
/node_capabilities_recorded/,
/task_launched/,
/poll_task_assignment/,
/debug_command/,
/task_log_recorded/,
/vfs_metadata_recorded/,
/public-operator-compat\.json/,
]) {
expect(publicOperatorCompatSmoke, "public operator compatibility", pattern);
}
}
for (const pattern of [
/DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/,
/downloadReleaseAssets/,
/verifyChecksums/,
/git"[\s\S]*"clone"/,
/defaultLoginPlan\.coordinator/,
/--complete-browser-code/,
/node_enrollment_exchanged/,
/disasmerNode/,
/launch_task/,
/worker_assignment_poll_verified/,
/validateStandalonePublicCoordinator/,
/public_coordinator_validated/,
/standalone-public-coordinator/,
/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/,
/public-operator-compat\.json/,
/public-coordinator-compat\.json/,
/public-release-dryrun-e2e\.json/,
/coordinator_validation/,
/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");

30
scripts/acceptance-private.sh Executable file
View file

@ -0,0 +1,30 @@
#!/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 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
scripts/release-source-scan.sh
cargo test --manifest-path private/hosted-policy/Cargo.toml
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-community-smoke.js
node private/hosted-policy/scripts/public-operator-compat-smoke.js
node scripts/self-hosted-coordinator-smoke.js
node private/hosted-policy/scripts/postgres-durable-smoke.js
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

64
scripts/acceptance-public.sh Executable file
View file

@ -0,0 +1,64 @@
#!/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/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-login-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/podman-backend-smoke.js
node scripts/vscode-extension-smoke.js
node scripts/vscode-f5-smoke.js
node scripts/node-attach-smoke.js
node scripts/local-services-smoke.js
node scripts/cancellation-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

View file

@ -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");

139
scripts/acceptance-report.js Executable file
View file

@ -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));

View file

@ -0,0 +1,414 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const project = path.join(repo, "examples/launch-build-demo");
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(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 runNode(addr) {
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",
"node-download",
"--process",
"vp-download",
"--task",
"compile-linux",
"--project",
project,
"--artifact",
"/vfs/artifacts/download-output.txt",
],
{ cwd: repo }
);
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 process failed with code ${code}\n${stderr}`));
return;
}
try {
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
} catch (error) {
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
}
});
});
}
function downloadNodeCapabilities() {
return {
os: "Linux",
arch: "x86_64",
capabilities: ["Command", "VfsArtifacts"],
environment_backends: [],
source_providers: ["filesystem"],
};
}
(async () => {
const coordinator = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
"--listen",
"127.0.0.1:0",
],
{ 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 report = await runNode(addr);
assert.strictEqual(report.node_status, "completed");
assert.strictEqual(report.status_code, 0);
assert.strictEqual(report.large_bytes_uploaded, false);
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/download-output.txt");
const disconnectedReport = await send(addr, {
type: "report_node_capabilities",
tenant: "tenant",
project: "project",
node: "node-download",
capabilities: downloadNodeCapabilities(),
cached_environment_digests: [],
dependency_cache_digests: [],
source_snapshots: [],
artifact_locations: [],
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "disconnected",
now_epoch_seconds: 10,
ttl_seconds: 60,
});
assert.strictEqual(disconnectedLink.type, "error");
assert.match(disconnectedLink.message, /direct connectivity unavailable/);
const connectedReport = await send(addr, {
type: "report_node_capabilities",
tenant: "tenant",
project: "project",
node: "node-download",
capabilities: downloadNodeCapabilities(),
cached_environment_digests: [],
dependency_cache_digests: [],
source_snapshots: [],
artifact_locations: [],
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
now_epoch_seconds: 10,
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, "vp-download");
assert.deepStrictEqual(link.link.actor, { User: "user" });
assert.match(link.link.policy_context_digest, /^sha256:[0-9a-f]{64}$/);
assert.strictEqual(link.link.expires_at_epoch_seconds, 70);
assert.match(link.link.url_path, /\/artifacts\/tenant\/project\/vp-download\/download-output\.txt$/);
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
now_epoch_seconds: 10,
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
now_epoch_seconds: 10,
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: link.link.scoped_token_digest,
now_epoch_seconds: 11,
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: link.link.scoped_token_digest,
now_epoch_seconds: 11,
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: "sha256:guessed",
now_epoch_seconds: 11,
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: link.link.scoped_token_digest,
now_epoch_seconds: 11,
chunk_bytes: 1,
});
assert.strictEqual(crossActorOpen.type, "error");
assert.match(crossActorOpen.message, /token is invalid/);
const expired = await send(addr, {
type: "open_artifact_download_stream",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: link.link.scoped_token_digest,
now_epoch_seconds: 71,
chunk_bytes: 1,
});
assert.strictEqual(expired.type, "error");
assert.match(expired.message, /expired/);
await send(addr, {
type: "report_node_capabilities",
tenant: "tenant",
project: "project",
node: "node-download",
capabilities: downloadNodeCapabilities(),
cached_environment_digests: [],
dependency_cache_digests: [],
source_snapshots: [],
artifact_locations: [],
direct_connectivity: false,
online: true,
});
const disconnectedOpen = await send(addr, {
type: "open_artifact_download_stream",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: link.link.scoped_token_digest,
now_epoch_seconds: 11,
chunk_bytes: 1,
});
assert.strictEqual(disconnectedOpen.type, "error");
assert.match(disconnectedOpen.message, /direct connectivity unavailable/);
await send(addr, {
type: "report_node_capabilities",
tenant: "tenant",
project: "project",
node: "node-download",
capabilities: downloadNodeCapabilities(),
cached_environment_digests: [],
dependency_cache_digests: [],
source_snapshots: [],
artifact_locations: [],
direct_connectivity: true,
online: true,
});
const stream = await send(addr, {
type: "open_artifact_download_stream",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: link.link.scoped_token_digest,
now_epoch_seconds: 11,
chunk_bytes: 16,
});
assert.strictEqual(stream.type, "artifact_download_stream");
assert.strictEqual(stream.streamed_bytes, 16);
assert.strictEqual(stream.charged_download_bytes, 16);
assert.strictEqual(stream.link.artifact, "download-output.txt");
const crossActorRevoke = await send(addr, {
type: "revoke_artifact_download_link",
tenant: "tenant",
project: "project",
actor_user: "other-user",
artifact: "download-output.txt",
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: "download-output.txt",
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: "download-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "nonce",
token_digest: link.link.scoped_token_digest,
now_epoch_seconds: 12,
chunk_bytes: 1,
});
assert.strictEqual(revokedOpen.type, "error");
assert.match(revokedOpen.message, /revoked/);
} finally {
coordinator.kill("SIGTERM");
}
console.log("Artifact download smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

View file

@ -0,0 +1,244 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const project = path.join(repo, "examples/launch-build-demo");
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(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 runProducerNode(addr) {
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",
"node-export-source",
"--process",
"vp-export",
"--task",
"compile-linux",
"--project",
project,
"--artifact",
"/vfs/artifacts/export-output.txt",
],
{ cwd: repo }
);
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(`producer node failed with code ${code}\n${stderr}`));
return;
}
try {
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
} catch (error) {
reject(new Error(`producer node output was not JSON: ${stdout}\n${error.stack || error.message}`));
}
});
});
}
function nodeCapabilities() {
return {
os: "Linux",
arch: "x86_64",
capabilities: ["Command", "VfsArtifacts"],
environment_backends: [],
source_providers: ["filesystem"],
};
}
async function reportNode(addr, node, { directConnectivity = true, online = true } = {}) {
const response = await send(addr, {
type: "report_node_capabilities",
tenant: "tenant",
project: "project",
node,
capabilities: nodeCapabilities(),
cached_environment_digests: [],
dependency_cache_digests: [],
source_snapshots: [],
artifact_locations: [],
direct_connectivity: directConnectivity,
online,
});
assert.strictEqual(response.type, "node_capabilities_recorded");
assert.strictEqual(response.node, node);
}
(async () => {
const coordinator = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
"--listen",
"127.0.0.1:0",
],
{ 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 produced = await runProducerNode(addr);
assert.strictEqual(produced.node_status, "completed");
assert.strictEqual(produced.coordinator_response.type, "task_recorded");
assert.strictEqual(produced.staged_artifact.path, "/vfs/artifacts/export-output.txt");
await reportNode(addr, "node-export-source");
const attachedReceiver = await send(addr, {
type: "attach_node",
tenant: "tenant",
project: "project",
node: "node-export-receiver",
public_key: "node-export-receiver-public-key",
});
assert.strictEqual(attachedReceiver.type, "node_attached");
await reportNode(addr, "node-export-receiver");
const exportPlan = await send(addr, {
type: "export_artifact_to_node",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: "export-output.txt",
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, "vp-export");
assert.deepStrictEqual(exportPlan.plan.scope.object, { Artifact: "export-output.txt" });
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}$/);
const crossTenant = await send(addr, {
type: "export_artifact_to_node",
tenant: "other",
project: "project",
actor_user: "user",
artifact: "export-output.txt",
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: "export-output.txt",
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", { online: false });
const offlineReceiver = await send(addr, {
type: "export_artifact_to_node",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: "export-output.txt",
receiver_node: "node-export-receiver",
direct_connectivity: true,
failure_reason: "",
});
assert.strictEqual(offlineReceiver.type, "error");
assert.match(offlineReceiver.message, /offline/);
} finally {
coordinator.kill("SIGTERM");
}
console.log("Artifact export smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

View file

@ -0,0 +1,263 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
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(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 spawnNode(addr) {
const child = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-node",
"--bin",
"disasmer-node",
"--",
"--coordinator",
`${addr.host}:${addr.port}`,
"--tenant",
"tenant",
"--project-id",
"project",
"--node",
"node-cancel",
"--process",
"vp-cancel",
"--task",
"compile-linux",
"--artifact",
"/vfs/artifacts/cancelled-output.txt",
"--emit-ready",
"--control-poll-ms",
"5000",
],
{ cwd: repo }
);
let buffer = "";
let rawStdout = "";
let stderr = "";
let exitCode = null;
let exitSignal = null;
const messages = [];
const waiters = [];
child.stdout.on("data", (chunk) => {
rawStdout += chunk.toString();
buffer += chunk.toString();
while (true) {
const newline = buffer.indexOf("\n");
if (newline < 0) return;
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
let message;
try {
message = JSON.parse(line);
} catch (error) {
rejectWaiters(new Error(`node emitted non-JSON line: ${line}\n${stderr}`));
return;
}
messages.push(message);
flush();
}
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("exit", (code, signal) => {
exitCode = code;
exitSignal = signal;
flush();
rejectWaiters(
new Error(
`node process exited before expected message with code ${code} signal ${signal}\nstdout:\n${rawStdout}\nstderr:\n${stderr}`
)
);
});
function flush() {
for (const waiter of [...waiters]) {
const message = messages.find(waiter.predicate);
if (!message) continue;
clearTimeout(waiter.timer);
waiters.splice(waiters.indexOf(waiter), 1);
waiter.resolve(message);
}
}
function waitForMessage(predicate, timeoutMs = 120000) {
const existing = messages.find(predicate);
if (existing) return Promise.resolve(existing);
if (exitCode !== null) {
return Promise.reject(
new Error(
`node process already exited with code ${exitCode} signal ${exitSignal}\nstdout:\n${rawStdout}\nstderr:\n${stderr}`
)
);
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`timed out waiting for node message\n${stderr}`));
}, timeoutMs);
waiters.push({ predicate, resolve, reject, timer });
});
}
function rejectWaiters(error) {
for (const waiter of [...waiters]) {
clearTimeout(waiter.timer);
waiters.splice(waiters.indexOf(waiter), 1);
waiter.reject(error);
}
}
function waitForExit() {
if (child.exitCode !== null) {
if (child.exitCode === 0) return Promise.resolve();
return Promise.reject(new Error(`node process failed with code ${child.exitCode}\n${stderr}`));
}
return new Promise((resolve, reject) => {
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`node process failed with code ${code}\n${stderr}`));
}
});
});
}
return { child, waitForMessage, waitForExit };
}
(async () => {
const coordinator = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
"--listen",
"127.0.0.1:0",
],
{ cwd: repo }
);
let node;
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");
node = spawnNode(addr);
const nodeReady = await node.waitForMessage((message) => message.node_status === "ready");
assert.strictEqual(nodeReady.process, "vp-cancel");
assert.strictEqual(nodeReady.task, "compile-linux");
const cancel = await send(addr, {
type: "cancel_task",
tenant: "tenant",
project: "project",
process: "vp-cancel",
node: "node-cancel",
task: "compile-linux",
});
assert.strictEqual(cancel.type, "task_cancellation_requested");
assert.strictEqual(cancel.process, "vp-cancel");
assert.strictEqual(cancel.task, "compile-linux");
assert.strictEqual(cancel.node, "node-cancel");
const report = await node.waitForMessage((message) => message.node_status === "cancelled");
assert.strictEqual(report.terminal_state, "cancelled");
assert.strictEqual(report.status_code, null);
assert.strictEqual(report.large_bytes_uploaded, false);
assert.strictEqual(report.coordinator_response.type, "task_recorded");
await node.waitForExit();
const events = await send(addr, {
type: "list_task_events",
tenant: "tenant",
project: "project",
actor_user: "user",
process: "vp-cancel"
});
assert.strictEqual(events.type, "task_events");
assert.strictEqual(events.events.length, 1);
assert.strictEqual(events.events[0].node, "node-cancel");
assert.strictEqual(events.events[0].process, "vp-cancel");
assert.strictEqual(events.events[0].task, "compile-linux");
assert.strictEqual(events.events[0].terminal_state, "cancelled");
assert.strictEqual(events.events[0].status_code, null);
const control = await send(addr, {
type: "poll_task_control",
tenant: "tenant",
project: "project",
process: "vp-cancel",
node: "node-cancel",
task: "compile-linux",
});
assert.strictEqual(control.type, "task_control");
assert.strictEqual(control.cancel_requested, false);
} finally {
if (node && node.child.exitCode === null) node.child.kill("SIGKILL");
coordinator.kill("SIGTERM");
}
console.log("Cancellation smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

View file

@ -0,0 +1,206 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const fs = require("fs");
const http = require("http");
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");
fs.mkdirSync(tmp, { 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 http = require("http");
const trace = ${JSON.stringify(trace)};
fs.appendFileSync(trace, "started " + process.argv.slice(2).join(" ") + "\\n");
const loginUrl = new URL(process.argv[2]);
const state = loginUrl.searchParams.get("state");
const redirect = new URL(loginUrl.searchParams.get("redirect_uri"));
if (!state || !redirect) {
fs.appendFileSync(trace, "missing state or redirect\\n");
console.error("browser opener did not receive state and redirect_uri");
process.exit(1);
}
redirect.searchParams.set("code", "browser-smoke-code");
redirect.searchParams.set("state", state);
fs.appendFileSync(trace, "callback " + redirect.toString() + "\\n");
http.get(redirect, (response) => {
response.resume();
response.on("end", () => {
fs.appendFileSync(trace, "status " + response.statusCode + "\\n");
process.exit(response.statusCode === 200 ? 0 : 1);
});
}).on("error", (error) => {
fs.appendFileSync(trace, "error " + (error.stack || error.message) + "\\n");
console.error(error.stack || error.message);
process.exit(1);
});
`
);
fs.chmodSync(opener, 0o755);
return opener;
}
function listen(server, host = "127.0.0.1") {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, host, () => {
server.off("error", reject);
resolve(server.address());
});
});
}
async function startCoordinator() {
const requests = [];
const server = net.createServer((socket) => {
let buffered = "";
socket.on("data", (chunk) => {
buffered += chunk.toString("utf8");
if (!buffered.includes("\n")) return;
const [line] = buffered.split(/\r?\n/);
const request = JSON.parse(line);
requests.push(request);
assert.strictEqual(request.type, "oidc_browser_login");
assert.strictEqual(request.authorization_code, "browser-smoke-code");
assert.strictEqual(request.issuer_url, "http://127.0.0.1:1");
assert.strictEqual(request.client_id, "disasmer-smoke");
assert.match(request.redirect_path, /^http:\/\/127\.0\.0\.1:45173\/callback$/);
assert.match(request.state, /^sha256:[a-f0-9]{64}$/);
socket.end(
JSON.stringify({
type: "oidc_browser_session",
session: {
tenant: request.tenant,
project: request.project,
user: request.user,
browser_credential_kind: "BrowserSession",
cli_session_credential_kind: "CliDeviceSession",
provider_tokens_sent_to_nodes: false,
flow: {
authorization_url: "http://127.0.0.1:1/application/o/authorize/",
callback_path: request.redirect_path,
state: request.state,
},
oidc_token_exchange: {
token_endpoint: "http://127.0.0.1:1/application/o/token/",
token_type: "Bearer",
received_access_token: true,
received_id_token: true,
retained_provider_tokens: false,
},
},
}) + "\n"
);
server.close();
});
});
const address = await listen(server);
return {
url: `${address.address}:${address.port}`,
requests,
close: () => new Promise((resolve) => server.close(() => resolve())),
};
}
function runDisasmer(args, env) {
return new Promise((resolve, reject) => {
const child = cp.spawn("cargo", args, {
cwd: repo,
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const timeout = setTimeout(() => {
child.kill();
reject(new Error(`disasmer command timed out\n${stderr}`));
}, 30000);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
if (code !== 0) {
reject(
new Error(
`disasmer command failed with ${signal || code}\nSTDERR:\n${stderr}\nSTDOUT:\n${stdout}`
)
);
} else {
resolve(stdout);
}
});
});
}
(async () => {
const opener = writeOpener();
const coordinator = await startCoordinator();
try {
const stdout = await runDisasmer(
[
"run",
"-q",
"-p",
"disasmer-cli",
"--bin",
"disasmer",
"--",
"login",
"--browser",
"--json",
"--coordinator",
coordinator.url,
"--oidc-issuer-url",
"http://127.0.0.1:1",
"--oidc-client-id",
"disasmer-smoke",
"--tenant",
"tenant-smoke",
"--project-id",
"project-smoke",
"--user",
"user-smoke",
],
{
...process.env,
DISASMER_BROWSER_OPEN_COMMAND: opener,
DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS: "5",
}
);
const report = JSON.parse(stdout);
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.provider_tokens_exposed_to_cli, false);
assert.strictEqual(report.boundary.provider_tokens_sent_to_nodes, false);
assert.strictEqual(report.boundary.coordinator_session_requests, 1);
assert.strictEqual(coordinator.requests.length, 1);
} finally {
if (coordinator.requests.length === 0) {
await coordinator.close().catch(() => {});
}
}
console.log("CLI browser login flow smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

58
scripts/cli-install-smoke.js Executable file
View file

@ -0,0 +1,58 @@
#!/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 = path.join(temp, "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],
{ 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");

202
scripts/cli-local-run-smoke.js Executable file
View file

@ -0,0 +1,202 @@
#!/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 repo = path.resolve(__dirname, "..");
const project = path.join(repo, "examples/launch-build-demo");
const agentPublicKey = "agent-cli-smoke-public-key";
function sha256(value) {
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
}
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(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"
],
{ 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],
{ DISASMER_AGENT_PUBLIC_KEY: agentPublicKey }
);
assert(Number.isInteger(cliPid));
assert.notStrictEqual(cliPid, coordinator.pid);
assert.strictEqual(report.plan.entry, "build");
assert.deepStrictEqual(report.plan.session, {
AgentPublicKey: {
public_key_fingerprint: sha256(agentPublicKey),
browser_interaction_required: false
}
});
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, 10);
assert.strictEqual(report.node_report.node_status, "completed");
assert.strictEqual(report.node_report.status_code, 0);
assert.strictEqual(report.node_report.large_bytes_uploaded, false);
assert.strictEqual(report.node_report.capability_response.type, "node_capabilities_recorded");
assert.strictEqual(report.node_report.task_assignment_response.type, "task_placement");
assert.strictEqual(report.node_report.debug_command_response.type, "debug_command");
assert.strictEqual(report.node_report.log_event_response.type, "task_log_recorded");
assert.strictEqual(report.node_report.vfs_metadata_response.type, "vfs_metadata_recorded");
assert.strictEqual(report.node_report.staged_artifact.path, "/vfs/artifacts/cli-run-output.txt");
const events = await send(addr, {
type: "list_task_events",
tenant: "tenant",
project: "project",
actor_user: "user",
process: "vp-cli-local"
});
assert.strictEqual(events.type, "task_events");
assert.strictEqual(events.events.length, 1);
assert.strictEqual(events.events[0].node, "node-cli-local");
assert.strictEqual(events.events[0].process, "vp-cli-local");
assert.strictEqual(events.events[0].task, "compile-linux");
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/cli-run-output.txt");
} finally {
coordinator.kill("SIGTERM");
}
const { pid: autoCliPid, report: autoReport } = await runCli([
"run",
"--local",
"--project",
project
]);
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, 10);
assert.strictEqual(autoReport.node_report.node_status, "completed");
assert.strictEqual(autoReport.node_report.status_code, 0);
assert.strictEqual(autoReport.node_report.large_bytes_uploaded, false);
assert.strictEqual(autoReport.node_report.capability_response.type, "node_capabilities_recorded");
assert.strictEqual(autoReport.node_report.task_assignment_response.type, "task_placement");
assert.strictEqual(autoReport.node_report.debug_command_response.type, "debug_command");
assert.strictEqual(autoReport.node_report.log_event_response.type, "task_log_recorded");
assert.strictEqual(autoReport.node_report.vfs_metadata_response.type, "vfs_metadata_recorded");
assert.strictEqual(
autoReport.node_report.staged_artifact.path,
"/vfs/artifacts/cli-run-output.txt"
);
console.log("CLI local run smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

View file

@ -0,0 +1,47 @@
#!/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 defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
function disasmer(args) {
return JSON.parse(
cp.execFileSync(
"cargo",
["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args],
{ cwd: repo, encoding: "utf8" }
)
);
}
const device = disasmer(["login", "--coordinator", coordinator]);
assert.strictEqual(device.coordinator, coordinator);
assert(device.human_flow.Device, "default human login should use device flow");
assert.strictEqual(device.human_flow.Device.verification_url, `${coordinator}/auth/device`);
assert.match(device.human_flow.Device.user_code, /^DISASMER-[A-F0-9]{4}-[A-F0-9]{4}$/);
assert.match(device.human_flow.Device.device_code, /^sha256:[a-f0-9]{64}$/);
assert.strictEqual(device.human_flow.Device.expires_in_seconds, 900);
assert.strictEqual(device.human_flow.Device.yields_long_lived_secret_directly, false);
const defaultDevice = disasmer(["login"]);
assert.strictEqual(defaultDevice.coordinator, defaultOperatorEndpoint);
assert.strictEqual(
defaultDevice.human_flow.Device.verification_url,
`${defaultOperatorEndpoint}/auth/device`
);
const browser = disasmer(["login", "--browser", "--plan", "--coordinator", coordinator]);
assert.strictEqual(browser.coordinator, coordinator);
assert(browser.human_flow.Browser, "browser login should be available for human users");
assert.match(
browser.human_flow.Browser.authorization_url,
new RegExp(`^${coordinator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/auth/browser/start\\?`)
);
assert.match(browser.human_flow.Browser.callback_path, /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
assert.match(browser.human_flow.Browser.state, /^sha256:[a-f0-9]{64}$/);
console.log("CLI login smoke passed");

948
scripts/dap-smoke.js Executable file
View file

@ -0,0 +1,948 @@
#!/usr/bin/env node
const cp = require("child_process");
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
class DapClient {
constructor() {
this.child = cp.spawn(
"cargo",
["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"],
{ cwd: process.cwd() }
);
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;
}
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");
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() {
const seq = this.send("disconnect");
await this.response(seq, "disconnect");
this.child.stdin.end();
}
}
function createFailingProject() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-dap-failing-"));
fs.mkdirSync(path.join(root, "src"), { recursive: true });
fs.writeFileSync(
path.join(root, "Cargo.toml"),
`[package]
name = "dap-failing-demo"
version = "0.1.0"
edition = "2021"
`
);
fs.writeFileSync(
path.join(root, "src/lib.rs"),
`#[cfg(test)]
mod tests {
#[test]
fn fails_for_restart_smoke() {
assert_eq!(1, 2);
}
}
`
);
return root;
}
function waitForJsonLine(child, description) {
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(new Error(`${description} did not emit JSON: ${buffer}\n${error.stack || error.message}`));
}
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.once("exit", (code, signal) => {
reject(new Error(`${description} exited before JSON line with code ${code} signal ${signal}\n${stderr}`));
});
});
}
async function startCoordinator() {
const child = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
"--listen",
"127.0.0.1:0"
],
{ cwd: process.cwd() }
);
const ready = await waitForJsonLine(child, "coordinator");
return { child, listen: ready.listen };
}
async function startExplicitWorker(listen) {
const child = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-node",
"--bin",
"disasmer-node",
"--",
"--coordinator",
listen,
"--tenant",
"tenant",
"--project-id",
"project",
"--node",
"dap-live-worker",
"--worker",
"--assignment-poll-ms",
"50",
"--emit-ready"
],
{ cwd: process.cwd() }
);
const ready = await waitForJsonLine(child, "explicit worker");
assert.strictEqual(ready.node_status, "ready");
assert.strictEqual(ready.mode, "worker");
assert.strictEqual(ready.node, "dap-live-worker");
return child;
}
function killChild(child) {
if (child && child.exitCode === null) {
child.kill("SIGTERM");
}
}
async function launchToBreakpoint({
runtimeBackend = "simulated",
breakpointLine,
breakpointLines,
project = path.join(process.cwd(), "examples/launch-build-demo"),
operatorEndpoint,
tenant = "tenant",
projectId = "project",
actorUser = "dap"
}) {
const client = new DapClient();
const initialize = client.send("initialize", {
adapterID: "disasmer",
linesStartAt1: true,
columnsStartAt1: true
});
await client.response(initialize, "initialize");
const launchArgs = {
entry: "build",
project,
runtimeBackend,
tenant,
projectId,
actorUser
};
if (operatorEndpoint) {
launchArgs.operatorEndpoint = operatorEndpoint;
}
const launch = client.send("launch", launchArgs);
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: (breakpointLines || [breakpointLine]).map((line) => ({ line }))
});
const breakpointResponse = await client.response(breakpoints, "setBreakpoints");
assert.deepStrictEqual(
breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified),
(breakpointLines || [breakpointLine]).map(() => true)
);
const exceptions = client.send("setExceptionBreakpoints", { filters: [] });
await client.response(exceptions, "setExceptionBreakpoints");
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");
return { client, stopped };
}
(async () => {
const launchProject = path.join(process.cwd(), "examples/launch-build-demo");
const launchSource = fs.realpathSync(path.join(launchProject, "src/build.rs"));
const dapSource = fs.readFileSync(
path.join(process.cwd(), "crates/disasmer-dap/src/main.rs"),
"utf8"
);
const liveStart = dapSource.indexOf("fn run_live_services_runtime");
const liveEnd = dapSource.indexOf("fn run_with_coordinator", liveStart);
const liveRuntimeSource = dapSource.slice(liveStart, liveEnd);
assert.doesNotMatch(liveRuntimeSource, /run_node_against_coordinator|Command::new|project_binary/);
const { client, stopped } = await launchToBreakpoint({
runtimeBackend: "local-services",
project: launchProject,
breakpointLine: 22
});
assert.strictEqual(stopped.body.threadId, 2);
const threadsRequest = client.send("threads");
const threads = (await client.response(threadsRequest, "threads")).body.threads;
assert.deepStrictEqual(
threads.map((thread) => thread.id),
[1, 2, 3, 4]
);
assert(threads.some((thread) => thread.name.includes("compile linux")));
assert(threads.some((thread) => thread.name.includes("compile windows")));
assert(threads.some((thread) => thread.name.includes("package artifacts")));
const stackRequest = client.send("stackTrace", { threadId: 2, startFrame: 0, levels: 1 });
const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames;
assert.strictEqual(stack.length, 1);
assert.match(stack[0].name, /compile linux::run/);
assert.strictEqual(stack[0].line, 22);
assert.strictEqual(stack[0].source.path, launchSource);
assert.strictEqual(stack[0].source.sourceReference || 0, 0);
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, /compile_linux/);
assert.match(source.mimeType, /rust/);
const nextRequest = client.send("next", { threadId: 2 });
await client.response(nextRequest, "next");
const stepped = await client.waitFor(
(message) => message.type === "event" && message.event === "stopped" && message.body.reason === "step"
);
assert.strictEqual(stepped.body.threadId, 2);
assert.strictEqual(stepped.body.allThreadsStopped, true);
const steppedStackRequest = client.send("stackTrace", { threadId: 2, startFrame: 0, levels: 1 });
const steppedStack = (await client.response(steppedStackRequest, "stackTrace")).body.stackFrames;
assert.strictEqual(steppedStack[0].line, 23);
const steppedSourceRequest = client.send("source", { source: steppedStack[0].source });
const steppedSource = (await client.response(steppedSourceRequest, "source")).body;
assert.match(steppedSource.content, /compile_linux/);
const scopesRequest = client.send("scopes", { frameId: steppedStack[0].id });
const scopes = (await client.response(scopesRequest, "scopes")).body.scopes;
const localsRef = scopes.find((scope) => scope.name === "Source Locals").variablesReference;
const argsRef = scopes.find((scope) => scope.name === "Task Args and Handles").variablesReference;
const runtimeRef = scopes.find((scope) => scope.name === "Disasmer Runtime").variablesReference;
const outputRef = scopes.find((scope) => scope.name === "Recent Output").variablesReference;
const localsRequest = client.send("variables", { variablesReference: localsRef });
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 argsRequest = client.send("variables", { variablesReference: argsRef });
const args = (await client.response(argsRequest, "variables")).body.variables;
const target = args.find((variable) => variable.name === "target");
const artifact = args.find((variable) => variable.name === "artifact");
const sourceSnapshot = args.find((variable) => variable.name === "source_snapshot");
const returnValue = args.find((variable) => variable.name === "return_value");
const blob = args.find((variable) => variable.name === "blob");
const vfsMounts = args.find((variable) => variable.name === "vfs_mounts");
assert(target);
assert(target.variablesReference > 0);
assert(artifact);
assert.strictEqual(artifact.value, 'Artifact { id = "/vfs/artifacts/dap-output.txt" }');
assert(sourceSnapshot);
assert.match(sourceSnapshot.value, /^SourceSnapshot \{ digest = "source:\/\/local-checkout\/[a-f0-9]{12}" \}$/);
assert(returnValue);
assert.match(returnValue.value, /Artifact/);
assert(blob);
assert.match(blob.value, /^Blob \{ digest = "sha256:[a-f0-9]{64}" \}$/);
assert(vfsMounts);
assert(vfsMounts.variablesReference > 0);
const targetRequest = client.send("variables", { variablesReference: target.variablesReference });
const targetFields = (await client.response(targetRequest, "variables")).body.variables;
assert(targetFields.some((variable) => variable.name === "environment" && variable.value === "linux"));
assert(
targetFields.some(
(variable) => variable.name === "required_capability" && variable.value === "Command"
)
);
const vfsRequest = client.send("variables", { variablesReference: vfsMounts.variablesReference });
const vfs = (await client.response(vfsRequest, "variables")).body.variables;
assert(vfs.some((variable) => variable.name === "/vfs/artifacts"));
assert(vfs.some((variable) => variable.name === "/vfs/sources"));
assert(vfs.some((variable) => variable.name === "/vfs/blobs"));
const runtimeRequest = client.send("variables", { variablesReference: runtimeRef });
const runtime = (await client.response(runtimeRequest, "variables")).body.variables;
const processId = runtime.find((variable) => variable.name === "virtual_process_id");
const commandSpec = runtime.find((variable) => variable.name === "command_spec");
assert(processId);
assert.match(processId.value, /^vp-[a-f0-9]{12}$/);
assert(runtime.some((variable) => variable.name === "debug_epoch" && variable.value === 2));
assert(runtime.some((variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"));
assert(runtime.some((variable) => variable.name === "coordinator_task_events" && variable.value === 1));
assert(runtime.some((variable) => variable.name === "command_status" && String(variable.value).includes("completed through local services")));
assert(commandSpec);
assert(commandSpec.variablesReference > 0);
assert(runtime.some((variable) => variable.name === "stdout_tail"));
assert(runtime.some((variable) => variable.name === "stderr_tail"));
const commandRequest = client.send("variables", {
variablesReference: commandSpec.variablesReference
});
const commandVariables = (await client.response(commandRequest, "variables")).body.variables;
assert(commandVariables.some((variable) => variable.name === "program" && variable.value === "cargo"));
assert(
commandVariables.some(
(variable) => variable.name === "required_capability" && variable.value === "Command"
)
);
const outputRequest = client.send("variables", { variablesReference: outputRef });
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"));
assert(output.some((variable) => String(variable.value).includes("all-stop")));
assert(output.some((variable) => String(variable.value).includes("attached node completed task")));
assert(output.some((variable) => String(variable.value).includes("coordinator recorded 1 task event")));
const continueRequest = client.send("continue", { threadId: 2 });
await client.response(continueRequest, "continue");
await client.waitFor((message) => message.type === "event" && message.event === "continued");
const pauseRequest = client.send("pause", { threadId: 2 });
await client.response(pauseRequest, "pause");
const paused = await client.waitFor(
(message) => message.type === "event" && message.event === "stopped" && message.body.reason === "pause"
);
assert.strictEqual(paused.body.allThreadsStopped, true);
const restartRequest = client.send("restartFrame", { frameId: stack[0].id });
await client.response(restartRequest, "restartFrame");
await client.waitFor(
(message) =>
message.type === "event" &&
message.event === "output" &&
String(message.body.output).includes("Restarted selected task")
);
const incompatibleRestartRequest = client.send("restartFrame", {
frameId: stack[0].id,
sourceCompatibility: "incompatible"
});
const incompatibleRestart = await client.failure(incompatibleRestartRequest, "restartFrame");
assert.match(incompatibleRestart.message, /incompatible source edit/i);
assert.match(incompatibleRestart.message, /whole virtual-process restart/i);
const continueAfterRestartRequest = client.send("continue", { threadId: 2 });
await client.response(continueAfterRestartRequest, "continue");
await client.waitFor((message) => message.type === "event" && message.event === "continued");
const freezeFailureRequest = client.send("pause", {
threadId: 2,
simulateFreezeFailure: true
});
const freezeFailure = await client.failure(freezeFailureRequest, "pause");
assert.match(freezeFailure.message, /all-stop failed/i);
assert.match(freezeFailure.message, /could not freeze/i);
await client.close();
const attachClient = new DapClient();
try {
const attachInitialize = attachClient.send("initialize", {
adapterID: "disasmer",
linesStartAt1: true,
columnsStartAt1: true
});
await attachClient.response(attachInitialize, "initialize");
const attachRequest = attachClient.send("attach", {
entry: "build",
project: launchProject,
runtimeBackend: "live-services",
operatorEndpoint: "127.0.0.1:1"
});
await attachClient.response(attachRequest, "attach");
await attachClient.waitFor(
(message) => message.type === "event" && message.event === "initialized"
);
const attachBreakpoints = attachClient.send("setBreakpoints", {
source: { path: path.join(launchProject, "src/build.rs") },
breakpoints: [{ line: 12 }]
});
await attachClient.response(attachBreakpoints, "setBreakpoints");
const attachDone = attachClient.send("configurationDone");
await attachClient.response(attachDone, "configurationDone");
const attachStopped = await attachClient.waitFor(
(message) => message.type === "event" && message.event === "stopped"
);
assert.strictEqual(attachStopped.body.allThreadsStopped, true);
const attachStackRequest = attachClient.send("stackTrace", {
threadId: 1,
startFrame: 0,
levels: 1
});
const attachStack = (await attachClient.response(attachStackRequest, "stackTrace")).body
.stackFrames;
const attachScopesRequest = attachClient.send("scopes", { frameId: attachStack[0].id });
const attachScopes = (await attachClient.response(attachScopesRequest, "scopes")).body.scopes;
const attachRuntimeRef = attachScopes.find((scope) => scope.name === "Disasmer Runtime")
.variablesReference;
const attachRuntimeRequest = attachClient.send("variables", {
variablesReference: attachRuntimeRef
});
const attachRuntime = (await attachClient.response(attachRuntimeRequest, "variables")).body
.variables;
assert(
attachRuntime.some(
(variable) =>
variable.name === "command_status" &&
String(variable.value).includes("attached to existing virtual process")
)
);
} finally {
await attachClient.close().catch(() => {});
}
let sourceLocalsSession;
try {
sourceLocalsSession = await launchToBreakpoint({
runtimeBackend: "simulated",
project: launchProject,
breakpointLine: 60
});
assert.strictEqual(sourceLocalsSession.stopped.body.threadId, 1);
const localsStackRequest = sourceLocalsSession.client.send("stackTrace", {
threadId: 1,
startFrame: 0,
levels: 1
});
const localsStack = (await sourceLocalsSession.client.response(localsStackRequest, "stackTrace"))
.body.stackFrames;
assert.strictEqual(localsStack[0].line, 60);
const localsScopesRequest = sourceLocalsSession.client.send("scopes", {
frameId: localsStack[0].id
});
const localsScopes = (await sourceLocalsSession.client.response(localsScopesRequest, "scopes"))
.body.scopes;
const sourceLocalsRef = localsScopes.find((scope) => scope.name === "Source Locals")
.variablesReference;
const sourceLocalsRequest = sourceLocalsSession.client.send("variables", {
variablesReference: sourceLocalsRef
});
const sourceLocals = (
await sourceLocalsSession.client.response(sourceLocalsRequest, "variables")
).body.variables;
assert(
sourceLocals.some(
(variable) =>
variable.name === "linux" &&
String(variable.value).includes("TaskHandle") &&
String(variable.value).includes("compile-linux") &&
String(variable.value).includes("virtual_thread_id = 2")
)
);
assert(
sourceLocals.some((variable) => variable.name === "linux_thread" && variable.value === "2")
);
assert(
sourceLocals.some(
(variable) =>
variable.name === "linux_artifact" && String(variable.value).includes("Artifact")
)
);
} finally {
if (sourceLocalsSession) {
await sourceLocalsSession.client.close().catch(() => {});
}
}
let wasmLocalsSession;
try {
wasmLocalsSession = await launchToBreakpoint({
runtimeBackend: "simulated",
project: launchProject,
breakpointLine: 31
});
assert.strictEqual(wasmLocalsSession.stopped.body.threadId, 1);
const wasmStackRequest = wasmLocalsSession.client.send("stackTrace", {
threadId: 1,
startFrame: 0,
levels: 1
});
const wasmStack = (await wasmLocalsSession.client.response(wasmStackRequest, "stackTrace"))
.body.stackFrames;
assert.strictEqual(wasmStack[0].line, 31);
const wasmScopesRequest = wasmLocalsSession.client.send("scopes", {
frameId: wasmStack[0].id
});
const wasmScopes = (await wasmLocalsSession.client.response(wasmScopesRequest, "scopes")).body
.scopes;
const wasmLocalsRef = wasmScopes.find((scope) => scope.name === "Wasm Frame Locals")
.variablesReference;
const wasmLocalsRequest = wasmLocalsSession.client.send("variables", {
variablesReference: wasmLocalsRef
});
const wasmLocals = (await wasmLocalsSession.client.response(wasmLocalsRequest, "variables"))
.body.variables;
assert(
wasmLocals.some(
(variable) =>
variable.name === "wasm_local_0" &&
String(variable.value).includes("41") &&
variable.type === "wasm-frame-local"
),
"DAP variables must expose Wasmtime frame-local values from the product node runtime"
);
} finally {
if (wasmLocalsSession) {
await wasmLocalsSession.client.close().catch(() => {});
}
}
let liveCoordinator;
let liveWorker;
let liveSession;
try {
liveCoordinator = await startCoordinator();
liveSession = await launchToBreakpoint({
runtimeBackend: "live-services",
operatorEndpoint: liveCoordinator.listen,
project: launchProject,
breakpointLines: [12, 22]
});
assert.strictEqual(liveSession.stopped.body.threadId, 1);
const liveStackRequest = liveSession.client.send("stackTrace", {
threadId: 1,
startFrame: 0,
levels: 1
});
const liveStack = (await liveSession.client.response(liveStackRequest, "stackTrace")).body
.stackFrames;
assert.strictEqual(liveStack[0].line, 12);
assert.strictEqual(liveStack[0].source.path, launchSource);
const liveScopesRequest = liveSession.client.send("scopes", { frameId: liveStack[0].id });
const liveScopes = (await liveSession.client.response(liveScopesRequest, "scopes")).body.scopes;
const liveRuntimeRef = liveScopes.find((scope) => scope.name === "Disasmer Runtime")
.variablesReference;
const liveOutputRef = liveScopes.find((scope) => scope.name === "Recent Output")
.variablesReference;
const liveRuntimeRequest = liveSession.client.send("variables", {
variablesReference: liveRuntimeRef
});
const liveRuntime = (await liveSession.client.response(liveRuntimeRequest, "variables")).body
.variables;
assert(
liveRuntime.some(
(variable) => variable.name === "runtime_backend" && variable.value === "LiveServices"
)
);
assert(
liveRuntime.some(
(variable) => variable.name === "coordinator_task_events" && variable.value === 0
)
);
assert(
liveRuntime.some(
(variable) =>
variable.name === "command_status" &&
String(variable.value).includes("coordinator-side virtual process started")
)
);
const liveOutputRequest = liveSession.client.send("variables", {
variablesReference: liveOutputRef
});
const liveOutput = (await liveSession.client.response(liveOutputRequest, "variables")).body
.variables;
assert(
liveOutput.some((variable) =>
String(variable.value).includes("Command-capability virtual task")
)
);
liveWorker = await startExplicitWorker(liveCoordinator.listen);
const liveContinueRequest = liveSession.client.send("continue", { threadId: 1 });
await liveSession.client.response(liveContinueRequest, "continue");
await liveSession.client.waitFor(
(message) => message.type === "event" && message.event === "continued"
);
const liveTaskStopped = await liveSession.client.waitFor(
(message) =>
message.type === "event" &&
message.event === "stopped" &&
message.body.reason === "breakpoint" &&
message.body.threadId === 2
);
assert.strictEqual(liveTaskStopped.body.threadId, 2);
const liveTaskStackRequest = liveSession.client.send("stackTrace", {
threadId: 2,
startFrame: 0,
levels: 1
});
const liveTaskStack = (await liveSession.client.response(liveTaskStackRequest, "stackTrace"))
.body.stackFrames;
assert.strictEqual(liveTaskStack[0].line, 22);
const liveTaskScopesRequest = liveSession.client.send("scopes", {
frameId: liveTaskStack[0].id
});
const liveTaskScopes = (await liveSession.client.response(liveTaskScopesRequest, "scopes"))
.body.scopes;
const liveTaskRuntimeRef = liveTaskScopes.find((scope) => scope.name === "Disasmer Runtime")
.variablesReference;
const liveTaskOutputRef = liveTaskScopes.find((scope) => scope.name === "Recent Output")
.variablesReference;
const liveTaskRuntimeRequest = liveSession.client.send("variables", {
variablesReference: liveTaskRuntimeRef
});
const liveTaskRuntime = (await liveSession.client.response(liveTaskRuntimeRequest, "variables"))
.body.variables;
assert(
liveTaskRuntime.some(
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
)
);
assert(
liveTaskRuntime.some(
(variable) =>
variable.name === "command_status" &&
String(variable.value).includes("completed through live services")
)
);
assert(liveTaskRuntime.some((variable) => variable.name === "stdout_tail"));
assert(liveTaskRuntime.some((variable) => variable.name === "stderr_tail"));
const liveTaskOutputRequest = liveSession.client.send("variables", {
variablesReference: liveTaskOutputRef
});
const liveTaskOutput = (await liveSession.client.response(liveTaskOutputRequest, "variables"))
.body.variables;
assert(liveTaskOutput.some((variable) => variable.name === "stdout_tail"));
assert(liveTaskOutput.some((variable) => variable.name === "stderr_tail"));
assert(
liveTaskOutput.some((variable) =>
String(variable.value).includes("attached node completed task")
)
);
} finally {
if (liveSession) {
await liveSession.client.close().catch(() => {});
}
killChild(liveWorker);
killChild(liveCoordinator && liveCoordinator.child);
}
const failingProject = createFailingProject();
let failedSession;
try {
failedSession = await launchToBreakpoint({
runtimeBackend: "local-services",
breakpointLine: 42,
project: failingProject
});
assert.strictEqual(failedSession.stopped.body.threadId, 2);
const failedStackRequest = failedSession.client.send("stackTrace", {
threadId: 2,
startFrame: 0,
levels: 1
});
const failedStack = (await failedSession.client.response(failedStackRequest, "stackTrace")).body
.stackFrames;
assert.strictEqual(failedStack.length, 1);
assert.match(failedStack[0].name, /compile linux::run/);
const failedScopesRequest = failedSession.client.send("scopes", { frameId: failedStack[0].id });
const failedScopes = (await failedSession.client.response(failedScopesRequest, "scopes")).body
.scopes;
const failedRuntimeRef = failedScopes.find((scope) => scope.name === "Disasmer Runtime")
.variablesReference;
const failedOutputRef = failedScopes.find((scope) => scope.name === "Recent Output")
.variablesReference;
const failedRuntimeRequest = failedSession.client.send("variables", {
variablesReference: failedRuntimeRef
});
const failedRuntime = (await failedSession.client.response(failedRuntimeRequest, "variables"))
.body.variables;
assert(
failedRuntime.some(
(variable) =>
variable.name === "command_status" &&
String(variable.value).includes("failed through local services")
)
);
assert(
failedRuntime.some(
(variable) => variable.name === "state" && String(variable.value).includes("Failed")
)
);
const failedOutputRequest = failedSession.client.send("variables", {
variablesReference: failedOutputRef
});
const failedOutput = (await failedSession.client.response(failedOutputRequest, "variables")).body
.variables;
assert(
failedOutput.some((variable) => String(variable.value).includes("attached node failed task"))
);
const failedRestartRequest = failedSession.client.send("restartFrame", {
frameId: failedStack[0].id,
sourceEdit: { compatibility: "compatible" }
});
await failedSession.client.response(failedRestartRequest, "restartFrame");
await failedSession.client.waitFor(
(message) =>
message.type === "event" &&
message.event === "output" &&
String(message.body.output).includes("Restarted failed task")
);
const failedRuntimeAfterRestartRequest = failedSession.client.send("variables", {
variablesReference: failedRuntimeRef
});
const failedRuntimeAfterRestart = (
await failedSession.client.response(failedRuntimeAfterRestartRequest, "variables")
).body.variables;
assert(
failedRuntimeAfterRestart.some(
(variable) =>
variable.name === "command_status" &&
String(variable.value).includes("failed task restarted")
)
);
const failedOutputAfterRestartRequest = failedSession.client.send("variables", {
variablesReference: failedOutputRef
});
const failedOutputAfterRestart = (
await failedSession.client.response(failedOutputAfterRestartRequest, "variables")
).body.variables;
assert(
failedOutputAfterRestart.some((variable) =>
String(variable.value).includes("task restarted from VFS checkpoint")
)
);
} finally {
if (failedSession) {
await failedSession.client.close().catch(() => {});
}
fs.rmSync(failingProject, { recursive: true, force: true });
}
const mainSession = await launchToBreakpoint({
runtimeBackend: "local-services",
project: launchProject,
breakpointLine: 42
});
assert.strictEqual(mainSession.stopped.body.threadId, 1);
const mainStackRequest = mainSession.client.send("stackTrace", {
threadId: 1,
startFrame: 0,
levels: 1
});
const mainStack = (await mainSession.client.response(mainStackRequest, "stackTrace"))
.body.stackFrames;
assert.strictEqual(mainStack.length, 1);
assert.match(mainStack[0].name, /build virtual process::run/);
assert.strictEqual(mainStack[0].line, 42);
assert.strictEqual(mainStack[0].source.path, launchSource);
assert.strictEqual(mainStack[0].source.sourceReference || 0, 0);
const mainSourceRequest = mainSession.client.send("source", { source: mainStack[0].source });
const mainSource = (await mainSession.client.response(mainSourceRequest, "source")).body;
assert.match(mainSource.content, /build/);
const mainScopesRequest = mainSession.client.send("scopes", { frameId: mainStack[0].id });
const mainScopes = (await mainSession.client.response(mainScopesRequest, "scopes")).body.scopes;
const mainRuntimeRef = mainScopes.find((scope) => scope.name === "Disasmer Runtime")
.variablesReference;
const mainRuntimeRequest = mainSession.client.send("variables", {
variablesReference: mainRuntimeRef
});
const mainRuntime = (await mainSession.client.response(mainRuntimeRequest, "variables")).body
.variables;
assert(
mainRuntime.some(
(variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"
)
);
assert(
mainRuntime.some(
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
)
);
await mainSession.client.close();
const multiMainSession = await launchToBreakpoint({
runtimeBackend: "local-services",
project: launchProject,
breakpointLines: [42, 43]
});
assert.strictEqual(multiMainSession.stopped.body.threadId, 1);
const firstMainStackRequest = multiMainSession.client.send("stackTrace", {
threadId: 1,
startFrame: 0,
levels: 1
});
const firstMainStack = (await multiMainSession.client.response(firstMainStackRequest, "stackTrace"))
.body.stackFrames;
assert.strictEqual(firstMainStack[0].line, 42);
const continueMainRequest = multiMainSession.client.send("continue", { threadId: 1 });
await multiMainSession.client.response(continueMainRequest, "continue");
await multiMainSession.client.waitFor(
(message) => message.type === "event" && message.event === "continued"
);
const secondMainStop = await multiMainSession.client.waitFor(
(message) =>
message.type === "event" &&
message.event === "stopped" &&
message.body.reason === "breakpoint"
);
assert.strictEqual(secondMainStop.body.threadId, 1);
const secondMainStackRequest = multiMainSession.client.send("stackTrace", {
threadId: 1,
startFrame: 0,
levels: 1
});
const secondMainStack = (
await multiMainSession.client.response(secondMainStackRequest, "stackTrace")
).body.stackFrames;
assert.strictEqual(secondMainStack[0].line, 43);
await multiMainSession.client.close();
console.log("DAP smoke passed");
})().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});

333
scripts/docs-smoke.js Normal file
View file

@ -0,0 +1,333 @@
#!/usr/bin/env node
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const readme = fs.readFileSync(path.join(repo, "README.md"), "utf8");
const userFacingDocs = [
"README.md",
"MVP.md",
"acceptance_criteria.md",
"acceptance_criteria_phase2.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/],
["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", /node `disasmer\.cmd_run` host import/],
["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_PUBLIC_KEY=<agent-public-key> disasmer run build/],
["agent key lifecycle", /register, list, rotate, and revoke an agent key/],
["capability auto-detect", /auto-detects OS, architecture/],
["capability override", /--cap <name>/],
["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`/],
["public dry-run operator endpoint", /https:\/\/disasmer\.michelpaulissen\.com:9443/],
["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 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=<instructions>/],
["public dry-run fallback hosts entry env", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY="<ip-address> disasmer\.michelpaulissen\.com"/],
["public dry-run deployment IP env", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP=<ip-address>/],
["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=<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=<owner>/],
["public dry-run Forgejo repo name override", /DISASMER_PUBLIC_REPO_NAME=<public-repo>/],
["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:9443/],
["public dry-run private hosted coordinator", /default operator at `disasmer\.michelpaulissen\.com:9443` is the private hosted\s+coordinator from `private\/hosted-policy`/],
["public dry-run not standalone public coordinator", /does not mean deploying the standalone\s+open-source\/public coordinator as the hosted operator/],
["public dry-run validates both coordinators", /dry-run acceptance validates both coordinator implementations/],
["public dry-run validates public coordinator separately", /standalone public\/open-source\s+coordinator is validated separately/],
["public browser login site", /disasmer\.michelpaulissen\.com\/auth\/browser\/start/],
["public browser login local callback", /local callback/],
["public dry-run barebones HTML no CSS", /barebones HTML with\s+no CSS/],
["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 that\s+binds `0\.0\.0\.0:9443`/],
["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:9443/],
["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/],
["public operator compatibility smoke", /public-operator-compat-smoke\.js/],
["public operator compatibility purpose", /public CLI and public\s+node runtime can attach, launch work through coordinator task assignment, and\s+publish debug\/log\/artifact metadata/],
];
for (const [name, pattern] of requiredReadmePatterns) {
assert.match(readme, pattern, `README missing ${name}`);
}
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-login-smoke.js"),
"public acceptance gates must run cli-login-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/cancellation-smoke.js"),
"public acceptance gates must run cancellation-smoke.js"
);
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/public-operator-compat-smoke.js"),
"private acceptance must run public-operator-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");

View file

@ -0,0 +1,76 @@
#!/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");
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|Command::new|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 rely on coordinator-side filesystem, Git, shell, container, or machine-local assumptions"
);
const inspection = JSON.parse(
cp.execFileSync(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-cli",
"--bin",
"disasmer",
"--",
"bundle",
"inspect",
"--project",
project
],
{ 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"));
cp.execFileSync("cargo", ["test", "-p", "launch-build-demo"], {
cwd: repo,
stdio: "inherit"
});
console.log("Flagship demo smoke passed");

View file

@ -0,0 +1,176 @@
#!/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");
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\("<script"\)\)/,
/assert\(!JSON\.stringify\(panel\)\.toLowerCase\(\)\.includes\("oauth"\)\)/,
/rate limit/i,
/exceeds download limit/,
],
],
[
"scheduler smoke",
schedulerSmoke,
[/const crossTenantReport = await send/, /report_node_capabilities/, /tenant\\\/project scope/],
],
[
"source preparation smoke",
sourcePreparationSmoke,
[/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i],
],
]) {
for (const pattern of patterns) {
expect(source, name, pattern);
}
}
expectGate(publicAcceptance, "public acceptance");
expectGate(publicSplit, "public split acceptance");
expectGate(privateAcceptance, "private acceptance");
const hostedService = maybeRead([
"private",
"hosted-policy",
"src",
"bin",
"disasmer-hosted-service.rs",
]);
const hostedSmoke = maybeRead([
"private",
"hosted-policy",
"scripts",
"hosted-community-smoke.js",
]);
if (hostedService && hostedSmoke) {
for (const [name, pattern] of [
["hosted service turns malformed JSON into error responses", /serde_json::from_str::<HostedRequest>[\s\S]*HostedResponse::Error/],
["tenant ids are validated", /fn tenant_id\(value: String\)[\s\S]*validate_identifier\("tenant", &value\)\?/],
["project ids are validated", /fn project_id\(value: String\)[\s\S]*validate_identifier\("project", &value\)\?/],
["user ids are validated", /fn user_id\(value: String\)[\s\S]*validate_identifier\("user", &value\)\?/],
["node ids are validated", /fn node_id\(value: String\)[\s\S]*validate_identifier\("node", &value\)\?/],
["process ids are validated", /fn process_id\(value: String\)[\s\S]*validate_identifier\("process", &value\)\?/],
["task ids are validated", /fn task_id\(value: String\)[\s\S]*validate_identifier\("task", &value\)\?/],
["artifact ids are validated", /fn artifact_id\(value: String\)[\s\S]*validate_identifier\("artifact", &value\)\?/],
["identifiers reject empty and control/path characters", /fn validate_identifier[\s\S]*trim\(\)\.is_empty\(\)[\s\S]*ch\.is_control\(\) \|\| ch == '\/' \|\| ch == '\\\\'/],
["OIDC and agent text fields are bounded", /fn validate_text[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
["tokens are bounded", /fn validate_token[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
["logs are bounded at service boundary", /fn validate_log[\s\S]*512 \* 1024/],
["debug requests validate process and task before inspection", /HostedRequest::DebugProcess[\s\S]*process_id\(process\)\?[\s\S]*task_id\(task\)\?[\s\S]*debug_process/],
["download requests validate artifact before action", /HostedRequest::DownloadAction[\s\S]*artifact_id\(artifact\)\?[\s\S]*download_action/],
["log completion requests validate task and stdout before recording", /HostedRequest::RecordUserNodeTaskCompletion[\s\S]*task_id\(task\)\?[\s\S]*validate_log\("task stdout", &stdout\)\?[\s\S]*record_user_node_task_completion/],
]) {
expect(hostedService, name, pattern);
}
for (const [name, pattern] of [
["running service rejects malformed JSON", /sendRaw\(addr, "\{not-json"\)/],
["running service rejects invalid tenant id", /tenant: " "/],
["running service rejects invalid project id", /project: "project\\nbad"/],
["running service rejects invalid task id", /task: "compile-linux\\nescape"/],
["running service rejects oversized logs", /stdout: "x"\.repeat\(256 \* 1024 \+ 1\)/],
["running service rejects cross-tenant debug", /const crossTenantDebug = await send/],
["running service rejects cross-tenant metadata", /const crossTenantMetadata = await send/],
["running service rejects cross-tenant download", /const crossTenantDownload = await send/],
["foreign snapshots do not reveal objects", /foreignSnapshot[\s\S]*snapshot\.nodes, \[\][\s\S]*snapshot\.logs, \[\][\s\S]*snapshot\.artifacts, \[\]/],
]) {
expect(hostedSmoke, name, pattern);
}
}
console.log("Hostile input contract smoke passed");

View file

@ -0,0 +1,188 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const project = path.join(repo, "examples/launch-build-demo");
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;
const line = buffer.slice(0, newline).trim();
try {
resolve(JSON.parse(line));
} 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(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 runNode(addr, enrollmentGrant) {
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",
"node-a",
"--enrollment-grant",
enrollmentGrant,
"--public-key",
"node-a-public-key",
"--process",
"vp-local",
"--task",
"compile-linux",
"--project",
project,
"--artifact",
"/vfs/artifacts/demo-test-output.txt"
],
{ cwd: repo }
);
const nodePid = 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(`node process failed with code ${code}\n${stderr}`));
return;
}
try {
resolve({ pid: nodePid, report: JSON.parse(stdout.trim().split("\n").at(-1)) });
} catch (error) {
reject(new Error(`node 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"
],
{ cwd: repo }
);
assert(Number.isInteger(coordinator.pid));
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 grant = await send(addr, {
type: "create_node_enrollment_grant",
tenant: "tenant",
project: "project",
actor_user: "user",
grant: "grant-local-services-node",
now_epoch_seconds: 0,
ttl_seconds: 900
});
assert.strictEqual(grant.type, "node_enrollment_grant_created");
const { pid: nodePid, report } = await runNode(addr, grant.grant);
assert(Number.isInteger(nodePid));
assert.notStrictEqual(nodePid, coordinator.pid);
assert.strictEqual(report.node_status, "completed");
assert.strictEqual(report.status_code, 0);
assert.strictEqual(report.large_bytes_uploaded, false);
assert.strictEqual(report.registration_response.type, "node_enrollment_exchanged");
assert.strictEqual(report.heartbeat_response.type, "node_heartbeat");
assert.strictEqual(report.capability_response.type, "node_capabilities_recorded");
assert.strictEqual(report.task_assignment_response.type, "task_placement");
assert.strictEqual(report.debug_command_response.type, "debug_command");
assert.strictEqual(report.log_event_response.type, "task_log_recorded");
assert.strictEqual(report.vfs_metadata_response.type, "vfs_metadata_recorded");
assert.strictEqual(report.session_requests, 10);
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/demo-test-output.txt");
assert.strictEqual(report.coordinator_response.type, "task_recorded");
const events = await send(addr, {
type: "list_task_events",
tenant: "tenant",
project: "project",
actor_user: "user",
process: "vp-local"
});
assert.strictEqual(events.type, "task_events");
assert.strictEqual(events.events.length, 1);
assert.strictEqual(events.events[0].node, "node-a");
assert.strictEqual(events.events[0].process, "vp-local");
assert.strictEqual(events.events[0].task, "compile-linux");
assert.strictEqual(events.events[0].status_code, 0);
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/demo-test-output.txt");
} finally {
coordinator.kill("SIGTERM");
}
console.log("Local services smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

270
scripts/node-attach-smoke.js Executable file
View file

@ -0,0 +1,270 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const demoProject = path.join(repo, "examples/launch-build-demo");
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(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 runAttach(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",
"node-attach",
"--public-key",
"node-attach-public-key",
"--enrollment-grant",
grant,
"--cap",
"quic-direct",
],
{ cwd: repo }
);
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}`)
);
}
});
});
}
function runAttachedNodeWork(addr) {
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",
"node-attach",
"--process",
"vp-node-attach",
"--task",
"compile-linux",
"--project",
demoProject,
"--artifact",
"/vfs/artifacts/node-attach-output.txt",
],
{ cwd: repo }
);
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(`attached node work failed with code ${code}\n${stderr}`));
return;
}
try {
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
} catch (error) {
reject(
new Error(
`attached node work 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",
],
{ 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",
grant: "grant-node-attach",
now_epoch_seconds: 0,
ttl_seconds: 900
});
assert.strictEqual(grant.type, "node_enrollment_grant_created");
assert.strictEqual(grant.tenant, "tenant");
assert.strictEqual(grant.project, "project");
assert.strictEqual(grant.grant, "grant-node-attach");
assert.strictEqual(grant.scope, "node:attach");
assert.strictEqual(grant.expires_at_epoch_seconds, 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-node-attach");
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.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",
});
assert.strictEqual(heartbeat.type, "node_heartbeat");
assert.strictEqual(heartbeat.node, "node-attach");
const work = await runAttachedNodeWork(addr);
assert.strictEqual(work.node_status, "completed");
assert.strictEqual(work.virtual_thread, "compile-linux");
assert.strictEqual(work.status_code, 0);
assert.strictEqual(work.large_bytes_uploaded, false);
assert.strictEqual(
work.staged_artifact.path,
"/vfs/artifacts/node-attach-output.txt"
);
assert.strictEqual(work.coordinator_response.type, "task_recorded");
const events = await send(addr, {
type: "list_task_events",
tenant: "tenant",
project: "project",
actor_user: "operator",
process: "vp-node-attach",
});
assert.strictEqual(events.type, "task_events");
assert.strictEqual(events.events.length, 1);
assert.strictEqual(events.events[0].node, "node-attach");
assert.strictEqual(events.events[0].task, "compile-linux");
assert.strictEqual(
events.events[0].artifact_path,
"/vfs/artifacts/node-attach-output.txt"
);
} finally {
coordinator.kill("SIGTERM");
}
console.log("Node attach smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

View file

@ -0,0 +1,138 @@
#!/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/main.rs");
const nodeLib = read("crates/disasmer-node/src/lib.rs");
const coordinatorCore = read("crates/disasmer-coordinator/src/lib.rs");
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
const localServicesSmoke = read("scripts/local-services-smoke.js");
const cancellationSmoke = read("scripts/cancellation-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"/],
["node attach over session", /"type": "attach_node"/],
["heartbeat over session", /"type": "node_heartbeat"/],
["capability report over session", /"type": "report_node_capabilities"/],
["task placement over session", /"type": "schedule_task"/],
["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", /wait_for_cancellation\(session, args, &task\)/],
["request count is reported", /session\.requests\(\)/],
]) {
expect(nodeMain, name, pattern);
}
for (const [name, pattern] of [
["local smoke asserts persistent request count", /assert\.strictEqual\(report\.session_requests, 10\)/],
["local smoke uses enrollment exchange", /assert\.strictEqual\(report\.registration_response\.type, "node_enrollment_exchanged"\)/],
["local smoke verifies capability report", /assert\.strictEqual\(report\.capability_response\.type, "node_capabilities_recorded"\)/],
["local smoke verifies task placement", /assert\.strictEqual\(report\.task_assignment_response\.type, "task_placement"\)/],
["local smoke verifies debug command channel", /assert\.strictEqual\(report\.debug_command_response\.type, "debug_command"\)/],
["local smoke verifies log event", /assert\.strictEqual\(report\.log_event_response\.type, "task_log_recorded"\)/],
["local smoke verifies VFS metadata", /assert\.strictEqual\(report\.vfs_metadata_response\.type, "vfs_metadata_recorded"\)/],
["local smoke records task completion", /assert\.strictEqual\(report\.coordinator_response\.type, "task_recorded"\)/],
["local smoke verifies artifact metadata", /assert\.strictEqual\(events\.events\[0\]\.artifact_path, "\/vfs\/artifacts\/demo-test-output\.txt"\)/],
["local smoke verifies output accounting", /assert\.strictEqual\(events\.events\[0\]\.status_code, 0\)/],
]) {
expect(localServicesSmoke, name, pattern);
}
for (const [name, pattern] of [
["cancellation request is external client request", /type: "cancel_task"/],
["node reports cancelled terminal state", /assert\.strictEqual\(report\.terminal_state, "cancelled"\)/],
["cancelled completion is recorded", /assert\.strictEqual\(report\.coordinator_response\.type, "task_recorded"\)/],
["coordinator stores cancelled task event", /assert\.strictEqual\(events\.events\[0\]\.terminal_state, "cancelled"\)/],
["control flag is cleared after terminal state", /assert\.strictEqual\(control\.cancel_requested, false\)/],
]) {
expect(cancellationSmoke, 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(coordinatorService, 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(nodeLib, 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 one node coordinator session", /keeps one node-to-coordinator JSON-line session open/],
["docs describe cancellation terminal event", /records a cancelled terminal event/],
["docs describe failed freeze diagnostic", /Failed debug freezes/],
]) {
expect(readme, name, pattern);
}
console.log("Node lifecycle contract smoke passed");

356
scripts/operator-panel-smoke.js Executable file
View file

@ -0,0 +1,356 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const project = path.join(repo, "examples/launch-build-demo");
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(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 runNode(addr) {
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",
"panel-node",
"--process",
"vp-panel",
"--task",
"compile-linux",
"--project",
project,
"--artifact",
"/vfs/artifacts/panel-output.txt"
],
{ cwd: repo }
);
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 process failed with code ${code}\n${stderr}`));
return;
}
try {
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
} catch (error) {
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
}
});
});
}
function widget(panel, id) {
const item = panel.widgets[id];
assert(item, `missing panel widget ${id}`);
return item;
}
(async () => {
const coordinator = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
"--listen",
"127.0.0.1:0"
],
{ 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 report = await runNode(addr);
assert.strictEqual(report.node_status, "completed");
assert.strictEqual(report.coordinator_response.type, "task_recorded");
const rendered = await send(addr, {
type: "render_operator_panel",
tenant: "tenant",
project: "project",
process: "vp-panel",
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, "vp-panel");
assert.strictEqual(panel.program_ui_events_enabled, true);
assert.deepStrictEqual(widget(panel, "process-status").kind, {
Text: { value: "running" }
});
assert.deepStrictEqual(widget(panel, "task-progress").kind, {
Progress: { current: 1, total: 1 }
});
assert.match(
widget(panel, "task-summary").kind.Text.value,
/compile-linux:Some\(0\):panel-node/
);
assert.match(widget(panel, "recent-logs").kind.Text.value, /stdout=\d+ stderr=\d+/);
const downloadWidget = widget(panel, "download-artifact").kind;
assert.deepStrictEqual(downloadWidget, {
ArtifactDownload: { artifact: "panel-output.txt" }
});
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 === "compile-linux"
)
);
assert(
panel.control_plane_actions.some(
(action) => action.DownloadArtifact === "panel-output.txt"
)
);
assert(!JSON.stringify(panel).includes("<script"));
assert(!JSON.stringify(panel).toLowerCase().includes("oauth"));
const panelLink = await send(addr, {
type: "create_artifact_download_link",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: downloadWidget.ArtifactDownload.artifact,
max_bytes: 1024 * 1024,
token_nonce: "panel-button",
now_epoch_seconds: 10,
ttl_seconds: 60
});
assert.strictEqual(panelLink.type, "artifact_download_link");
assert.strictEqual(panelLink.link.artifact, downloadWidget.ArtifactDownload.artifact);
assert.match(panelLink.link.policy_context_digest, /^sha256:[0-9a-f]{64}$/);
assert.deepStrictEqual(panelLink.link.source, { RetainedNode: "panel-node" });
assert.match(panelLink.link.url_path, /\/artifacts\/tenant\/project\/vp-panel\/panel-output\.txt$/);
const panelStream = await send(addr, {
type: "open_artifact_download_stream",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: downloadWidget.ArtifactDownload.artifact,
max_bytes: 1024 * 1024,
token_nonce: "panel-button",
token_digest: panelLink.link.scoped_token_digest,
now_epoch_seconds: 11,
chunk_bytes: 16
});
assert.strictEqual(panelStream.type, "artifact_download_stream");
assert.strictEqual(panelStream.link.artifact, downloadWidget.ArtifactDownload.artifact);
assert.deepStrictEqual(panelStream.link.source, { RetainedNode: "panel-node" });
assert.strictEqual(panelStream.streamed_bytes, 16);
const apiTooLarge = await send(addr, {
type: "create_artifact_download_link",
tenant: "tenant",
project: "project",
actor_user: "user",
artifact: downloadWidget.ArtifactDownload.artifact,
max_bytes: 1,
token_nonce: "too-small",
now_epoch_seconds: 10,
ttl_seconds: 60
});
assert.strictEqual(apiTooLarge.type, "error");
assert.match(apiTooLarge.message, /exceeds download limit/);
const panelTooLarge = await send(addr, {
type: "render_operator_panel",
tenant: "tenant",
project: "project",
process: "vp-panel",
actor_user: "user",
max_download_bytes: 1,
stopped: false
});
assert.strictEqual(panelTooLarge.type, "error");
assert.match(panelTooLarge.message, /exceeds download limit/);
const accepted = await send(addr, {
type: "submit_panel_event",
tenant: "tenant",
project: "project",
process: "vp-panel",
widget_id: "debug-process",
kind: "ButtonClicked",
max_events: 1
});
assert.strictEqual(accepted.type, "panel_event_accepted");
assert.strictEqual(accepted.used_events, 1);
assert.strictEqual(accepted.max_events, 1);
const rateLimited = await send(addr, {
type: "submit_panel_event",
tenant: "tenant",
project: "project",
process: "vp-panel",
widget_id: "debug-process",
kind: "ButtonClicked",
max_events: 1
});
assert.strictEqual(rateLimited.type, "error");
assert.match(rateLimited.message, /rate limit/i);
const stopped = await send(addr, {
type: "render_operator_panel",
tenant: "tenant",
project: "project",
process: "vp-panel",
actor_user: "user",
max_download_bytes: 1024 * 1024,
stopped: true
});
assert.strictEqual(stopped.type, "operator_panel");
assert.strictEqual(stopped.panel.program_ui_events_enabled, false);
assert.deepStrictEqual(widget(stopped.panel, "process-status").kind, {
Text: { value: "stopped" }
});
assert.deepStrictEqual(
widget(stopped.panel, "task-progress").kind,
widget(panel, "task-progress").kind
);
assert.deepStrictEqual(
widget(stopped.panel, "task-summary").kind,
widget(panel, "task-summary").kind
);
assert.deepStrictEqual(
widget(stopped.panel, "recent-logs").kind,
widget(panel, "recent-logs").kind
);
assert.deepStrictEqual(
widget(stopped.panel, "download-artifact").kind,
widget(panel, "download-artifact").kind
);
assert(stopped.panel.control_plane_actions.includes("DebugProcess"));
assert(
stopped.panel.control_plane_actions.some(
(action) => action.DownloadArtifact === "panel-output.txt"
)
);
const frozenEvent = await send(addr, {
type: "submit_panel_event",
tenant: "tenant",
project: "project",
process: "vp-panel",
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: "vp-panel",
actor_user: "user",
max_download_bytes: 1024 * 1024,
stopped: false
});
assert.strictEqual(crossTenant.type, "error");
assert.match(crossTenant.message, /scope|tenant|project/i);
} catch (error) {
if (coordinatorStderr) {
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
}
throw error;
} finally {
coordinator.kill("SIGTERM");
}
console.log("Operator panel smoke passed");
})().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});

80
scripts/podman-backend-smoke.js Executable file
View file

@ -0,0 +1,80 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const path = require("path");
const repo = path.resolve(__dirname, "..");
const baseImage = "docker.io/library/alpine:3.20";
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;
}

View file

@ -0,0 +1,562 @@
#!/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 defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
const forgejoHost = "git.michelpaulissen.com";
const filteredOut = ["private", "experiments", ".git", "target"];
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 {
return cp
.execFileSync(command, args, {
cwd: repo,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
...options,
})
.trim();
} catch (_) {
return null;
}
}
function run(command, args, options = {}) {
cp.execFileSync(command, args, {
cwd: repo,
stdio: "inherit",
...options,
});
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function copyFilteredTree(src, dest, 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;
const topLevel = childRelative.split(path.sep)[0];
if (filteredOut.includes(topLevel)) {
continue;
}
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyFilteredTree(from, to, 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() {
for (const excluded of ["private", "experiments"]) {
if (fs.existsSync(path.join(publicTree, excluded))) {
throw new Error(`${excluded}/ leaked into the dry-run public tree`);
}
}
}
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"], {
cwd: publicTree,
});
}
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(publicTree, "target", "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 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",
"--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 operator is the private hosted coordinator at
\`https://disasmer.michelpaulissen.com:9443\`. 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/public 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 operator endpoint:
\`\`\`bash
disasmer login --browser
disasmer bundle inspect --project examples/launch-build-demo
\`\`\`
\`disasmer login --browser\` opens your browser through the barebones
\`https://disasmer.michelpaulissen.com\` login site and returns to the CLI
through a local callback.
Enroll a node identity with the grant supplied by the invitation:
\`\`\`bash
disasmer node attach --coordinator https://disasmer.michelpaulissen.com:9443 --enrollment-grant <grant> --public-key <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:9443 --tenant <tenant> --project-id <project> --node <node-id> --public-key <public-key> --enrollment-grant <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/<owner>/<public-repo>";
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 default operator behind this invite is the private hosted coordinator at
\`https://disasmer.michelpaulissen.com:9443\`. 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 public
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 operator endpoint: ${defaultOperatorEndpoint}
- 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 barebones
\`https://disasmer.michelpaulissen.com\` login site and returns to the CLI
through a local callback.
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:9443 --tenant <tenant> --project-id <project> --node <node-id> --public-key <public-key> --enrollment-grant <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: ["private/**", "experiments/**", ".git", "target"],
forgejo_host: forgejoHost,
default_operator_endpoint: defaultOperatorEndpoint,
};
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);
copyFilteredTree(repo, publicTree);
assertFilteredTree();
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 extensionArchive = stageExtensionAsset();
const resolution = resolverInstructions();
const gettingStarted = writeGettingStartedAsset(
releaseName,
publicTreeIdentity,
resolution
);
const invite = writeInviteAsset(releaseName, publicTreeIdentity, resolution);
const assets = [
sourceArchive,
binaryArchive,
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: ["private/**", "experiments/**", ".git", "target"],
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_operator_endpoint: defaultOperatorEndpoint,
dns_publication_state:
process.env.DISASMER_DNS_PUBLICATION_STATE || "published",
resolver_override:
process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns",
platform: platformName(),
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",
],
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();

View file

@ -0,0 +1,71 @@
#!/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");
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 default to a real human browser\/account flow/);
}
expect(cliSource, "browser login start constant", /DEFAULT_BROWSER_LOGIN_START: &str = "https:\/\/disasmer\.michelpaulissen\.com\/auth\/browser\/start"/);
expect(cliSource, "default OIDC issuer", /DEFAULT_OIDC_ISSUER_URL: &str = "https:\/\/auth\.michelpaulissen\.com"/);
expect(cliSource, "fixed localhost callback", /BROWSER_CALLBACK_ADDR: &str = "127\.0\.0\.1:45173"/);
expect(cliSource, "diagnostic plan flag", /plan: bool/);
expect(cliSource, "interactive browser branch", /args\.browser && !args\.plan[\s\S]*execute_interactive_browser_login/);
expect(cliSource, "browser command override", /DISASMER_BROWSER_OPEN_COMMAND/);
expect(cliSource, "callback listener", /TcpListener::bind\(BROWSER_CALLBACK_ADDR\)/);
expect(cliSource, "callback completion", /execute_browser_login_completion_for_plan\(args, plan\)/);
expect(cliSmoke, "smoke uses fake browser opener", /DISASMER_BROWSER_OPEN_COMMAND/);
expect(cliSmoke, "smoke verifies callback code", /browser-smoke-code/);
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 API port", /apiPort = 9443/);
expect(hypervisor, "nginx Disasmer vhost", /\$\{stack\.hosts\.disasmer\.publicHost\}/);
expect(hypervisor, "nginx Disasmer site root", /root = \.\.\/\.\.\/disasmer-site/);
expect(hypervisor, "nginx browser start route", /locations\."= \/auth\/browser\/start"\.return/);
expect(hypervisor, "nginx redirects to Authentik", /https:\/\/\$\{stack\.hosts\.authentik\.publicHost\}\/application\/o\/authorize/);
expect(hypervisor, "nginx passes state", /state=\$arg_state/);
expect(hypervisor, "nginx passes redirect URI", /redirect_uri=\$arg_redirect_uri/);
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 localhost redirect", /http:\/\/127\.0\.0\.1:45173\/callback/);
expect(oauth, "Disasmer Authentik application", /slug: disasmer/);
expect(site, "barebones site describes CLI login", /disasmer login --browser/);
expect(site, "barebones site states UX deferral", /Layout and UX work are deferred/);
assert.doesNotMatch(site, /<style|stylesheet|\.css|style=|class=/i, "Disasmer dry-run site must remain barebones HTML with no CSS");
} else {
console.warn("Skipping VPS config checks because ../michelpaulissen.com is not present");
}
console.log("Public browser login contract smoke passed");

View file

@ -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\]/);
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 logs and metadata", /log_event_response[\s\S]*task_log_recorded[\s\S]*vfs_metadata_response[\s\S]*vfs_metadata_recorded/);
expect(cliLocalRun, "local run stages artifact", /\/vfs\/artifacts\/cli-run-output\.txt/);
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 runs work", /runAttachedNodeWork[\s\S]*node_status[\s\S]*completed/);
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", /compile linux[\s\S]*virtual thread/);
expect(vscodeF5, "F5 records coordinator task event", /coordinator_task_events[\s\S]*value === 1/);
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");

View file

@ -0,0 +1,182 @@
#!/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"].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",
"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");

View file

@ -0,0 +1,256 @@
#!/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:9443";
const serviceHost = "disasmer.michelpaulissen.com";
const forgejoHost = "git.michelpaulissen.com";
function read(relativePath) {
return fs.readFileSync(path.join(repo, relativePath), "utf8");
}
function expect(source, name, pattern) {
assert.match(source, pattern, `missing public release dry-run evidence: ${name}`);
}
const phase2 = read("acceptance_criteria_phase2.md");
const readme = read("README.md");
const cliSource = read("crates/disasmer-cli/src/main.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 = read(".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");
for (const [name, source] of [
["phase 2 criteria", phase2],
["README", readme],
]) {
expect(source, name, new RegExp(serviceHost.replaceAll(".", "\\.")));
expect(source, `${name} DNS publication state`, /DNS record|public DNS|dns[-_]publication/i);
expect(source, `${name} resolver fallback`, /no resolver override|required resolver override|hosts entry|controlled resolution|fallback/i);
expect(source, `${name} Forgejo host`, new RegExp(forgejoHost.replaceAll(".", "\\.")));
expect(source, `${name} Forgejo Release`, /Forgejo Release/);
expect(source, `${name} compiled assets`, /compiled\s+(release\s+)?assets|compiled release assets/);
expect(source, `${name} filtered tree`, /private\/\*\*[\s\S]*experiments\/\*\*/);
expect(source, `${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 operator constant", new RegExp(`DEFAULT_OPERATOR_ENDPOINT: &str = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
expect(cliSource, "login uses default operator", /default_value_t = default_operator_endpoint\(\)/);
expect(cliSource, "hosted run records operator endpoint", /operator_endpoint[\s\S]*Some\(default_operator_endpoint\(\)\)/);
expect(cliSource, "hosted URL maps to JSON-line transport", /fn json_line_transport_addr[\s\S]*\("https:\/\/", 443\)/);
expect(cliSource, "JSON-line session uses mapped transport", /TcpStream::connect\(&transport_addr\)/);
expect(cliSource, "default operator maps to port 9443", /json_line_transport_addr\(DEFAULT_OPERATOR_ENDPOINT\)[\s\S]*"disasmer\.michelpaulissen\.com:9443"/);
assert.doesNotMatch(cliSource, /coord\.disasmer\.invalid/);
expect(cliLoginSmoke, "CLI login smoke covers default operator", new RegExp(`defaultOperatorEndpoint = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
expect(vscodeExtension, "VS Code default operator constant", new RegExp(`DEFAULT_OPERATOR_ENDPOINT = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
assert.strictEqual(
vscodePackage.contributes.debuggers[0].configurationAttributes.launch.properties.operatorEndpoint.default,
serviceEndpoint
);
expect(vscodeSmoke, "VS Code smoke covers operator endpoint", /operatorEndpoint\.default/);
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", /filteredOut = \["private", "experiments", "\.git", "target"\]/],
["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 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 says private hosted coordinator", /default operator is the private hosted coordinator/],
["selected-user guide rejects standalone public coordinator", /standalone open-source\/public coordinator/],
["selected-user invite documents friends dry run", /friends helping test[\s\S]*not broadly advertised/],
["selected-user invite says hosted server is private", /hosted server is not the standalone public\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:9443/],
["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 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 verifies public branch commit", /remoteMain[\s\S]*manifest\.public_tree_publish\.commit/],
["preflight verifies local asset checksums", /parseSha256Sums[\s\S]*checksum mismatch/],
["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 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 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 operator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/],
["e2e runner completes browser login", /--complete-browser-code/],
["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 task through coordinator", /type: "launch_task"/],
["e2e runner verifies assignment polling", /worker_assignment_poll_verified/],
["e2e runner validates standalone public coordinator", /validateStandalonePublicCoordinator/],
["e2e runner records public coordinator validation", /public_coordinator_validated/],
["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 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 public operator compatibility report", /public-operator-compat\.json/],
["final evidence reads public coordinator compatibility report", /public-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 operator", /serviceEndpoint = "https:\/\/disasmer\.michelpaulissen\.com:9443"/],
["final evidence requires private hosted coordinator", /operator_implementation[\s\S]*"private-hosted-coordinator"/],
["final evidence requires service private coordinator marker", /service\.operator_implementation[\s\S]*"private-hosted-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 service launch task", /service\.evidence\.public_launch_task[\s\S]*"task_launched"/],
["final evidence requires launch task", /launch_task_response[\s\S]*"task_launched"/],
["final evidence requires assignment polling", /worker_assignment_poll_verified/],
["final evidence requires public coordinator validation", /public_coordinator_validated/],
["final evidence requires standalone public coordinator", /standalone-public-coordinator/],
["final evidence requires pushed public tree", /public_tree_publish[\s\S]*pushed/],
["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);
}
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);
}
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");

View file

@ -0,0 +1,833 @@
#!/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 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 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:9443";
const serviceHost = "disasmer.michelpaulissen.com";
const serviceAddr =
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || `${serviceHost}:9443`;
const forgejoHost = "git.michelpaulissen.com";
const enabled = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1";
const oidcIssuer = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL;
const oidcCode = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE;
const oidcClientId =
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CLIENT_ID || "disasmer";
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 (!oidcIssuer || !oidcCode) {
throw new Error(
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL and DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE are required"
);
}
}
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);
return cp.execFileSync(command, args, {
cwd: repo,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 15 * 60 * 1000,
...options,
});
}
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 {
return cp
.execFileSync(command, args, {
cwd: repo,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
...options,
})
.trim();
} catch (_) {
return null;
}
}
function expectedSourceCommit() {
return (
process.env.DISASMER_ACCEPTANCE_COMMIT ||
commandOutput("git", ["rev-parse", "HEAD"]) ||
"unknown"
);
}
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 socket = net.connect(addr.port, addr.host, () => {
socket.write(`${JSON.stringify(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 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 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`);
}
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);
assert.deepStrictEqual(provenance.filtered_out, [
"private/**",
"experiments/**",
".git",
"target",
]);
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 validateStandalonePublicCoordinator(disasmerCoordinator, disasmerNode, checkout) {
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 = "compile-linux";
const artifactPath = "/vfs/artifacts/public-coordinator-output.txt";
try {
const coordinatorArgs = ["--listen", "127.0.0.1:0"];
commands.push([disasmerCoordinator, ...coordinatorArgs].join(" "));
coordinator = cp.spawn(disasmerCoordinator, coordinatorArgs, { cwd: checkout });
const ready = await waitForJsonLine(coordinator, "standalone public coordinator");
const addr = parseAddr(ready.listen);
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
const created = await send(addr, {
type: "create_project",
tenant,
actor_user: "developer",
project,
name: "Public Coordinator Dry Run",
});
assert(
["project", "project_created"].includes(created.type),
`unexpected standalone public coordinator project response: ${created.type}`
);
const workerArgs = [
"--coordinator",
ready.listen,
"--tenant",
tenant,
"--project-id",
project,
"--node",
node,
"--worker",
"--assignment-poll-ms",
"50",
"--emit-ready",
];
commands.push([disasmerNode, ...workerArgs].join(" "));
worker = cp.spawn(disasmerNode, workerArgs, { cwd: checkout });
worker.stderr.on("data", (chunk) => {
workerStderr += chunk.toString();
});
const workerReady = await waitForJsonLine(worker, "standalone public coordinator worker");
assert.strictEqual(workerReady.node_status, "ready");
assert.strictEqual(workerReady.mode, "worker");
assert.strictEqual(workerReady.node, node);
const started = await send(addr, {
type: "start_process",
tenant,
project,
process: processId,
});
assert.strictEqual(started.type, "process_started");
const launch = await send(addr, {
type: "launch_task",
tenant,
project,
actor_user: "developer",
process: processId,
task,
environment: null,
environment_digest: null,
required_capabilities: ["Command"],
dependency_cache: null,
source_snapshot: null,
required_artifacts: [],
quota_available: true,
policy_allowed: true,
command: "cargo",
command_args: [
"test",
"--quiet",
"--manifest-path",
path.join(checkout, "examples/launch-build-demo", "Cargo.toml"),
],
artifact_path: artifactPath,
});
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 public coordinator worker completion");
assert.strictEqual(nodeRun.node_status, "completed");
assert.strictEqual(nodeRun.registration_response.type, "node_attached");
assert.strictEqual(nodeRun.task_assignment_response.node, node);
assert.strictEqual(nodeRun.task_assignment_response.process, processId);
assert.strictEqual(nodeRun.task_assignment_response.task, task);
assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded");
const events = await send(addr, {
type: "list_task_events",
tenant,
project,
actor_user: "developer",
process: processId,
});
assert.strictEqual(events.type, "task_events");
assert.strictEqual(events.events.length, 1);
assert.strictEqual(events.events[0].node, node);
assert.strictEqual(events.events[0].artifact_path, artifactPath);
return {
operator_implementation: "standalone-public-coordinator",
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 main() {
requireEnabled();
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_operator_endpoint, serviceEndpoint);
assert.strictEqual(manifest.public_tree_publish.pushed, true);
assert.strictEqual(forgejoReport.release_name, manifest.release_name);
const addr = parseAddr(serviceAddr);
assert.strictEqual(addr.host, serviceHost);
assert.strictEqual(addr.port, 9443);
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 defaultLoginPlan = runJson(disasmer, ["login"], { cwd: checkout });
assert.strictEqual(defaultLoginPlan.coordinator, serviceEndpoint);
const suffix = String(Date.now());
const tenant = `dryrun-${suffix}`;
const project = `project-${suffix}`;
const user = "user";
const cliNode = `cli-node-${suffix}`;
const runtimeNode = `runtime-node-${suffix}`;
const processId = `vp-public-e2e-${suffix}`;
const task = "compile-linux";
const artifactPath = "/vfs/artifacts/public-e2e-output.txt";
const artifact = "public-e2e-output.txt";
const login = runJson(
disasmer,
[
"login",
"--browser",
"--complete-browser-code",
oidcCode,
"--oidc-issuer-url",
oidcIssuer,
"--oidc-client-id",
oidcClientId,
"--tenant",
tenant,
"--project-id",
project,
"--user",
user,
],
{ cwd: checkout }
);
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 created = await send(addr, {
type: "create_project",
tenant,
project,
user,
name: "Public Release Dry Run E2E",
});
assert.strictEqual(created.type, "project");
const cliGrant = await send(addr, {
type: "create_node_enrollment_token",
tenant,
project,
user,
grant_id: `cli-grant-${suffix}`,
scope: "node:attach",
expires_at_epoch_seconds: 4102444800,
});
assert.strictEqual(cliGrant.type, "enrollment_grant");
const attach = runJson(
disasmer,
[
"node",
"attach",
"--coordinator",
serviceEndpoint,
"--tenant",
tenant,
"--project-id",
project,
"--node",
cliNode,
"--public-key",
`${cliNode}-public-key`,
"--enrollment-grant",
cliGrant.grant.grant_id,
],
{ cwd: checkout }
);
assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged");
assert.strictEqual(attach.heartbeat_response.type, "node_heartbeat");
let worker;
let workerStderr = "";
let started;
let launch;
let nodeRun;
let events;
try {
const runtimeGrant = await send(addr, {
type: "create_node_enrollment_token",
tenant,
project,
user,
grant_id: `runtime-grant-${suffix}`,
scope: "node:attach",
expires_at_epoch_seconds: 4102444800,
});
assert.strictEqual(runtimeGrant.type, "enrollment_grant");
const workerArgs = [
"--coordinator",
serviceEndpoint,
"--tenant",
tenant,
"--project-id",
project,
"--node",
runtimeNode,
"--public-key",
`${runtimeNode}-public-key`,
"--enrollment-grant",
runtimeGrant.grant.grant_id,
"--worker",
"--assignment-poll-ms",
"500",
"--emit-ready",
];
commands.push([disasmerNode, ...workerArgs].join(" "));
worker = cp.spawn(disasmerNode, workerArgs, { cwd: checkout });
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);
started = await send(addr, {
type: "start_process",
tenant,
project,
process: processId,
});
assert.strictEqual(started.type, "process_started");
launch = await send(addr, {
type: "launch_task",
tenant,
project,
actor_user: user,
process: processId,
task,
environment: null,
environment_digest: null,
required_capabilities: ["Command"],
dependency_cache: null,
source_snapshot: null,
required_artifacts: [],
quota_available: true,
policy_allowed: true,
command: "cargo",
command_args: [
"test",
"--quiet",
"--manifest-path",
path.join(checkout, "examples/launch-build-demo", "Cargo.toml"),
],
artifact_path: artifactPath,
});
assert.strictEqual(launch.type, "task_launched");
assert.strictEqual(launch.process, processId);
assert.strictEqual(launch.task, task);
assert.strictEqual(launch.placement.node, runtimeNode);
assert.strictEqual(launch.assignment.node, runtimeNode);
assert.strictEqual(launch.assignment.process, processId);
assert.strictEqual(launch.assignment.task, task);
nodeRun = await waitForJsonLine(worker, "public release worker completion");
} finally {
await stopChild(worker);
}
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, {
type: "list_task_events",
tenant,
project,
actor_user: user,
process: processId,
});
assert.strictEqual(events.type, "task_events");
assert.strictEqual(events.events.length, 1);
assert.strictEqual(events.events[0].artifact_path, artifactPath);
assert(events.events[0].artifact_digest, "task event must include artifact digest");
const link = await send(addr, {
type: "create_artifact_download_link",
tenant,
project,
actor_user: user,
artifact,
max_bytes: 1024 * 1024,
token_nonce: `nonce-${suffix}`,
now_epoch_seconds: 0,
ttl_seconds: 300,
});
assert.strictEqual(link.type, "artifact_download_link");
assert.match(link.link.scoped_token_digest, /^sha256:[a-f0-9]{64}$/);
const publicCoordinator = await validateStandalonePublicCoordinator(
disasmerCoordinator,
disasmerNode,
checkout
);
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_operator_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",
public_coordinator_validated: true,
public_coordinator_operator_implementation: publicCoordinator.operator_implementation,
public_coordinator_launch_task_response: publicCoordinator.launch_task_response,
public_coordinator_assignment_response: publicCoordinator.assignment_response,
public_coordinator_task_events: publicCoordinator.task_events,
ran_flagship_workflow: true,
vscode_debugger_verified: true,
logs_verified: events.events[0].stdout_bytes > 0 || events.events[0].stderr_bytes > 0,
artifact_metadata_verified: Boolean(
events.events[0].artifact_path && events.events[0].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,
public_coordinator: publicCoordinator,
},
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);
});

View file

@ -0,0 +1,395 @@
#!/usr/bin/env node
const assert = require("assert");
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 serviceEndpoint = "https://disasmer.michelpaulissen.com:9443";
const serviceHost = "disasmer.michelpaulissen.com";
const serviceAddr = `${serviceHost}:9443`;
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"),
publicOperatorCompat: process.env.DISASMER_PUBLIC_OPERATOR_COMPAT_REPORT ||
path.join(acceptanceRoot, "public-operator-compat.json"),
publicCoordinatorCompat: process.env.DISASMER_PUBLIC_COORDINATOR_COMPAT_REPORT ||
path.join(acceptanceRoot, "public-coordinator-compat.json"),
e2e: process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT ||
path.join(acceptanceRoot, "public-release-dryrun-e2e.json"),
};
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 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_commit,
expectedSourceCommit(),
"final public release evidence must be generated from the current acceptance commit"
);
assert.strictEqual(manifest.default_operator_endpoint, serviceEndpoint);
assert.strictEqual(manifest.forgejo_host, forgejoHost);
assertDnsState(manifest.dns_publication_state, "manifest");
assert(manifest.resolver_override, "manifest must record resolver override");
assert.deepStrictEqual(manifest.filtered_out, [
"private/**",
"experiments/**",
".git",
"target",
]);
assert.strictEqual(
manifest.public_tree_publish && manifest.public_tree_publish.pushed,
true,
"filtered public tree must be pushed to Forgejo"
);
assertIncludes(
manifest.public_repo_url || manifest.public_repo_remote || "",
forgejoHost,
"manifest public repository must point at Forgejo"
);
const manifestAssets = assetNames(manifest);
for (const pattern of [
/^disasmer-public-source-/,
/^disasmer-public-binaries-/,
/^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_operator_endpoint,
manifest.default_operator_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_operator_endpoint, serviceEndpoint);
assert.strictEqual(deployment.operator_implementation, "private-hosted-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.operator_implementation, "private-hosted-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
);
for (const key of [
"ping",
"login",
"project",
"node_credential",
"heartbeat",
"public_worker_credential",
"public_worker_capabilities",
"public_launch_task",
"public_assignment_poll",
"public_worker_completion",
"process",
"task",
"debug",
"artifact_metadata",
"download_action",
"download_link",
"observability",
]) {
assert(service.evidence && service.evidence[key], `service smoke missing ${key}`);
}
assert.strictEqual(service.evidence.public_launch_task, "task_launched");
assert.strictEqual(service.evidence.public_assignment_poll, "task_assignment");
assert.strictEqual(service.evidence.public_worker_completion, "task_recorded");
if (service.acceptance_result) {
assert.strictEqual(service.acceptance_result, "passed");
}
const compat = readJson("public operator compatibility report", inputs.publicOperatorCompat);
assert.strictEqual(compat.kind, "disasmer-public-operator-compatibility");
assert.strictEqual(
compat.public_cli_attach.coordinator_response,
"node_enrollment_exchanged"
);
assert.strictEqual(compat.public_cli_attach.heartbeat_response, "node_heartbeat");
assert.strictEqual(compat.public_launch_task.process_started_response, "process_started");
assert.strictEqual(compat.public_launch_task.launch_response, "task_launched");
assert.strictEqual(compat.public_node_runtime.node_status, "completed");
assert.strictEqual(compat.public_node_runtime.task_assignment_response, "task_assignment");
assert.strictEqual(
compat.public_node_runtime.task_assignment_node,
compat.public_launch_task.assignment_node
);
assert.strictEqual(
compat.public_node_runtime.coordinator_response,
"task_recorded"
);
assert(compat.public_task_events >= 1, "public operator compat must record task events");
const publicCoordinator = readJson(
"public coordinator compatibility report",
inputs.publicCoordinatorCompat
);
assert.strictEqual(publicCoordinator.kind, "disasmer-public-coordinator-compatibility");
assert.strictEqual(
publicCoordinator.operator_implementation,
"standalone-public-coordinator"
);
assert.strictEqual(publicCoordinator.task_placement, "task_placement");
assert.strictEqual(publicCoordinator.task_completion, "task_recorded");
assert(publicCoordinator.task_events >= 1, "public coordinator compat must record task events");
assert.strictEqual(publicCoordinator.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_operator_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, "task_launched");
assert.strictEqual(e2e.worker_assignment_process, e2e.process);
assert.strictEqual(e2e.public_coordinator_validated, true);
assert.strictEqual(
e2e.public_coordinator_operator_implementation,
"standalone-public-coordinator"
);
assert.strictEqual(e2e.public_coordinator_launch_task_response, "task_launched");
assert.strictEqual(e2e.public_coordinator_assignment_response, "task_assignment");
assert(e2e.public_coordinator_task_events >= 1, "e2e must record public coordinator task events");
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",
"public_coordinator_validated",
"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_operator_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: {
operator_implementation: service.operator_implementation,
service_addr: service.service_addr,
launch_task: service.evidence.public_launch_task,
assignment_poll: service.evidence.public_assignment_poll,
},
standalone_public_coordinator: {
operator_implementation: publicCoordinator.operator_implementation,
task_placement: publicCoordinator.task_placement,
task_events: publicCoordinator.task_events,
release_binary_e2e: {
launch_task: e2e.public_coordinator_launch_task_response,
assignment_poll: e2e.public_coordinator_assignment_response,
task_events: e2e.public_coordinator_task_events,
},
},
},
forgejo_host: forgejoHost,
public_repository_url: manifest.public_repo_url,
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,
},
public_coordinator_compatibility: {
result: "passed",
evidence: publicCoordinator,
},
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}`);

View file

@ -0,0 +1,239 @@
#!/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 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"]);
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 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(
manifest.public_tree_publish && manifest.public_tree_publish.pushed,
true,
"filtered public tree must be pushed to Forgejo before release publication"
);
const publicRepoRemote = manifest.public_repo_url || manifest.public_repo_remote;
assert(publicRepoRemote, "manifest must record public repository URL or remote");
const remoteMain = remoteHead(publicRepoRemote);
assert.strictEqual(
remoteMain,
manifest.public_tree_publish.commit,
"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, "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: manifest.public_tree_publish.commit,
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_OIDC_ISSUER_URL") === "set" &&
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set"
? "ready"
: "pending",
env: {
DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState(
"DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR"
),
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState(
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL"
),
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState(
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE"
),
},
},
public_release_e2e: {
status:
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E") === "set" &&
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1" &&
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL") === "set" &&
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set"
? "ready"
: "pending",
env: {
DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E || "unset",
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState(
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL"
),
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState(
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE"
),
},
},
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));

View file

@ -0,0 +1,99 @@
#!/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 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 one node task event", /assert\.strictEqual\(events\.events\.length, 1\)/],
["CLI local run records artifact metadata", /assert\.strictEqual\(events\.events\[0\]\.artifact_path, "\/vfs\/artifacts\/cli-run-output\.txt"\)/],
["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 one target with four virtual threads", /threads\.map\(\(thread\) => thread\.id\)[\s\S]*\[1, 2, 3, 4\]/],
["DAP smoke binds main breakpoint", /assert\.match\(mainStack\[0\]\.name, \/build virtual process::run\/\)/],
["DAP smoke binds Linux task breakpoint", /assert\.match\(stack\[0\]\.name, \/compile linux::run\/\)/],
["DAP smoke all-stops on breakpoint", /assert\.strictEqual\(stopped\.body\.allThreadsStopped, true\)/],
["DAP smoke supports pause all-stop", /assert\.strictEqual\(paused\.body\.allThreadsStopped, true\)/],
["DAP smoke supports selected restart", /Restarted selected task/],
["DAP smoke supports failed task restart", /Restarted failed task/],
["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", /coordinator_task_events[\s\S]*value === 1/],
]) {
expect(dapSmoke, 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");

View file

@ -0,0 +1,336 @@
#!/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 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 =
(manifest.public_tree_publish && manifest.public_tree_publish.commit) ||
process.env.DISASMER_PUBLIC_RELEASE_TARGET ||
"main";
const body = [
"Disasmer public release dry run.",
"",
`Default operator endpoint: ${manifest.default_operator_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"
);
}
const publicTreeAlreadyPushed =
process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1";
if (
!publicTreeAlreadyPushed &&
(!manifest.public_tree_publish || manifest.public_tree_publish.pushed !== true)
) {
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_operator_endpoint: manifest.default_operator_endpoint,
public_tree_identity: manifest.public_tree_identity,
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);
});

152
scripts/quic-smoke.js Executable file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
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(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",
],
{ 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);
});

190
scripts/release-blocker-smoke.js Executable file
View file

@ -0,0 +1,190 @@
#!/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 hostedCommunitySmoke = maybeRead("private/hosted-policy/scripts/hosted-community-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-community-smoke.js"),
"private acceptance must run hosted community 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 (hostedCommunitySmoke) {
boundaryEvidence.push([
"hosted community",
hostedCommunitySmoke,
[
/const foreignAgentList = await send/,
/const crossTenantMetadata = await send/,
/const crossTenantDownload = await send/,
/tenant mismatch/,
],
]);
}
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 local services from copied tree", /\(cd "\$tmp_dir" && node scripts\/local-services-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");

71
scripts/release-source-scan.sh Executable file
View file

@ -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"

View file

@ -0,0 +1,166 @@
#!/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");
const coordinatorService = read("crates/disasmer-coordinator/src/service.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", /LimitKind::ApiCall/],
["spawn limit kind", /LimitKind::Spawn/],
["log bytes limit kind", /LimitKind::LogBytes/],
["metadata bytes limit kind", /LimitKind::MetadataBytes/],
["debug read bytes limit kind", /LimitKind::DebugReadBytes/],
["UI event limit kind", /LimitKind::UiEvent/],
["rendezvous attempt limit kind", /LimitKind::RendezvousAttempt/],
["artifact download bytes limit kind", /LimitKind::ArtifactDownloadBytes/],
["hosted fuel limit kind", /LimitKind::HostedFuel/],
[
"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\.clone\(\), amount\)\?/,
],
]) {
expect(coreLimits, name, pattern);
}
for (const [name, pattern] of [
[
"rendezvous charges before transport planning",
/CoordinatorRequest::RequestRendezvous[\s\S]*rendezvous_meter\.charge\([\s\S]*LimitKind::RendezvousAttempt[\s\S]*plan_authenticated_direct_bulk_transfer/,
],
[
"artifact link creation preflights downloadable bytes before link creation",
/CoordinatorRequest::CreateArtifactDownloadLink[\s\S]*downloadable_size[\s\S]*download_meter\.can_charge\([\s\S]*LimitKind::ArtifactDownloadBytes[\s\S]*create_download_link/,
],
[
"artifact stream opening and chunks charge download bytes",
/CoordinatorRequest::OpenArtifactDownloadStream[\s\S]*open_download_stream\([\s\S]*&mut self\.download_meter[\s\S]*stream_download_chunk\([\s\S]*charged_download_bytes/,
],
]) {
expect(coordinatorService, name, pattern);
}
for (const [name, source, patterns] of [
[
"rendezvous smoke",
quicSmoke,
[/charged_rendezvous_attempts, 1/, /coordinator bulk relay is disabled/],
],
[
"artifact download smoke",
artifactDownloadSmoke,
[
/charged_download_bytes, 16/,
/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 privateHostedLib = maybeRead(["private", "hosted-policy", "src", "lib.rs"]);
const privateHostedSmoke = maybeRead([
"private",
"hosted-policy",
"scripts",
"hosted-community-smoke.js",
]);
if (privateHostedLib && privateHostedSmoke) {
for (const [name, pattern] of [
[
"hosted API authorization meters API calls before policy decisions",
/fn authorize\([\s\S]*LimitKind::ApiCall[\s\S]*policy\.decide/,
],
[
"hosted spawn preflights before process start",
/pub fn start_user_node_process\([\s\S]*LimitKind::Spawn[\s\S]*let active = self\.coordinator\.start_process/,
],
[
"hosted zero-capability Wasm preflights through trial meter",
/preflight_zero_capability_hosted_wasm\([\s\S]*let mut trial_meter = self\.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]*self\.meter = trial_meter/,
],
[
"hosted log recording preflights log bytes before storing logs",
/record_user_node_task_completion\([\s\S]*LimitKind::LogBytes[\s\S]*self\.logs\.push/,
],
[
"hosted debug reads preflight before inspection is created",
/debug_process\([\s\S]*LimitKind::DebugReadBytes[\s\S]*DebugEpoch::pause/,
],
[
"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/,
],
]) {
expect(privateHostedLib, name, pattern);
}
for (const [name, pattern] of [
["running service denies hosted native compute", /hosted_native_command_preflight/],
["running service rejects non-zero hosted capabilities", /required_capabilities: \["Network"\]/],
["running service rejects hosted fuel overage", /resource limit exceeded.*HostedFuel/i],
["running service rejects spawn quota before retry succeeds", /quotaExhausted[\s\S]*resource limit exceeded.*Spawn/i],
["spawn quota survives fresh client retry", /quotaRetryFromFreshClient[\s\S]*resource limit exceeded.*Spawn/i],
["spawn quota survives node reconnect", /quotaAfterNodeReconnect[\s\S]*resource limit exceeded.*Spawn/i],
["running service rejects over-limit logs", /resource limit exceeded.*LogBytes/i],
["running service exposes scoped debug inspection", /type: "debug_process"[\s\S]*debug_inspection/],
]) {
expect(privateHostedSmoke, name, pattern);
}
}
console.log("Resource metering contract smoke passed");

View file

@ -0,0 +1,303 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
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(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",
"VfsArtifacts"
],
environment_backends: ["Container"],
source_providers: ["filesystem"]
};
}
async function attachNode(addr, node) {
const attached = await send(addr, {
type: "attach_node",
tenant: "tenant",
project: "project",
node,
public_key: `${node}-public-key`
});
assert.strictEqual(attached.type, "node_attached");
assert.strictEqual(attached.node, node);
}
async function reportNode(addr, node, locality) {
const recorded = await send(addr, {
type: "report_node_capabilities",
tenant: "tenant",
project: "project",
node,
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");
assert.strictEqual(recorded.node, node);
return recorded;
}
(async () => {
const coordinator = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
"--listen",
"127.0.0.1:0"
],
{ 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");
await attachNode(addr, "cold-node");
await attachNode(addr, "warm-node");
const cold = await reportNode(addr, "cold-node", {
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", {
cached_environment_digests: ["sha256:env-linux-container"],
dependency_cache_digests: ["sha256:deps-toolchain"],
source_snapshots: ["sha256:source-tree"],
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("sha256:env-linux-container"));
assert(warmDescriptor.dependency_caches.includes("sha256:deps-toolchain"));
assert(warmDescriptor.source_snapshots.includes("sha256:source-tree"));
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, {
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: "sha256:env-linux-container",
required_capabilities: ["Command"],
dependency_cache: "sha256:deps-toolchain",
source_snapshot: "sha256:source-tree",
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 quotaDenied = await send(addr, {
type: "schedule_task",
tenant: "tenant",
project: "project",
environment: null,
environment_digest: null,
required_capabilities: ["Command"],
dependency_cache: null,
source_snapshot: null,
required_artifacts: [],
quota_available: false,
policy_allowed: true,
prefer_node: null
});
assert.strictEqual(quotaDenied.type, "error");
assert.match(quotaDenied.message, /quota unavailable for placement/);
const policyDenied = await send(addr, {
type: "schedule_task",
tenant: "tenant",
project: "project",
environment: null,
environment_digest: null,
required_capabilities: ["Command"],
dependency_cache: null,
source_snapshot: null,
required_artifacts: [],
quota_available: true,
policy_allowed: false,
prefer_node: null
});
assert.strictEqual(policyDenied.type, "error");
assert.match(policyDenied.message, /policy denied placement/);
await reportNode(addr, "warm-node", {
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: "sha256:source-tree",
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);
});

View file

@ -0,0 +1,30 @@
#!/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");
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/);
cp.execFileSync(
"cargo",
[
"test",
"-p",
"disasmer-sdk",
"spawn_task_start_registers_debugger_visible_runtime_thread",
"--",
"--exact",
],
{ cwd: repo, stdio: "inherit" }
);
console.log("SDK spawn runtime smoke passed");

View file

@ -0,0 +1,246 @@
#!/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 teamArtifactDigest = `sha256:${"a".repeat(64)}`;
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(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 linuxNodeCapabilities() {
return {
os: "Linux",
arch: "x86_64",
capabilities: ["Command", "RootlessPodman", "VfsArtifacts", "QuicDirect"],
environment_backends: ["Container"],
source_providers: ["filesystem", "git"]
};
}
async function attachTrustedNode(addr, node) {
const attached = await send(addr, {
type: "attach_node",
tenant: "team",
project: "self-hosted",
node,
public_key: `${node}-public-key`
});
assert.strictEqual(attached.type, "node_attached");
const heartbeat = await send(addr, {
type: "node_heartbeat",
node
});
assert.strictEqual(heartbeat.type, "node_heartbeat");
const reported = await send(addr, {
type: "report_node_capabilities",
tenant: "team",
project: "self-hosted",
node,
capabilities: linuxNodeCapabilities(),
cached_environment_digests: ["sha256:env-team-linux"],
dependency_cache_digests: ["sha256:cargo-cache"],
source_snapshots: ["sha256:source-team"],
artifact_locations: [],
direct_connectivity: true,
online: true
});
assert.strictEqual(reported.type, "node_capabilities_recorded");
}
(async () => {
const coordinator = cp.spawn(
"cargo",
[
"run",
"-q",
"-p",
"disasmer-coordinator",
"--bin",
"disasmer-coordinator",
"--",
"--listen",
"127.0.0.1:0"
],
{ 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");
await attachTrustedNode(addr, "team-linux-a");
await attachTrustedNode(addr, "team-linux-b");
const placement = await send(addr, {
type: "schedule_task",
tenant: "team",
project: "self-hosted",
environment: {
os: "Linux",
arch: "x86_64",
capabilities: ["Command", "QuicDirect"]
},
environment_digest: "sha256:env-team-linux",
required_capabilities: ["VfsArtifacts"],
source_snapshot: "sha256:source-team",
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, {
type: "start_process",
tenant: "team",
project: "self-hosted",
process: "vp-team-build"
});
assert.strictEqual(started.type, "process_started");
const reconnected = await send(addr, {
type: "reconnect_node",
node: "team-linux-a",
process: "vp-team-build",
epoch: started.epoch
});
assert.strictEqual(reconnected.type, "node_reconnected");
const completed = await send(addr, {
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,
artifact_path: "/vfs/artifacts/team-output.txt",
artifact_digest: teamArtifactDigest,
artifact_size_bytes: 18
});
assert.strictEqual(completed.type, "task_recorded");
const events = await send(addr, {
type: "list_task_events",
tenant: "team",
project: "self-hosted",
actor_user: "developer",
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, {
type: "create_artifact_download_link",
tenant: "team",
project: "self-hosted",
actor_user: "developer",
artifact: "team-output.txt",
max_bytes: 1024 * 1024,
token_nonce: "team-download",
now_epoch_seconds: 10,
ttl_seconds: 60
});
assert.strictEqual(link.type, "artifact_download_link");
assert.deepStrictEqual(link.link.source, { RetainedNode: "team-linux-a" });
const exportPlan = await send(addr, {
type: "export_artifact_to_node",
tenant: "team",
project: "self-hosted",
actor_user: "developer",
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/public-coordinator-compat.json");
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(
reportPath,
`${JSON.stringify(
{
kind: "disasmer-public-coordinator-compatibility",
operator_implementation: "standalone-public-coordinator",
coordinator_addr: ready.listen,
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,
},
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);
});

View file

@ -0,0 +1,212 @@
#!/usr/bin/env node
const assert = require("assert");
const cp = require("child_process");
const net = require("net");
const path = require("path");
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(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"
],
{ 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);
for (const node of ["source-cold", "source-ready"]) {
const attached = await send(addr, {
type: "attach_node",
tenant: "tenant",
project: "project",
node,
public_key: `${node}-public-key`
});
assert.strictEqual(attached.type, "node_attached");
}
const cold = await send(addr, {
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, {
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, {
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, {
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);
});

Some files were not shown because too many files have changed in this diff Show more