diff --git a/README.md b/README.md index 3412a8b..5c64621 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,72 @@ session-scoped ID. The relay still observes normal network and HTTP tunnel traffic. These statements are pinned to that v0.5.1 source commit and must not be generalized to other releases. +#### Windows Temporary Layered Entry + +For Windows amd64 attended sessions, a trusted Agent can verify the signed +`rdev-bootstrap.exe` release artifact and materialize a private layered handoff. +The bootstrap fetches an HTTPS `layered-assets.json`, verifies its Ed25519 +release-root signature, selects only the Windows core `rdev-host` runtime, and +uses the shared resumable content-addressed downloader. The controller pins the +expected release version and `windows/amd64` in signed bootstrap metadata; the +bootstrap requires the downloaded manifest to match that version before it +requests the runtime. Ticket and gateway values remain in the separately +signed join manifest. + +The first run downloads and SHA-256 verifies the core runtime. A warm run +revalidates and reuses the digest cache without fetching the runtime again. +The user-scoped cache is rooted below +`%LOCALAPPDATA%\RemoteDevSkillkit\cache`; managed directories and files use +private `0700`/`0600` permissions. On Windows, the primary visible launcher is +`Start-ConnectionEntry.ps1`. The visible `Start-ConnectionEntry.cmd` broker +tries that launcher under the current policy, retries with a process-scoped +`-ExecutionPolicy Bypass`, and then uses its native CMD path. The retry changes +no registry or machine policy. All paths share one private attempt directory; +once its state reaches `core_started`, no launcher starts another core. Both +launchers restrict handoff, attempt, and cache ACLs to the current user, +SYSTEM, and Administrators; reject reparse or UNC paths; recheck bootstrap size +and SHA-256; and keep the verified bootstrap in the foreground. + +Temporary mode runs the selected host in the foreground. It does not install a +service, create a scheduled task or registry persistence, or start a background +updater. If layered prerequisites were not published, Connection Entry retains +the visible `Start-ConnectionEntry.ps1` archive-compatible fallback. Signature, +digest, origin, path, or verification failures never execute the downloaded +asset or automatically downgrade. The launcher prints the separately verified +self-contained archive fallback as an explicit recovery command instead. + +The layered run report contains `from_cache`, `resumed`, `bytes`, and ordered +stage durations for manifest fetch, signature verification, runtime resolution, +and launch preparation. `bytes` is the verified runtime size, not measured +network transfer volume. No performance target is claimed until real Windows +cold and warm acceptance evidence is collected. + +For acceptance, save the first standalone JSON report emitted by each run as +`cold-layered-run.json` and `warm-layered-run.json`; do not include subsequent +foreground host output in either file. Package both reports with the other real +Windows evidence: + +```bash +rdev acceptance package-windows-temporary \ + --plan windows-plan/windows-temporary-plan.json \ + --out windows-evidence \ + --transcript transcript.txt \ + --release-verification rdev-verify.json \ + --audit audit.jsonl \ + --no-persistence-dir no-persistence \ + --denial-probes-dir denial-probes \ + --cold-layered-run cold-layered-run.json \ + --warm-layered-run warm-layered-run.json +``` + +The packager requires a cache miss in the cold report, a cache hit in the warm +report, the complete ordered verification stages, and nonnegative durations. +Reports containing ticket, gateway, token, redaction-marker, or private-path +content are rejected before copying. Synthetic fixture reports are +non-production and do not satisfy the formal release gate or replace a real +Windows run. Keep the complete session-bound Windows evidence package private; +review any report separately before publication. + ### 2. Run Scoped Work The agent creates signed tasks for specific capabilities: shell, PowerShell, diff --git a/TASKS.md b/TASKS.md index 470ad1f..7fd9a27 100644 --- a/TASKS.md +++ b/TASKS.md @@ -138,19 +138,19 @@ - [x] Harden Connection Entry helper dependency repair so `RDEV_*_INSTALL_ACTION_JSON` can only execute the standard schema-checked `rdev deps install --execute` contract with matching tool, scope, URL, and SHA-256 instead of arbitrary Agent-authored install commands. - [x] Add `rdev.support-session-fresh-agent-connect-contract.v1` so high-level support-session payloads tell newly installed Agents how to recover missing `rdev`, send the one human handoff, wait/report connection status, and avoid model-authored setup scripts. - [x] Add `rdev.support-session-target-handoff-envelope.v1` so high-level created/connect/started support-session payloads expose one ready-to-forward `full_text` message, keeping fresh Agents from reconstructing the target-side human response from separate fields. -- [x] Add fresh-Agent `rdev support-session prepare` / `rdev.support_session.prepare` so Agents inspect local `rdev` recovery, helper assets, one-command target readiness, and adaptive connectivity strategy before improvising setup. +- [x] Add fresh-Agent `rdev support-session prepare` / `rdev.support_session.prepare` so Agents inspect local bootstrap recovery, bootstrap assets, one-command target readiness, and adaptive connectivity strategy before improvising setup. - [x] Add high-level fresh-Agent `rdev support-session connect` / `rdev.sessions.connect` so Agents start with one "connect a computer" entry that either returns a ready human handoff or the standard visible foreground `connect --start` command. -- [x] Add preferred `rdev support-session connect --start` plus `cli_start_now_command` in connect/handoff payloads so fresh Agents can start the visible local gateway, build helper assets, write the ready file, and send one target command without hand-running gateway/invite/package scripts. +- [x] Add preferred `rdev support-session connect --start` plus `cli_start_now_command` in connect/handoff payloads so fresh Agents can start the visible local gateway, build bootstrap assets, write the ready file, and send one target command without hand-running gateway/invite/package scripts. - [x] Add `rdev.support-session-foreground-feedback.v1` and machine-readable `rdev.support-session-foreground-event.v1` stderr events so a foreground `connect --start` command can report `connected` without requiring the Agent to invent a polling loop. - [x] Add fresh-Agent `rdev support-session handoff` / `rdev.support_session.handoff` so Agents get one first-contact routing decision before choosing create vs foreground start, reducing model-dependent use of prepare/plan or ad hoc bootstrap code. -- [x] Make `rdev support-session start` auto-prepare verified Windows/macOS/Linux helper assets when a checkout and Go are available, and include asset/readiness/connectivity reports in `rdev.support-session-started.v1`. -- [x] Add standardized support-session planning through `rdev support-session plan` and MCP tool `rdev.support_session.plan` so Agents get gateway startup, verified helper assets, invite creation, localized target commands, and scoped attended-temporary auto-authorization without writing ad hoc bootstrap or authorization code. +- [x] Make `rdev support-session start` auto-prepare verified Windows/macOS/Linux bootstrap assets when a checkout and Go are available, and include asset/readiness/connectivity reports in `rdev.support-session-started.v1`. +- [x] Add standardized support-session planning through `rdev support-session plan` and MCP tool `rdev.support_session.plan` so Agents get gateway startup, verified bootstrap assets, invite creation, localized target commands, and scoped attended-temporary auto-authorization without writing ad hoc bootstrap or authorization code. - [x] Add standardized support-session creation through `rdev support-session create` and MCP tool `rdev.support_session.create` so Agents with a reachable gateway get a ready target command, join URL, real ticket code, manifest root, and status watcher without manual invite parsing or placeholder substitution. - [x] Add foreground support-session startup through `rdev support-session start` so Agents without a running gateway can start the local gateway, create the session, print the ready target command, and keep serving without ad hoc background process management. - [x] Add `rdev.support-session-ready-file.v1` and default `support-session-ready.json` output for `rdev support-session start` so Agents can read `ready_file.path` when foreground stdout is hard to parse. - [x] Add `rdev acceptance fresh-agent-support-session` as a local contract gate for the handoff/create/start/status flow so regressions in one-command fresh-Agent behavior fail before real multi-harness acceptance. - [x] Add standardized support-session status feedback through `rdev support-session status`, `GET /v1/support-session/status`, and MCP tool `rdev.support_session.status` so Agents can watch the target host and proactively report when `connected=true`. -- [x] Add target-side bootstrap self-repair for missing `rdev` through configured gateway `/assets` helper downloads with SHA-256 verification for Windows/macOS/Linux. +- [x] Add target-side bootstrap acquisition through configured gateway `/assets` endpoints with SHA-256 verification for Windows/macOS/Linux. - [x] Add first-host-only attended-temporary auto-authorization metadata for standard visible Connection Entries, with audit events and regression coverage. - [x] Add support-session `gateway_url_candidates` and default wildcard listen handling so runtime-generated Connection Entry commands carry ordered signed gateway failover metadata, while fresh Agents treat candidates as diagnostic/preflight data and never send `0.0.0.0`, loopback, or hand-picked gateway URLs to remote hosts. - [x] Add ordered gateway-candidate fallback directly into `rdev.support-session-created.v1` target commands so target-side one-liners try known Connection Entry URLs before failing and Agents do not write ad hoc PowerShell/shell fallback scripts. @@ -165,7 +165,7 @@ - [x] Add `rdev.support-session-connection-entry-runner-recommendation.v1` to created/connect/started payloads so Agents can upgrade from a simple support-session handoff to the self-contained adaptive Connection Entry runner using inline invite JSON and standard plan/dry-run commands. - [x] Add `rdev.support-session-agent-runbook.v1` to handoff/prepare/create/start/connect/status/recovery payloads so fresh Agents get one standard connect/wait/report/recover sequence before choosing lower-level tools. - [x] Tighten fresh-Agent runbook routing so ordinary "connect this computer" requests start with `rdev.sessions.connect` / `rdev support-session connect`, and low-level invite/package planning is reserved for explicit package materialization, authorized managed owned-host planning, or high-level recovery instructions. -- [x] Add `rdev.support-session-fresh-agent-failure-prevention.v1` inside the Agent runbook so real failure modes like manual gateway/invite/bootstrap glue, missing helper assets that produce `rdev is required`, background gateway workarounds, custom authorization polling, and Agent-written PowerShell/shell setup are captured as machine-readable forbidden paths with standard recovery routes. +- [x] Add `rdev.support-session-fresh-agent-failure-prevention.v1` inside the Agent runbook so failure modes like manual gateway/invite/bootstrap glue, missing bootstrap assets or signed layered metadata, background gateway workarounds, custom authorization polling, and Agent-written PowerShell/shell setup are captured as machine-readable forbidden paths with standard recovery routes. - [x] Put `rdev.sessions.connect` first in the public MCP tool contract so fresh Agents discover the high-level connection entry before low-level invite/package tools. - [x] Add `rdev.connection-attempt-policy.v1` and bounded target-side timeout/retry behavior so Windows/macOS/Linux one-liners fail over quickly instead of hanging on a dead gateway candidate. - [x] Add MCP wait support to `rdev.support_session.status` so Agents can wait for connection feedback through the standard tool instead of writing custom polling loops. @@ -174,13 +174,13 @@ - [x] Add `user_handoff.auto_target_rule` so Agents handle unknown target OS by sending the join URL first and using returned platform commands only when needed. - [x] Add `rdev.support-session-connection-recovery.v1` to status and wait-timeout payloads so Agents recover failed first contact through standard `rdev` tools instead of writing custom PowerShell, shell, relay, authorization-polling, or bootstrap code. - [x] Add `rdev.support-session-connected-next-steps.v1` to status payloads so Agents proactively report established connections, inspect session endpoint capabilities, and create only the smallest scoped task after `connected=true`. -- [x] Add `rdev.support-session-target-bootstrap-requirements.v1` and CLI `rdev.support-session-target-bootstrap-readiness.v1` probing so Agents detect missing gateway helper assets before sending clean-machine Windows/macOS/Linux commands. +- [x] Add `rdev.support-session-target-bootstrap-requirements.v1` and CLI `rdev.support-session-target-bootstrap-readiness.v1` probing so Agents detect missing gateway bootstrap assets before sending clean-machine Windows/macOS/Linux commands. - [x] Add `rdev gateway serve --rdev-assets-dir` for explicit low-level dev gateways, while keeping ordinary support sessions on `rdev support-session start/create/status`. -- [x] Add default `rdev gateway serve --dev --auto-build-rdev-assets` so accidental low-level dev gateways build and serve verified Windows/macOS/Linux helpers from a valid checkout with Go, reducing clean-target `rdev is required` failures when a fresh Agent chooses `gateway serve` plus `invite create`. -- [x] Add fresh-Agent bootstrap self-repair acceptance coverage so the local gate fetches join page, Windows/macOS/Linux bootstrap scripts, and helper asset hashes, proving clean targets are not told to install `rdev` manually. +- [x] Add default `rdev gateway serve --dev --auto-build-rdev-assets` so development gateways build and serve verified Windows/macOS/Linux bootstrap binaries from a valid checkout with Go. +- [x] Add fresh-Agent bootstrap acquisition acceptance coverage so the local gate fetches join pages, Windows/macOS/Linux bootstrap scripts, and bootstrap asset hashes. - [x] Add configured stable fallback acceptance coverage so the local fresh-Agent gate proves `RDEV_RELAY_GATEWAY_URL` is auto-selected, target commands use relay join URLs, and Agent supervision treats the session as durable after LAN changes. -- [x] Add signed join-manifest gateway candidates so generated support-session bootstraps pass ordered gateway candidates into `rdev host serve`, and the target host selects a reachable signed candidate before registration. -- [x] Add post-registration runtime fallback across signed join-manifest gateway candidates so `rdev host serve --transport auto` can switch to another reachable candidate when the current gateway fails before session tasks are processed. +- [x] Add signed join-manifest gateway candidates so generated support-session entries pass ordered gateway candidates through `rdev-bootstrap`, and the signed core selects a reachable candidate before registration. +- [x] Add post-registration runtime fallback across signed join-manifest gateway candidates so the single core started by `rdev-bootstrap` can switch to another reachable candidate when the current gateway fails before session tasks are processed. - [x] Add foreground `support-session-status.json` output and `rdev.support-session-status-file.v1` metadata so Agents can read the latest machine-readable connection event from `status_file.path` instead of parsing long-running terminal output or writing polling loops. - [x] Add regression coverage that drives the foreground status-file watcher from `waiting` to `connected` after a real in-memory host registration, proving Agents can read connected feedback from `status_file.path`. - [x] Add foreground `target-handoff.txt` and `connected-report.txt` output with `rdev.support-session-handoff-text-file.v1` and `rdev.support-session-connected-report-file.v1` metadata so fresh Agents can forward the target-side command and connection-success report from plain-text files instead of parsing JSON or terminal streams. @@ -190,7 +190,7 @@ - [x] Add acceptance evidence scaffold readiness status with `rdev.acceptance-evidence-status.v1`, `rdev acceptance evidence-status`, and MCP tool `rdev.acceptance.evidence_status` so Agents can fail closed on missing, empty, or placeholder hosted-provider and relay/connectivity evidence before packaging. - [x] Add post-release download evidence scaffolding and readiness status with `rdev.post-release-download-evidence-scaffold.v1`, `rdev acceptance scaffold-post-release-download`, `rdev.post-release-download-evidence-status.v1`, `rdev acceptance post-release-evidence-status`, and MCP tools so Agents collect real GitHub Release download transcripts with standard file names before packaging. - [x] Harden post-release download evidence packaging and verification so platform, Skillkit, and post-release verification placeholder files cannot be archived as release evidence even when the readiness status command is skipped. -- [x] Add `rdev connection-entry run --helper-transcript-out` and update relay/connectivity evidence plans so real helper startup, probe, host-serve, and cleanup transcript evidence is generated by the runner instead of hand-written by Agents. +- [x] Add `rdev connection-entry run --helper-transcript-out` and update relay/connectivity evidence plans so real helper startup, probe, bootstrap, and cleanup transcript evidence is generated by the runner instead of hand-written by Agents. - [x] Add `rdev connection-entry run --evidence-dir` and update relay/connectivity evidence plans so runner-result, helper transcript, gateway status, host status, connection status, audit JSONL, and evidence report files are generated by the runner as one standard acceptance evidence directory. - [x] Add `rdev acceptance package-relay-adapter --evidence-dir` and update relay/connectivity evidence plans so Agents package the runner-generated evidence directory without passing individual runner/status/audit file flags. - [x] Add `rdev acceptance package-hosted-provider-runtime --evidence-dir` and update hosted runtime evidence plans so Agents package the standard hosted evidence directory without passing individual gateway/storage/auth/backup/restore/role/audit file flags. @@ -199,9 +199,9 @@ - [x] Add package-level `rdev acceptance scaffold-evidence --hosted-provider-package` / `--relay-adapter-package` inputs and MCP `hosted_provider_package` / `relay_adapter_package` fields so Agents scaffold real hosted-provider and relay/connectivity evidence without hand-picking internal plan JSON paths. - [x] Update generated hosted-provider and relay-adapter package runbooks plus evidence-plan Agent rules so fresh Agents are explicitly routed to package-level `scaffold-evidence` commands, with package verifier checks preventing regression to internal plan-path handoff. - [x] Add directory-level `rdev acceptance scaffold-post-release-download --post-release-install-dir` and MCP `post_release_install_dir` inputs so Agents scaffold public download evidence without hand-picking post-release plan and verification JSON files. -- [ ] Run real clean-machine Windows Connection Entry acceptance using the standardized support-session plan and verified helper asset path. +- [ ] Run real clean-machine Windows layered Connection Entry acceptance using the signed bootstrap-only handoff. - [ ] Run real fresh-agent Codex/Claude Code/Hermes/OpenClaw/OpenCode acceptance to confirm the Agent calls `rdev.support_session.create` when a gateway is reachable, uses `rdev support-session connect --start` when no gateway exists, reserves `rdev.support_session.plan` for review/debug, and never invents gateway/bootstrap code. -- [ ] Run clean Windows target acceptance using the `connect --start` generated PowerShell handoff to prove `/assets/rdev-windows-amd64.exe` self-repair, SHA-256 verification, auto-activated first host registration, status wait, session close, and no-persistence cleanup. +- [ ] Run Windows 10/11 layered-entry acceptance proving PowerShell-first fallback order, one core/registration, Range resume, warm cache, route reselection, ACL/reparse and Defender-lock behavior, cleanup, and no persistence. - [x] Auto-include manifest root public keys in gateway ticket responses, Agent invite host commands, MCP invite output, and join bootstrap scripts so target users do not copy trust roots from chat. - [x] Allow signed-manifest-verified LAN/private and HTTPS gateway registration while keeping raw `--gateway --ticket-code` registration local-dev only. - [x] Add max-control Agent authority profile for remote-host-assisted discovery and downstream authorized device control. @@ -324,6 +324,7 @@ - [x] Add full production enrollment authority lifecycle beyond the local certificate, dev hosted issuance/renewal primitives, optional dev operator auth token for issuance/renewal/revocation refresh, host-side near-expiry renewal, local renewal, empty/signed revocation baseline, and dev revocation-list distribution primitives: production operator identity and roles, fleet renewal policy, key custody, and emergency drills. - [ ] Add real Windows Service managed-mode execution and acceptance proof beyond dry-run plans. - [ ] Publish signed per-platform Connection Entry release archives to GitHub Releases and verify real downloads with post-release install transcripts packaged by `rdev acceptance package-post-release-download`. +- [ ] Collect real Windows temporary layered cold/warm measurements: bytes downloaded, time to host registration, reconnect time, cache hit rate, helper isolation, and rollback result. - [ ] Add real restrictive-network acceptance evidence for frp, Chisel, headscale/Tailscale-compatible mesh, WireGuard, and SSH tunnel paths across Windows/macOS/Linux using the standard connectivity adapter packages and `rdev acceptance package-relay-adapter`. - [x] Add systemd user-unit mode. - [x] Add Linux managed-service acceptance plan verification for systemd user-unit evidence. diff --git a/cmd/rdev-bootstrap/main.go b/cmd/rdev-bootstrap/main.go index e7449f9..1865186 100644 --- a/cmd/rdev-bootstrap/main.go +++ b/cmd/rdev-bootstrap/main.go @@ -1,3 +1,5 @@ +//go:build !windows + package main import ( @@ -9,7 +11,7 @@ import ( ) func main() { - app := bootstrapcmd.App{Stdout: os.Stdout, Stderr: os.Stderr} + app := bootstrapcmd.App{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} if err := app.Run(context.Background(), os.Args[1:]); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/cmd/rdev-bootstrap/main_test.go b/cmd/rdev-bootstrap/main_test.go index d684ebb..0aa8d05 100644 --- a/cmd/rdev-bootstrap/main_test.go +++ b/cmd/rdev-bootstrap/main_test.go @@ -26,4 +26,7 @@ func TestRdevBootstrapEntrypointAvoidsFullRuntimeImports(t *testing.T) { if !strings.Contains(source, "internal/bootstrapcmd") { t.Fatalf("rdev-bootstrap entrypoint should delegate to internal/bootstrapcmd:\n%s", source) } + if !strings.Contains(source, "Stdin: os.Stdin") { + t.Fatalf("rdev-bootstrap entrypoint should pass inherited stdin to foreground commands:\n%s", source) + } } diff --git a/cmd/rdev-bootstrap/main_windows.go b/cmd/rdev-bootstrap/main_windows.go new file mode 100644 index 0000000..af9b261 --- /dev/null +++ b/cmd/rdev-bootstrap/main_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package main + +import ( + "context" + "errors" + "os" + "syscall" + + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd/windowsentry" +) + +func main() { + app := windowsentry.App{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} + if err := app.Run(context.Background(), os.Args[1:]); err != nil { + _, _ = os.Stderr.WriteString("rdev-bootstrap layered-run failed\n") + if errors.Is(err, syscall.ERROR_ALREADY_EXISTS) { + os.Exit(2) + } + os.Exit(1) + } +} diff --git a/docs/operations/ACCEPTANCE.md b/docs/operations/ACCEPTANCE.md index cdb7574..e72e921 100644 --- a/docs/operations/ACCEPTANCE.md +++ b/docs/operations/ACCEPTANCE.md @@ -208,7 +208,7 @@ one-message flow: 11. report `connected=true` through `connected_next_steps.user_report`; 12. fetch a local join page, Windows `bootstrap.ps1`, macOS/Linux `bootstrap.sh`, and `/assets/*.sha256` endpoints from a `httptest` gateway - to prove clean targets can download and SHA-256 verify helper binaries + to prove clean targets can download and SHA-256 verify bootstrap binaries instead of being told to install `rdev` manually; 13. configure `RDEV_RELAY_GATEWAY_URL` during the local gate and prove the high-level handoff auto-selects that stable gateway, the target command uses @@ -217,8 +217,8 @@ one-message flow: asking the Agent to write relay/mesh/VPN/SSH glue; 14. include signed runtime gateway candidates in the generated target bootstrap URL, so the fetched join manifest can carry ordered gateway candidates to - `rdev host serve` and the target host can select a reachable signed - candidate before registration; + `rdev-bootstrap`; the verified core can select a reachable signed candidate + before registration and later switch routes without registering again; 15. avoid custom PowerShell, shell, relay, authorization-polling, ticket, root, gateway, transport, or bootstrap glue; 16. include `rdev.support-session-target-handoff-envelope.v1` on created, @@ -226,8 +226,8 @@ one-message flow: human-facing text from separate fields; 17. include `agent_connection_runbook.fresh_agent_failure_prevention`, a machine-readable regression guard for real fresh-Agent failures such as - manual gateway/invite/bootstrap assembly, missing helper assets that produce - `rdev is required`, background gateway workarounds, custom authorization polling, + manual gateway/invite/bootstrap assembly, missing bootstrap assets or signed + layered metadata, background gateway workarounds, custom authorization polling, and Agent-written PowerShell/shell setup. The command writes `report.json` with schema @@ -266,10 +266,10 @@ rdev acceptance package-relay-adapter \ The package archives the shareable standard evidence files and does not require Agents to pass six individual file flags. The result uses schema `rdev.connection-entry.runner-result.v1` and records the selected path, gateway -override, helper-start status, transport, host-serve argv, probe results, and +override, helper-start status, transport, bootstrap argv, probe results, and manual-action requirements. The helper transcript, status files, and audit JSONL are standard evidence generated by the runner from dependency install, -helper-start, gateway-probe, host-serve, and cleanup events, so Agents should +helper-start, gateway-probe, bootstrap, and cleanup events, so Agents should not hand-write them. If a helper is missing, the runner accepts dependency repair only through @@ -436,35 +436,28 @@ host plan: ```bash rdev acceptance windows-temporary \ --out .rdev/acceptance/windows-temporary \ - --gateway https://api.example.com/v1 \ - --ticket-code ABCD-1234 \ - --download-url https://agent.example.com/rdev-host.exe \ - --expected-sha256 \ - --release-bundle-url https://agent.example.com/release-bundle.json \ - --release-root-public-key release-root:... \ - --verifier-download-url https://agent.example.com/rdev-verify.exe \ - --verifier-sha256 + --handoff-archive ./Windows-ConnectionEntry.zip ``` The command writes `windows-temporary-plan.json` with schema -`rdev.acceptance.windows-temporary-plan.v1` and `run-windows-temporary.ps1`. It +`rdev.acceptance.windows-temporary-plan.v1` and a private copy of the measured +handoff ZIP. It validates: -- local or URL bootstrap script availability; -- bootstrap script SHA-256 availability; -- gateway URL, ticket code, host download URL, and host SHA-256; -- signed release manifest or signed release bundle, release root, verifier - download URL, verifier SHA-256, and bundle required artifacts when bundle mode - is used; +- the closed ZIP SHA-256, hard size limit of 1,048,576 bytes, and bounded full + entry reads (including compressed-entry integrity) before any evidence is written; +- the exact PowerShell, process-scoped PowerShell retry, and native CMD order; +- required `rdev-bootstrap`, signed manifest, checksum, verification, and + explicit recovery-profile entries; +- bootstrap-only launcher content with no direct full-helper startup; - host-denial probes for package install, elevation, service management, GUI control, and credential changes; - no-persistence inspection commands for services, scheduled tasks, Run keys, startup folders, and firewall rules. -This command does not execute PowerShell. It produces the operator-reviewed plan -for a real Windows VM or support-host acceptance run. The generated launcher is -intentionally visible and foreground-only; it does not install a service or -autorun entry. +This command does not execute PowerShell. It produces a non-sensitive summary +plan for a real Windows VM or support-host run; the private ZIP remains outside +the public evidence package. Verify the generated plan before sending or running the launcher: @@ -477,14 +470,10 @@ The verifier emits `rdev.acceptance-verification.windows-temporary-plan.v1` JSON and exits nonzero if any preflight check fails. It validates: - plan schema and generated plan checks; -- launcher existence, private file mode, and parameter agreement with the plan; -- launcher absence of forbidden persistence or policy-weakening operations such - as `Set-ExecutionPolicy`, service creation, scheduled-task registration, - Run-key mutation, firewall-rule creation, or elevation through `runas`; -- local bootstrap script SHA-256 when the script path is available, or a pinned - bootstrap SHA-256 when the launcher downloads the script by URL; -- signed release manifest or signed release bundle, release root, verifier URL, - host SHA-256, verifier SHA-256, and bundle required-artifact inputs; +- private handoff ZIP existence, protected private file ACL, SHA-256, size, bounded + full-entry integrity, and required entries; +- PowerShell-first selection, fallback order, bootstrap-only command, and + non-automatic recovery; - foreground run command, transcript commands, no-persistence checks, host-denial probes, and required evidence checklist. @@ -498,25 +487,30 @@ rdev acceptance package-windows-temporary \ --release-verification rdev-verify.json \ --audit audit.jsonl \ --no-persistence-dir no-persistence \ - --denial-probes-dir denial-probes + --denial-probes-dir denial-probes \ + --cold-layered-run cold-layered-run.json \ + --warm-layered-run warm-layered-run.json \ + --layered-entry-evidence layered-entry-evidence.json ``` The package command emits `rdev.acceptance-package.windows-temporary.v1` JSON, -writes `package.json` and `checksums.txt`, copies the plan and launcher, redacts +writes `package.json` and `checksums.txt`, copies the non-sensitive plan, redacts transcripts and verifier output, and fails closed until all required release evidence is present: -- PowerShell transcript from bootstrap and foreground host startup; +- PowerShell/CMD attempt transcript and exactly one core-start transition; - standalone `rdev-verify` output with `"ok": true`; -- session join, endpoint trust, task, host-denial probe, revoke, and cancellation +- session registration, route reselection, task, revoke, and cancellation audit evidence; +- cold Range-resume and warm-cache layered run reports; - one no-persistence evidence file for services, scheduled tasks, HKCU/HKLM Run keys, startup folders, and firewall rules; - one host-denial probe evidence file for package install, elevation, service management, GUI control, and credential change. -Use the packaged directory as the release-candidate artifact. Do not publish a -Windows temporary acceptance claim from screenshots or raw transcripts alone. +The private handoff ZIP is deliberately excluded from this public evidence +directory. Do not publish a Windows temporary acceptance claim from screenshots +or raw transcripts alone. ## Linux Managed Service diff --git a/docs/operations/AGENT_BOOTSTRAP_PROMPT.md b/docs/operations/AGENT_BOOTSTRAP_PROMPT.md index 8ba331b..d58a107 100644 --- a/docs/operations/AGENT_BOOTSTRAP_PROMPT.md +++ b/docs/operations/AGENT_BOOTSTRAP_PROMPT.md @@ -134,7 +134,7 @@ Steps: missing `rdev`, what not to ask humans for, and which custom PowerShell, shell, tunnel, interrupt, or polling scripts are forbidden. If you are about to improvise setup code, stop and follow that contract instead. If local - `rdev`, gateway state, or target helper assets are unclear, call + `rdev`, gateway state, or target bootstrap assets are unclear, call the CLI-only `rdev support-session prepare` command or run `rdev support-session prepare --build-assets` from the checkout. Follow its `connection_readiness`, `asset_report`, `missing_inputs`, and @@ -187,17 +187,16 @@ Steps: Connection Entry URLs on the target machine with the returned `connection_attempt_policy` timeout/retry behavior; do not write your own PowerShell, shell, relay, or interrupt-polling fallback. Ordinary attended - `/join/.../bootstrap.*` handoffs use `rdev host serve --transport - long-poll` for stable HTTPS-only connectivity. Use `--transport auto` only - for managed or explicit advanced runner paths where WSS fallback has been - validated. Read + `/join/.../bootstrap.*` handoffs verify and invoke `rdev-bootstrap + layered-run`. The single verified core uses `--transport auto` and may + switch among signed routes without another registration. Read `agent_connection_runbook` first; it is the machine-readable order of operations for connecting, waiting, reporting, and recovering without custom scripts. Read `agent_connection_runbook.fresh_agent_failure_prevention` before writing any setup code: it captures known bad fresh-Agent failure patterns such as - manual `gateway serve` plus `invite create`, missing helper assets that make - Windows say `rdev is required`, background gateway glue, custom interrupt + manual `gateway serve` plus `invite create`, missing bootstrap assets or + signed layered metadata, background gateway glue, custom interrupt polling, and Agent-written PowerShell/shell bootstraps. If you are about to write one of those workarounds, stop and use the returned `cli_start_now_command`, `ready_file.path`, `status_file.path`, @@ -226,14 +225,14 @@ Steps: extract the join URL into a separate first message. Also read `target_bootstrap_requirements` and, for CLI create calls, `target_bootstrap_readiness`. If an existing gateway cannot - serve the verified helper assets for the selected platform, use the + serve the verified bootstrap assets for the selected platform, use the standard `rdev support-session connect --start` or `rdev support-session prepare --build-assets` recovery path instead of - asking the target-side human to install `rdev` manually or writing a custom - downloader. If no suitable + asking the target-side human to install a runtime manually or writing a + custom downloader. If no suitable gateway is running yet, run `rdev support-session connect --start` in a visible foreground terminal. It - prepares verified Windows/macOS/Linux helper assets when a checkout and Go + prepares verified Windows/macOS/Linux bootstrap assets when a checkout and Go are available, starts the local gateway, and prints `rdev.support-session-started.v1` with top-level `target_handoff_envelope`, `user_handoff`, @@ -284,11 +283,11 @@ Steps: code when the plan can provide it. Do not manually combine `rdev gateway serve` plus `rdev invite create` for a normal support session; use `rdev support-session connect --start` so ready/status files, - auto-authorization, and helper assets are created together. If a low-level dev + auto-authorization, and bootstrap assets are created together. If a low-level dev gateway is explicitly required, keep the default `--auto-build-rdev-assets` behavior enabled from a valid checkout with Go, - or configure helper assets with `--rdev-assets-dir` / platform-specific - asset flags before generating human-facing target commands. For lower-level package materialization + or configure bootstrap assets and signed layered release inputs with + `--rdev-assets-dir` before generating human-facing target commands. For lower-level package materialization or when `rdev.sessions.create` is not sufficient, use `CLI-only: rdev invite create` or `rdev invite create` so the Agent receives `connection_entry`, `connection_entry.package_catalog`, diff --git a/docs/operations/BOOTSTRAP.md b/docs/operations/BOOTSTRAP.md index de3be44..c287c3d 100644 --- a/docs/operations/BOOTSTRAP.md +++ b/docs/operations/BOOTSTRAP.md @@ -41,10 +41,10 @@ gateway URLs, transports, release roots, checksums, or helper commands. 1. Operator creates a ticket. 2. Remote user opens the join URL. 3. Join page displays operator, server, mode, capabilities, and expiration. -4. User runs the visible PowerShell bootstrap or a signed self-contained - connection entry package. -5. Bootstrap downloads or unpacks the signed `rdev-host` binary. -6. Bootstrap verifies checksum/signature and the signed release evidence. +4. User runs the visible PowerShell launcher from the signed layered handoff. +5. `rdev-bootstrap` verifies the signed layered manifest and downloads the + minimal core after registration inputs are available. +6. The bootstrap verifies version, checksum, signed size, and release root. 7. Host generates a keypair. 8. Host joins a Control Plane session using the one-time ticket. 9. Gateway records the target endpoint. @@ -53,36 +53,14 @@ gateway URLs, transports, release roots, checksums, or helper commands. During development, `rdev host serve --trust-pin sha256:` can pin the gateway signing public key from `GET /v1/trust`. Production bootstrap should derive this trust pin from the signed join manifest or a pinned trust root, not from unauthenticated chat text. -## Draft Script +## Windows Layered Handoff -The repository includes a visible foreground bootstrap draft: - -```text -scripts/bootstrap/windows-temporary.ps1 -``` - -It accepts: - -- `GatewayUrl` -- `TicketCode` -- `DownloadUrl` -- `ExpectedSha256` -- optional `ManifestUrl` -- optional `ManifestRootPublicKey` -- optional `ReleaseManifestUrl` -- optional `ReleaseBundleUrl` -- optional `ReleaseBundleRequiredArtifacts` -- optional `ReleaseRootPublicKey` -- optional `VerifierDownloadUrl` -- optional `VerifierExpectedSha256` -- optional `TrustPin` -- optional `HostName` - -The script downloads `rdev-host.exe` into a temp directory, verifies SHA-256, and runs: - -```powershell -rdev-host.exe host serve --mode temporary --manifest-url -``` +The controller materializes `Windows-ConnectionEntry.zip` with visible +`Start-ConnectionEntry.ps1` and `Start-ConnectionEntry.cmd` launchers plus the +signed focused bootstrap. PowerShell is preferred; the CMD broker retries with +a process-scoped policy argument and then uses native CMD while sharing one +attempt state. All three paths invoke `rdev-bootstrap layered-run` and start at +most one core. It does not install a Windows Service, write registry persistence, weaken execution policy, or bypass UAC. @@ -170,7 +148,7 @@ For ordinary "connect this computer" requests, Agents should start with through the CLI. That high-level path either returns the ready `target_handoff_envelope.full_text` to forward to the human, or the visible foreground `rdev support-session connect --start` command that creates the -gateway, verified helper assets, ready files, status files, auto-authorization +gateway, verified bootstrap assets, ready files, status files, auto-authorization metadata, and recovery guidance together. Low-level invite creation is CLI-only and reserved for explicit package materialization, @@ -308,37 +286,35 @@ The development gateway serves: /join//bootstrap.ps1 ``` -These helpers use an existing `rdev` when one is available. When `rdev` is -missing and the gateway was started with configured helper assets, they download -the platform helper from `/assets`, verify its SHA-256 through the matching -`.sha256` endpoint, and then run a visible attended host session: +These launchers download the platform `rdev-bootstrap` from `/assets`, verify +its SHA-256 through the matching `.sha256` endpoint, and invoke only: ```bash -rdev host serve --manifest-url --transport long-poll --once=false +rdev-bootstrap layered-run --manifest-url --platform / --transport auto ``` -This is the one-link connection entry path for support and debugging. It is an attended +`rdev-bootstrap` verifies the signed layered manifest, version, asset size, and +SHA-256 before starting exactly one core. The core owns WSS, long-poll, and poll +route changes without another registration or process. This is an attended temporary session: no background service is installed, no persistent execution -policy is changed, and no persistence is created by the temporary bootstrap. -The Windows one-line command does not use `-ExecutionPolicy Bypass`, does not -call `Set-ExecutionPolicy`, and does not change machine/user policy. The -bootstrap script now carries the pinned -`--manifest-root-public-key`, so target-side users do not need to copy trust -roots, ticket codes, gateway URLs, or transport flags from chat. +policy is changed, and no persistence is created. The Windows broker prefers +normal PowerShell, retries PowerShell with a process-scoped policy argument, and +uses native CMD only after both PowerShell paths fail; every launcher shares one +attempt state. Target-side users do not copy trust roots, ticket codes, gateway +URLs, or transport flags from chat. For new target-host connections, agents should prefer the invite's `connection_entry.package_catalog` and the signed join manifest's `package_catalog` before choosing a target package. The connection entry package is the no-prerequisite path when release assets are published: one -platform-specific bundle with `rdev`/`rdev-host`, signed release-bundle -evidence, the join manifest URL, the pinned manifest root, visible stop/revoke -instructions, and the selected attended/managed transport. If package assets are not -published or release inputs are missing, the Agent should use the catalog's -visible `/bootstrap.sh` or `/bootstrap.ps1` fallback rather than asking the -target-side human to assemble raw values. It should prefer the generated -attended bootstrap's HTTPS long-poll path unless an explicit managed/advanced -entry validates WSS/auto fallback. It should probe HTTPS long-poll, short-poll, -LAN/private gateway reachability, and already configured +platform-specific `rdev-bootstrap`, signed release evidence, the join manifest +URL, the pinned roots, visible stop/revoke instructions, and the selected mode. +The signed core remains a post-registration download and is not embedded in the +first handoff. If package assets are not published or release inputs are +missing, the Agent should use the catalog's visible `/bootstrap.sh` or +`/bootstrap.ps1` path rather than asking the target-side human to assemble raw +values. The core selects among WSS, HTTPS long-poll, short-poll, LAN/private +gateway reachability, and already configured proxy/relay/mesh/SSH paths before asking the human for more network details. Elevation is allowed only through normal UAC/sudo/system prompts for a signed session task that needs it, and the authorization must be recorded as evidence. diff --git a/docs/operations/DEV_GATEWAY.md b/docs/operations/DEV_GATEWAY.md index c8b7ee0..c5714db 100644 --- a/docs/operations/DEV_GATEWAY.md +++ b/docs/operations/DEV_GATEWAY.md @@ -11,14 +11,13 @@ polling, long polling, and WSS; WSS reuses the same TLS/mTLS client settings as the HTTPS API. For Connection Entry bootstraps, the dev gateway defaults -`--auto-build-rdev-assets=true`. When no explicit `--rdev-assets-dir` or -platform-specific helper flag is set, and the command is run from a valid -checkout with Go available, it builds the Windows/macOS/Linux `rdev` helpers -and serves them from `/assets`. This keeps accidental low-level -`gateway serve` plus `invite create` flows from giving clean targets a -bootstrap that fails with "rdev is required". Operators can disable the -behavior with `--auto-build-rdev-assets=false` or override it with reviewed -explicit asset paths. +`--auto-build-rdev-assets=true`. When no explicit `--rdev-assets-dir` is set, +and the command is run from a valid checkout with Go available, it builds the +Windows/macOS/Linux `rdev-bootstrap` binaries and serves them from `/assets`. +The join launchers still require a verified signed layered manifest, pinned +release root, and expected version; missing release metadata fails closed. +Operators can disable automatic bootstrap builds with +`--auto-build-rdev-assets=false` or provide reviewed explicit asset paths. ## Operator Auth @@ -1231,25 +1230,20 @@ verifier supports the same bundle check for target-host or bootstrap contexts after the verifier binary itself has been hash-pinned. Generated `connection-entry-release.zip` archives also embed this startup gate: their visible launchers call the packaged `rdev-verify --bundle -release/release-bundle.json --root-public-key ---require-artifacts ` before running packaged `rdev -connection-entry run`. +bin/release-bundle.json --root-public-key +--require-artifacts ` before invoking packaged +`rdev-bootstrap layered-run`. -For Windows bootstrap, publish a tiny verifier binary alongside the host binary: +For Windows acceptance, validate the complete private handoff: -```powershell -.\windows-temporary.ps1 ` - -GatewayUrl https://agent.example.com ` - -TicketCode ABCD-1234 ` - -DownloadUrl https://example/rdev-host.exe ` - -ExpectedSha256 ` - -ReleaseBundleUrl https://example/release-bundle.json ` - -ReleaseRootPublicKey release-root: ` - -VerifierDownloadUrl https://example/rdev-verify.exe ` - -VerifierExpectedSha256 +```bash +rdev acceptance windows-temporary \ + --out .rdev/acceptance/windows-temporary \ + --handoff-archive ./Windows-ConnectionEntry.zip ``` -The script hash-pins `rdev-verify.exe` before using it to verify the signed release evidence. Single-artifact `-ReleaseManifestUrl` remains supported for compatibility. +The acceptance plan records only the archive digest, size, launcher order, and +bootstrap boundary. It excludes the private ZIP from public evidence packages. ## Limitations diff --git a/docs/project/ACCEPTANCE_TESTS.md b/docs/project/ACCEPTANCE_TESTS.md index 5d44372..4791455 100644 --- a/docs/project/ACCEPTANCE_TESTS.md +++ b/docs/project/ACCEPTANCE_TESTS.md @@ -483,8 +483,8 @@ The local test suite currently covers: - managed Mac LaunchAgent acceptance planning and verification through `rdev acceptance managed-mac-service` and `rdev acceptance verify-managed-mac-service`, covering checked plist generation, managed host arguments, release-bundle gate requirements, `rdev host service-control --execute` command plan, service-backed acceptance command plan, evidence verification command plan, forbidden policy-weakening command rejection, and uninstall guidance without auto-starting launchd. - managed Mac LaunchAgent acceptance evidence packaging through `rdev acceptance package-managed-mac-service`, covering verified plan and plist archival, plan-verifier output, redacted review/start/inspect/log/release-gate/audit/reconnect/stop/uninstall transcripts, verified managed Mac report/session evidence archival, interrupt-required proof, checksums, and release-blocking failure when release-gate output is not `ok=true` or the managed Mac report fails verification. - macOS LaunchAgent lifecycle control through `rdev host service-control`, covering dry-run by default, explicit `--execute` for launchctl, plist label checks, and start/inspect/stop action planning. -- Windows temporary acceptance planning and verification through `rdev acceptance windows-temporary` and `rdev acceptance verify-windows-temporary`, covering reviewed PowerShell launcher generation, bootstrap script hash capture, signed release manifest or release bundle verifier requirements, launcher safety checks, interrupt probes for package/elevation/service/GUI/credential operations, no-persistence inspection commands, and required evidence checklist without executing PowerShell. -- Windows temporary acceptance evidence packaging through `rdev acceptance package-windows-temporary`, covering verified plan and launcher archival, redacted transcript and verifier output, audit capture, interrupt-probe evidence coverage, no-persistence evidence coverage, checksums, and release-blocking failure when verifier output is not `ok=true`. +- Windows layered-entry acceptance planning and verification through `rdev acceptance windows-temporary` and `rdev acceptance verify-windows-temporary`, covering the real handoff ZIP digest/size gate, bounded full-entry integrity reads, protected Windows DACL/0600 output, required archive entries, PowerShell-first fallback order, bootstrap-only launchers, interrupt probes, no-persistence commands, and required evidence without executing the launcher. +- Windows layered-entry evidence packaging through `rdev acceptance package-windows-temporary`, covering the non-sensitive plan, protected output files, redacted transcript and verifier output, strict cold/warm layered reports, audit capture, interrupt-probe and no-persistence evidence, checksums, and exclusion of the private handoff ZIP. - release bundle verification through `rdev release create-bundle`, `rdev release verify-bundle`, and standalone `rdev-verify --bundle`, covering signed bundle index verification, per-artifact signed manifest verification, artifact and manifest SHA-256/size checks, required artifact presence, and tamper failure evidence. - shell adapter cooperative cancellation through `ExecuteContext` and hostrunner context propagation, returning `rdev.shell-result.v1` artifacts with `canceled=true` instead of timeout evidence. - PowerShell adapter MVP through `adapter=powershell`, covering `powershell.user` capability enforcement, allowlisted PowerShell executable execution, no `-ExecutionPolicy Bypass`, `rdev.powershell-result.v1` evidence, redaction, workspace-lock release, interrupt-required service-management detection, and context cancellation. @@ -495,7 +495,7 @@ The local test suite currently covers: ## Next Automation Targets - Real managed Mac LaunchAgent acceptance execution: review generated plan, start/inspect/stop with `rdev host service-control --execute`, reconnect after reboot, run locked-worktree Codex, verify evidence, package the run with `rdev acceptance package-managed-mac-service`, and uninstall. -- Windows temporary host acceptance execution: verify the generated plan, run it on a clean Windows VM, verify signed release artifacts, run visible foreground bootstrap, confirm outbound-only host loop, collect no-persistence inspection output, host-denial probes, and revocation transcript. +- Windows layered-entry acceptance execution: verify the delivered ZIP, run the visible PowerShell/CMD paths on Windows 10/11, exercise Range resume, route reselection, ACL/reparse and Defender-lock behavior, then collect cleanup, no-persistence, denial, and revocation evidence. - Release transcript packaging: publish redacted acceptance command transcript, verified report JSON, Windows temporary evidence package when applicable, and session evidence checksums for each release candidate. - Windows Service managed-host acceptance execution: run the verified reviewed `sc.exe` plan on a clean Windows machine, verify service creation/status/start/stop/delete, confirm release-bundle startup gate, reconnect behavior, local stop/uninstall, and absence of temporary-mode persistence leakage. - Restrictive-network relay acceptance execution: run a verified Chisel/frpc, SSH tunnel, headscale/Tailscale-compatible mesh, or WireGuard connectivity adapter package against a deployed or already configured route on clean Windows/macOS/Linux targets, capture the Connection Entry runner evidence directory, then package it with `rdev acceptance package-relay-adapter --evidence-dir` and verify it with `rdev acceptance verify-relay-adapter-package`. diff --git a/docs/superpowers/plans/2026-07-15-windows-layered-connection-entry.md b/docs/superpowers/plans/2026-07-15-windows-layered-connection-entry.md new file mode 100644 index 0000000..9faec48 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-windows-layered-connection-entry.md @@ -0,0 +1,201 @@ +# Windows Layered Connection Entry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Windows temporary Connection Entry download only a signed core host runtime, reuse verified cached bytes, and retain the verified self-contained archive as a fallback. + +**Architecture:** A signed layered asset manifest identifies core runtime and optional components by platform, relative asset path, SHA-256, size, and capability. `rdev-bootstrap` verifies the manifest with the release root, resolves a same-origin HTTPS asset URL, and delegates resumable verified caching to `internal/assetdownload`. The bootstrapper runs the cached core runtime in the foreground; optional helpers are deferred. + +**Tech Stack:** Go standard library, Ed25519, `internal/release`, `internal/assetdownload`, `internal/bootstrapcmd`, `internal/httpapi`, Go tests. + +## Global Constraints + +- Support Windows temporary Connection Entry first; preserve archive fallback for every platform. +- Add no third-party dependency. +- Reuse `assetdownload.Download` for SHA-256 verification, cache hit, range resume, retry, and atomic `.part` promotion. +- Verify the release-root Ed25519 signature before executing a downloaded binary. +- Allow only HTTPS, same-origin relative asset paths; reject absolute URLs, traversal, query strings, fragments, and backslashes. +- Use user-scoped `0700` cache directories and `0600` cache files. Temporary mode must not create a service, scheduled task, or background updater. +- Do not include tickets, gateway URLs, tokens, or sensitive local paths in release assets or public reports. + +## File Structure + +- Create `internal/release/layered_assets.go` and `_test.go` for signed manifest validation and selection. +- Modify `internal/release/candidate.go` and `_test.go` to emit `layered-assets.json` and preserve the existing archive. +- Modify `internal/httpapi/server.go` and `_test.go` to serve only configured Windows host runtime assets. +- Modify `internal/bootstrapcmd/bootstrapcmd.go` and `_test.go`, plus `cmd/rdev-bootstrap/main.go` and `_test.go`, for foreground verified cache bootstrap. +- Modify `internal/cli/cli.go`, `internal/cli/cli_test.go`, `internal/acceptance/windows_temporary_package.go`, `internal/acceptance/windows_temporary_package_test.go`, `README.md`, and `TASKS.md` to prefer the layered handoff and collect measured evidence. + +--- + +### Task 1: Define the Signed Layered Manifest + +**Files:** +- Create: `internal/release/layered_assets.go` +- Test: `internal/release/layered_assets_test.go` + +**Interfaces:** + +```go +const LayeredAssetManifestSchemaVersion = "rdev.layered-assets.v1" + +type LayeredAssetManifest struct { + SchemaVersion string `json:"schema_version"` + Version string `json:"version"` + GeneratedAt time.Time `json:"generated_at"` + SigningKeyID string `json:"signing_key_id"` + Assets []LayeredAsset `json:"assets"` + Signature string `json:"signature"` +} + +type LayeredAsset struct { + ID string `json:"id"` + Platform string `json:"platform"` + Kind string `json:"kind"` + RelativePath string `json:"relative_path"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + Capabilities []string `json:"capabilities,omitempty"` +} + +func SignLayeredAssetManifest(LayeredAssetManifest, signing.Key) (LayeredAssetManifest, error) +func VerifyLayeredAssetManifest(LayeredAssetManifest, model.TrustBundle, time.Time) error +func SelectLayeredAsset(LayeredAssetManifest, platform, kind string, capabilities []string) (LayeredAsset, error) +``` + +- [ ] Write `TestLayeredAssetManifestSignsVerifiesAndSelectsWindowsCore`, using a generated Ed25519 key and `rdev-host-windows-amd64` with kind `core-runtime`. Add table cases rejecting zero size, non-`sha256:` digest, duplicate ID, unknown kind, empty version, absolute path, `..`, backslash, query/fragment, and invalid signature. +- [ ] Run `rtk test go test ./internal/release -run TestLayeredAssetManifest -count=1`; expect failure because the API is absent. +- [ ] Implement canonical unsigned JSON: copy assets, sort by ID, sort capabilities, set `Signature` empty, and sign with `ed25519.Sign`. Permit only `core-runtime` and `optional-helper`; require a nonempty version and a unique matching core runtime. +- [ ] Implement this exact path predicate and call it during both signing and verification: + +```go +func validRelativeAssetPath(value string) bool { + u, err := url.Parse(value) + if err != nil || value == "" || u.IsAbs() || u.RawQuery != "" || u.Fragment != "" { return false } + if strings.Contains(value, `\\`) || strings.HasPrefix(value, "/") { return false } + clean := path.Clean(value) + return clean == value && clean != "." && !strings.HasPrefix(clean, "../") +} +``` + +- [ ] Run `rtk test go test ./internal/release -run 'TestLayeredAssetManifest|Test.*Bundle' -count=1`; expect PASS. +- [ ] Run `rtk proxy gofmt -w internal/release/layered_assets.go internal/release/layered_assets_test.go && rtk proxy go vet ./internal/release && rtk test go test ./internal/release`, then commit `feat: add signed layered asset manifest`. + +### Task 2: Emit and Serve a Windows Core Runtime + +**Files:** +- Modify: `internal/release/candidate.go` +- Test: `internal/release/candidate_test.go` +- Modify: `internal/httpapi/server.go` +- Test: `internal/httpapi/server_test.go` +- Modify: `internal/cli/cli.go` +- Test: `internal/cli/cli_test.go` + +**Interfaces:** + +```go +const layeredAssetManifestPath = "layered-assets.json" + +func WriteLayeredAssetManifest(path string, candidate Candidate, key signing.Key, now time.Time) (CandidateFile, error) +``` + +- [ ] Write `TestPrepareCandidateWritesSignedWindowsLayeredAssets`: build a candidate containing `rdev-host-windows-amd64.exe`, verify `layered-assets.json` with `Candidate.RootPublicKey`, and assert selection returns `assets/rdev-host-windows-amd64.exe` as `core-runtime`. Write HTTP tests for `GET /assets/rdev-host-windows-amd64.exe`, its `.sha256` sibling, unknown names, and `../` traversal. +- [ ] Run `rtk test go test ./internal/release ./internal/httpapi -run 'TestPrepareCandidateWritesSignedWindowsLayeredAssets|Test.*HostWindows.*Asset' -count=1`; expect failure. +- [ ] Add `LayeredAssetManifestPath string` to `Candidate`. After candidate artifacts are staged and before checksums are collected, write signed `layered-assets.json`; add it to `Candidate.Files` and checksums. Classify only exact basename `rdev-host-windows-amd64.exe` as the Windows core runtime. Leave all other artifacts in the existing archive and out of this manifest. +- [ ] Add `RdevHostWindowsAMD64Path string` to `httpapi.Assets` and `gatewayServeOptions`; parse `--rdev-host-windows-amd64` beside existing rdev asset flags. Extend `assetPath` with that exact flat name and use existing path validation and `.sha256` behavior. Do not add wildcard or nested asset paths. +- [ ] Run `rtk test go test ./internal/release ./internal/httpapi ./internal/cli -run 'TestPrepareCandidate|Test.*HostWindows.*Asset|TestGatewayServe' -count=1`; expect PASS. +- [ ] Run `rtk proxy gofmt -w internal/release/candidate.go internal/release/candidate_test.go internal/httpapi/server.go internal/httpapi/server_test.go internal/cli/cli.go internal/cli/cli_test.go && rtk proxy go vet ./internal/release ./internal/httpapi ./internal/cli && rtk test go test ./internal/release ./internal/httpapi ./internal/cli`, then commit `feat: publish layered Windows host assets`. + +### Task 3: Add Foreground Verified Bootstrap Cache + +**Files:** +- Modify: `internal/bootstrapcmd/bootstrapcmd.go` +- Test: `internal/bootstrapcmd/bootstrapcmd_test.go` +- Modify: `cmd/rdev-bootstrap/main.go` +- Test: `cmd/rdev-bootstrap/main_test.go` + +**Interfaces:** + +```go +type LayeredRunOptions struct { + ManifestURL string + Root model.TrustBundle + Platform string + CacheDir string + Mode string + Args []string + Client *http.Client + Now time.Time +} + +type LayeredRunReport struct { + SchemaVersion string `json:"schema_version"` + AssetID string `json:"asset_id"` + FromCache bool `json:"from_cache"` + Resumed bool `json:"resumed"` + Bytes int64 `json:"bytes"` + Stages []LayeredRunStage `json:"stages"` +} + +type LayeredRunStage struct { + Name string `json:"name"` + DurationMS int64 `json:"duration_ms"` +} + +func RunLayeredTemporary(context.Context, LayeredRunOptions) (LayeredRunReport, string, error) +``` + +- [ ] Write `TestRunLayeredTemporaryUsesVerifiedDigestCache` with `httptest.NewTLSServer`: first run downloads a signed core file, second run has `FromCache=true` and does not re-fetch bytes. Add test coverage for interrupted transfer resuming with `Range`, bad signature preventing download/execution, HTTP manifest rejection, and cross-origin redirect rejection. +- [ ] Run `rtk test go test ./internal/bootstrapcmd ./cmd/rdev-bootstrap -run TestRunLayeredTemporary -count=1`; expect failure. +- [ ] Fetch only an HTTPS manifest with redirect checks that preserve origin. Decode it, call `release.VerifyLayeredAssetManifest`, select `windows/amd64` `core-runtime`, and resolve its verified relative path against the manifest URL. +- [ ] Call the existing downloader exactly once per selected core asset: + +```go +result, err := assetdownload.Download(ctx, assetdownload.Options{ + Mirrors: []assetdownload.Mirror{{URL: assetURL}}, + ExpectedSHA256: asset.SHA256, + OutputPath: filepath.Join(cacheDir, "runtime", strings.TrimPrefix(asset.SHA256, "sha256:"), filepath.Base(asset.RelativePath)), + CachePath: filepath.Join(cacheDir, "content", strings.TrimPrefix(asset.SHA256, "sha256:")), +}) +``` + +- [ ] Measure manifest fetch, manifest verification, runtime download/cache resolution, and runtime launch preparation with `time.Since`, and emit them as `LayeredRunStage` values. Return only asset ID, byte count, cache/resume booleans, and these non-sensitive measurements. Return the private verified executable path separately. Do not execute a child process in `internal/bootstrapcmd`. +- [ ] Add `rdev-bootstrap layered-run --manifest-url --root-public-key --platform --cache-dir --mode temporary -- `. Permit only `--mode temporary`; after `RunLayeredTemporary` succeeds, execute the returned path in the foreground with `exec.CommandContext`. Do not create a service, scheduler, registry key, or self-replacement. +- [ ] Run `rtk test go test ./internal/bootstrapcmd ./cmd/rdev-bootstrap -count=1 && rtk proxy go vet ./internal/bootstrapcmd ./cmd/rdev-bootstrap`; expect PASS. Commit `feat: bootstrap verified layered host runtime`. + +### Task 4: Prefer the Layered Windows Handoff, Keep Archive Fallback + +**Files:** +- Modify: `internal/release/candidate.go` +- Test: `internal/release/candidate_test.go` +- Modify: `internal/cli/cli.go` +- Test: `internal/cli/cli_test.go` +- Modify: `README.md` + +- [ ] Write `TestWindowsConnectionEntryPrefersLayeredBootstrapAndRetainsArchiveFallback`. With a valid layered manifest and `rdev-bootstrap.exe`, assert the generated handoff has `rdev-bootstrap.exe layered-run`, `layered-assets.json`, a pinned root key, `--platform windows/amd64`, and a user-scoped cache path. Without layered assets, assert it uses `Start-ConnectionEntry.ps1`. +- [ ] Run `rtk test go test ./internal/release ./internal/cli -run TestWindowsConnectionEntryPrefersLayeredBootstrapAndRetainsArchiveFallback -count=1`; expect failure. +- [ ] Modify the Windows launcher/template generator to prefer the layered command only if both signed layered manifest and bootstrap executable are present. Session ticket and gateway values must still come from the existing signed join manifest, not candidate assets. Preserve the existing archive command as the explicit fallback on missing layered assets or bootstrap failure. +- [ ] Add a README section for cold/warm behavior, temporary-mode no-background-update policy, cache policy, and report fields `from_cache`, `resumed`, `bytes`, and stage durations. Do not claim performance numbers. +- [ ] Run `rtk test go test ./internal/release ./internal/cli -count=1 && rtk test go test ./... && rtk proxy go vet ./...`; expect PASS. Commit `feat: prefer layered Windows connection entry`. + +### Task 5: Require Cold and Warm Bootstrap Evidence in Windows Packages + +**Files:** +- Modify: `internal/acceptance/windows_temporary_package.go` +- Test: `internal/acceptance/windows_temporary_package_test.go` +- Modify: `internal/acceptance/windows_temporary_verify.go` +- Test: `internal/acceptance/windows_temporary_verify_test.go` +- Modify: `internal/cli/cli.go` +- Test: `internal/cli/cli_test.go` +- Modify: `README.md` +- Modify: `TASKS.md` + +- [ ] Write tests requiring `cold-layered-run.json` (`from_cache=false`) and `warm-layered-run.json` (`from_cache=true`) with a signature-verification stage and nonnegative durations. Assert package verification fails for missing reports, reversed cache semantics, missing signature phase, or report text containing ticket/gateway/token patterns. +- [ ] Run `rtk test go test ./internal/acceptance -run 'Test.*Windows.*Layered' -count=1`; expect failure. +- [ ] Add those reports to Windows temporary required evidence. Validate report schema, phase names, cache semantics, durations, and redaction before copying the files and adding their SHA-256 checksums. Fixture reports must remain non-production and cannot satisfy the formal release gate alone. +- [ ] Add a `TASKS.md` unchecked item for real Windows cold/warm measurements: bytes downloaded, time to host registration, reconnect time, cache hit rate, helper isolation, and rollback result. Update README acceptance instructions with the two report names. +- [ ] Run `rtk proxy gofmt -w internal/acceptance/windows_temporary_package.go internal/acceptance/windows_temporary_package_test.go internal/acceptance/windows_temporary_verify.go internal/acceptance/windows_temporary_verify_test.go internal/cli/cli.go internal/cli/cli_test.go && rtk test go test ./internal/acceptance ./internal/cli && rtk test go test ./... && rtk proxy go vet ./...`; expect PASS. Commit `test: require layered bootstrap acceptance evidence`. + +## Deferred Follow-Up + +Do not add binary delta patches, automatic background updates, optional helper download, managed-mode activation/rollback, or macOS/Linux layered path in this plan. Write separate plans for those after this Windows temporary MVP has real acceptance evidence. diff --git a/docs/superpowers/plans/2026-07-16-windows-layered-entry-delivery.md b/docs/superpowers/plans/2026-07-16-windows-layered-entry-delivery.md new file mode 100644 index 0000000..f1de971 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-windows-layered-entry-delivery.md @@ -0,0 +1,346 @@ +# Windows Layered Entry Delivery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver a PowerShell-first Windows layered handoff with native CMD fallback, at-most-one core process per broker attempt, dynamic in-core route switching, and a final compressed size no greater than 1,048,576 bytes. + +**Architecture:** Materialization produces and measures a deterministic private ZIP. A focused Windows bootstrap reuses transport-neutral `assetdownload.Download`, verifies the signed layered manifest and core, records a private attempt transition, and starts the core in the foreground. The visible CMD broker advances through normal PowerShell, process-scoped PowerShell retry, and native CMD only before `core_started`; archive recovery remains an explicit profile of the same bootstrap. Every generated connection path is migrated away from the legacy full-helper downloader so `rdev-bootstrap` is the sole connection bootstrap project-wide. + +**Tech Stack:** Go standard library, Ed25519, `archive/zip`, Windows system `curl.exe`, `internal/release`, `internal/assetdownload`, Go tests, Windows cross-compilation. + +## Global Constraints + +- The final `Windows-ConnectionEntry.zip`, including the executable bootstrap, must be at most 1,048,576 bytes. +- Prefer visible PowerShell; retain visible native CMD fallback. +- A single broker attempt may start the core at most once. +- Automatic path fallback is allowed only before `core_started`; archive recovery is explicit and never automatic. +- `core_started` limits process duplication only; the running core must continue to probe and dynamically switch among eligible verified connection-pool routes when latency or availability degrades. +- Every Connection Entry on every supported platform must execute through `rdev-bootstrap`; generated launchers must not download or execute the old full `rdev` helper or invoke `rdev host serve`/`rdev-host` directly. +- Reuse `internal/assetdownload.Download`; do not duplicate cache, Range retry, SHA-256, size, or atomic-promotion logic in scripts or transports. +- Add no third-party dependency. +- Require HTTPS, same-origin relative asset paths, Ed25519 release-root verification, expected version, SHA-256, signed size, private `0700`/`0600` cache semantics, UNC/reparse rejection, bounded files, and atomic publication. +- Temporary mode creates no service, scheduled task, Run key, firewall rule, hidden process, or background updater. +- Tickets, gateway values, tokens, credentials, and private controller paths must not appear in release assets, size reports, logs, or public evidence. +- Every implementation task follows RED, GREEN, refactor, focused tests, `gofmt`, `go vet`, diff/security review, and one conventional commit. + +--- + +### Task 1: Deterministic Handoff ZIP And Hard Size Gate + +**Files:** +- Create: `internal/connectionentry/windows_layered_archive.go` +- Test: `internal/connectionentry/windows_layered_archive_test.go` +- Modify: `internal/connectionentry/windows_layered.go` +- Modify: `internal/connectionentry/plan.go` +- Test: `internal/connectionentry/windows_layered_test.go` + +**Interfaces:** +- Produces: `const maxWindowsLayeredHandoffBytes int64 = 1 << 20`. +- Produces: `writeWindowsLayeredArchive(path string, generatedAt time.Time, files []windowsLayeredArchiveFile) (windowsLayeredArchiveReport, error)`. +- Produces: `windowsLayeredArchiveReport{Path string, SizeBytes int64, SHA256 string}`. +- ZIP entries use basename-only safe paths, deterministic UTC timestamps, mode `0600`, and `zip.Deflate`. + +- [ ] **Step 1: Write failing archive tests** + + Add tests that create the same file set in different input orders and require byte-identical ZIP output; reject duplicate, absolute, traversal, separator, drive/ADS, Windows reserved-device (including superscript digit aliases), forbidden-character, trailing-dot/space, control-character, and case/normalization-collision names; verify every entry is `0600`; and reject an incompressible archive whose final size is 1,048,577 bytes or larger. Require atomic Windows `CREATE_NEW` with a protected DACL limited to current user, SYSTEM, and Administrators before any content write, with a Windows-only execution test and cross-compile check. Add a materialization test requiring `Windows-ConnectionEntry.zip`, verified private protection, its SHA-256/size checks, and absence of ticket, gateway, token, and source absolute-path values from archive reports, all plan check details, logs, and public metadata. Private runnable launcher content may carry the signed join-manifest URL/root. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/connectionentry -run 'TestWriteWindowsLayeredArchive|TestWindowsLayeredHandoffArchive' -count=1`. + + Expected: FAIL because `writeWindowsLayeredArchive` and the archive output do not exist. + +- [ ] **Step 3: Implement the minimal deterministic archive** + + Sort cloned entries by name, reject names unsafe under Windows basename semantics, create the temporary file with platform-private protection already installed, validate protection before writing, write a ZIP with fixed metadata, close it, hash and measure the closed file, remove it on any failure or oversize result, then atomically rename it into place. Revalidate after rename and truncate/remove any privacy-invalid publication while preserving cleanup errors. Include the six layered handoff files and an archive-recovery instruction that invokes `rdev-bootstrap` with the signed recovery profile. Add archive path, digest, size, and verified-private status to the package plan/checks without exposing private inputs. + +- [ ] **Step 4: Confirm GREEN and verify** + + Run `rtk test go test ./internal/connectionentry -run 'TestWriteWindowsLayeredArchive|TestWindowsLayeredHandoffArchive|TestWindowsConnectionEntryPrefersLayered' -count=1`, `rtk proxy gofmt -w internal/connectionentry/windows_layered_archive.go internal/connectionentry/windows_layered_archive_test.go internal/connectionentry/windows_layered.go internal/connectionentry/plan.go internal/connectionentry/windows_layered_test.go`, and `rtk proxy go vet ./internal/connectionentry`. + + Expected: PASS. + +- [ ] **Step 5: Review and commit** + + Run `rtk git diff --check`, inspect `rtk git diff`, verify no session secret entered the archive report, and commit `feat: enforce Windows handoff size gate`. + +### Task 2: Separate Downloader Policy From HTTP Transport + +**Files:** +- Modify: `internal/assetdownload/downloader.go` +- Create: `internal/assetdownload/transport.go` +- Create: `internal/assetdownload/http_transport.go` +- Test: `internal/assetdownload/downloader_test.go` +- Modify callers in: `internal/bootstrapcmd/bootstrapcmd.go` + +**Interfaces:** +- Produces: `type Transport interface { Fetch(context.Context, TransportRequest) (TransportResponse, error) }`. +- Produces: `TransportRequest{URL string, Offset int64, MaxBytes int64}`. +- Produces: `TransportResponse{StatusCode int, ContentLength int64, Body io.ReadCloser}`. +- Changes: `assetdownload.Options` replaces `Client *http.Client` with required `Transport Transport`. +- Produces: `HTTPTransport{Client *http.Client}` as the standard adapter. + +- [ ] **Step 1: Write failing transport-contract tests** + + Add a recording in-memory transport and require `Download` to request offset zero, resume from the exact `.part` size, restart when an offset request returns status 200, reject status outside 200/206, enforce signed response size before promotion, preserve retries/cancellation, and leave the previous verified output available until replacement succeeds. Retain all existing cache, digest, and transcript assertions. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/assetdownload -run 'TestDownload.*Transport|TestDownload' -count=1`. + + Expected: FAIL because the transport contract is absent. + +- [ ] **Step 3: Refactor without changing downloader ownership** + + Move only request/response mechanics into `HTTPTransport.Fetch`. Keep mirror order, Range offsets, retry/backoff, partial files, byte limits, SHA-256, cache validation, and promotion in `Download`. Require every caller to choose a transport explicitly. Replace remove-then-rename with a platform atomic replacement helper that never deletes a valid destination before the replacement operation succeeds. + +- [ ] **Step 4: Confirm GREEN and verify** + + Run `rtk test go test ./internal/assetdownload ./internal/bootstrapcmd -count=1`, format touched Go files, and run `rtk proxy go vet ./internal/assetdownload ./internal/bootstrapcmd`. + + Expected: PASS with existing behavior and new injected-transport coverage. + +- [ ] **Step 5: Review and commit** + + Run `rtk git diff --check`, inspect retry/atomic behavior and every caller, then commit `refactor: separate asset download transport`. + +### Task 3: Build The Focused Windows Bootstrap Under The Final ZIP Budget + +**Files:** +- Create: `internal/bootstrapcmd/windowsentry/app.go` +- Create: `internal/bootstrapcmd/windowsentry/curl_transport_windows.go` +- Create: `internal/bootstrapcmd/windowsentry/curl_transport_stub.go` +- Test: `internal/bootstrapcmd/windowsentry/app_test.go` +- Test: `internal/bootstrapcmd/windowsentry/curl_transport_windows_test.go` +- Modify: `cmd/rdev-bootstrap/main.go` +- Modify: `scripts/release/build-artifacts.sh` +- Test: `scripts/release_contract_test.go` + +**Interfaces:** +- Produces: focused `rdev-bootstrap layered-run` only in the Windows release build. +- Produces: a command transport that resolves only `%SystemRoot%\System32\curl.exe`, disables redirects, permits HTTPS only, writes response headers/body to bounded private temporary files, and returns `assetdownload.TransportResponse`. +- Build flags: `-trimpath -gcflags=all=-l -ldflags "-s -w -buildid= ..."` for `rdev-bootstrap` only. + +- [ ] **Step 1: Write failing bootstrap and real-size tests** + + Add tests proving the focused app rejects every subcommand except `layered-run`, rejects non-system curl paths and unsafe URLs, verifies a signed manifest before any core request, calls `assetdownload.Download` once for the selected core, and never requests optional helpers. Extend the release contract test to cross-build Windows amd64, materialize the representative final ZIP with the signed bootstrap, and require its closed-file size at or below 1,048,576 bytes. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/bootstrapcmd/windowsentry ./scripts -run 'TestWindowsEntry|TestReleaseWindowsLayeredHandoffSize' -count=1`. + + Expected: FAIL because the focused package and real final-size gate are absent; the current measured ZIP is about 2.88 MiB. + +- [ ] **Step 3: Implement focused linking and command transport** + + Move layered-only code behind the focused app, inject the command transport into `assetdownload.Download`, retain strict manifest decoding and release verification, and remove legacy upgrade/gzip helper behavior from the connection command. The transport may fetch bytes only; it may not own Range state, retry, checksum, cache, or promotion. Tune only the documented compiler flags and remove unreachable first-contact code until the actual signed representative ZIP passes. + +- [ ] **Step 4: Confirm GREEN and verify** + + Run the focused tests, `rtk test go test ./internal/assetdownload ./internal/bootstrapcmd ./cmd/rdev-bootstrap ./scripts -count=1`, cross-build Windows amd64, and inspect the recorded PE and final ZIP byte counts. + + Expected: PASS and final ZIP `size_bytes <= 1048576`. + +- [ ] **Step 5: Review and commit** + + Run `rtk git diff --check`, verify the size report contains no private path or session material, and commit `perf: fit Windows entry handoff under one MiB`. + +### Task 4: Add Private Attempt State And At-Most-One Core Start + +**Files:** +- Create: `internal/bootstrapcmd/windowsentry/attempt.go` +- Test: `internal/bootstrapcmd/windowsentry/attempt_test.go` +- Modify: `internal/bootstrapcmd/windowsentry/app.go` +- Test: `internal/bootstrapcmd/windowsentry/app_test.go` + +**Interfaces:** +- Produces: schema `rdev.windows-layered-attempt.v1`. +- Produces stages `pre_core`, `core_started`, and `core_exited`. +- Adds required `layered-run --attempt-dir PATH --launcher powershell|powershell-bypass|cmd`. + +- [ ] **Step 1: Write failing state tests** + + Require a fresh private non-UNC/non-reparse attempt directory, strict JSON decoding, legal forward-only transitions, atomic `0600` state replacement, rejection of a second `core_started`, and cancellation/exit transition after the child is reaped. Add a concurrent test whose two launchers race and whose command runner count is exactly one. Add a contract assertion that the launched core still receives `--transport auto` and that attempt state contains no selected-route field. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/bootstrapcmd/windowsentry -run 'TestAttempt|TestLayeredRunStartsCoreOnce' -count=1`. + + Expected: FAIL because attempt state is absent. + +- [ ] **Step 3: Implement the minimal state machine** + + Validate every path ancestor before opening state. Hold an exclusive attempt lock through verification and `core_started`; recheck the locked runtime; write `core_started` immediately before foreground execution; write `core_exited` only after wait returns. Return a distinct pre-core exit classification without logging URLs, local paths, or session values. Keep route selection out of this state machine and preserve `--transport auto` for the core's dynamic pool. + +- [ ] **Step 4: Confirm GREEN and verify** + + Run the focused package tests with `-race` on the host platform, format touched files, and run `rtk proxy go vet ./internal/bootstrapcmd/windowsentry`. + + Expected: PASS and command runner count one. + +- [ ] **Step 5: Review and commit** + + Inspect state permissions, transition ordering, cancellation, and redaction, then commit `feat: guard Windows entry core startup`. + +### Task 5: Prefer PowerShell And Fall Back Through Native CMD + +**Files:** +- Modify: `internal/connectionentry/windows_layered.go` +- Test: `internal/connectionentry/windows_layered_test.go` +- Modify: `internal/connectionentry/windows_layered_command_windows_test.go` +- Modify: `README.md` + +**Interfaces:** +- Preferred visible entry: `Start-ConnectionEntry.ps1`. +- Visible broker/fallback: `Start-ConnectionEntry.cmd`. +- CMD modes: default broker and private `--native --attempt-dir PATH`. + +- [ ] **Step 1: Replace old expectations with failing fallback tests** + + Require both launchers in the package plan, PowerShell as the preferred human entry, and broker order: current policy, process-scoped Bypass, native CMD. Require the same attempt directory on all paths, native fallback only for pre-core status, no second core after `core_started`, preserved `--transport auto`, and explicit archive output after layered exhaustion. Windows execution tests use fixture PowerShell/bootstrap commands and assert exactly one core marker for success, policy failure, runtime absence, download failure, and core-exit failure. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/connectionentry -run 'TestWindowsConnectionEntryPrefersPowerShell|TestWindowsLayered.*Fallback' -count=1`. + + Expected: FAIL because current tests and launcher explicitly forbid PowerShell from CMD. + +- [ ] **Step 3: Implement the broker and visible fallback** + + Generate both launchers. Make CMD create and secure a unique attempt directory by atomic `mkdir`, invoke PowerShell in the required order, and enter native mode only after a classified pre-core failure. Keep ACL, UNC/reparse, bootstrap digest/size, foreground execution, and cleanup checks on every path. PowerShell uses the same bootstrap and state contract. Neither launcher automatically executes the bootstrap archive profile. + +- [ ] **Step 4: Confirm GREEN and verify** + + Run host tests, cross-compile the Windows test binary, run available real Windows command tests, format files, and run `rtk proxy go vet ./internal/connectionentry`. + + Expected: PASS; every fixture records at most one core start. + +- [ ] **Step 5: Review and commit** + + Inspect command quoting, delayed expansion, Unicode paths, state cleanup, policy scope, secrets, and persistence strings, then commit `feat: prefer PowerShell with native CMD fallback`. + +### Task 6: Add Dynamic In-Core Route Pool + +**Files:** +- Create: `internal/hostcmd/route_pool.go` +- Test: `internal/hostcmd/route_pool_test.go` +- Modify: `internal/hostcmd/hostcmd.go` +- Test: `internal/hostcmd/hostcmd_test.go` + +**Interfaces:** +- Produces: a bounded route pool over signed gateway candidates and maintained event transports. +- Preserves: one registered session ID, endpoint ID, lease, event cursor, and core process across route changes. +- Selection policy: immediate active-route failure switch; latency-only switch requires a healthy candidate at least 20 percent faster for two consecutive probes; failed routes cool down before re-entry. + +- [ ] **Step 1: Write failing pool and continuity tests** + + Add deterministic fake-clock/probe tests for bounded concurrent initial probes, fastest healthy initial selection, immediate failover, two-probe latency hysteresis, cooldown, recovery, cancellation, and no flapping. Add a session-loop test that injects an active-route failure and requires a new gateway/transport with unchanged session ID, endpoint ID, lease identity, event cursor, and registration-call count of one. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/hostcmd -run 'TestRoutePool|TestRunSessionTasksSwitchesRouteWithoutReregistering' -count=1`. + + Expected: FAIL because current `gatewayCandidateSet` probes sequentially and stores no latency, health history, cooldown, or transport adapter. + +- [ ] **Step 3: Implement the bounded dynamic pool** + + Model safe route candidates from the signed join manifest and supported long-poll/short-poll adapters. Probe with a fixed small worker bound and per-probe timeout, keep health/latency state under one mutex, preserve the event cursor when switching, and feed failures/successes back after trust, event, task, and result requests. Exclude unsupported transports rather than adding an ad hoc protocol implementation. Keep all registration outside the route-switch loop. + +- [ ] **Step 4: Confirm GREEN and verify** + + Run `rtk test go test ./internal/hostcmd -run 'TestRoutePool|TestRunSessionTasks|TestRunSessionTask' -count=1`, `rtk test go test -race ./internal/hostcmd`, format touched files, and run `rtk proxy go vet ./internal/hostcmd`. + + Expected: PASS with one registration and dynamic route changes inside one core. + +- [ ] **Step 5: Review and commit** + + Inspect concurrency bounds, cursor continuity, route trust, hysteresis, cancellation, and redacted events, then commit `feat: add dynamic host route pool`. + +### Task 7: Remove Legacy Full-Helper Connection Paths + +**Files:** +- Modify: `internal/httpapi/server.go` +- Test: `internal/httpapi/server_test.go` +- Modify: `internal/release/candidate.go` +- Test: `internal/release/candidate_test.go` +- Modify: `internal/connectionrunner/runner.go` +- Test: `internal/connectionrunner/runner_test.go` +- Modify: `internal/agentinvite/invite.go` +- Test: `internal/agentinvite/invite_test.go` +- Modify: `internal/bootstrapcmd/bootstrapcmd.go` +- Test: `internal/bootstrapcmd/bootstrapcmd_test.go` +- Modify: `scripts/release/build-artifacts.sh` +- Test: `scripts/release_contract_test.go` + +**Interfaces:** +- Project-wide invariant: generated connection launchers invoke only `rdev-bootstrap` for bootstrap and foreground host startup. +- Release builds emit `rdev-bootstrap` for every supported `GOOS/GOARCH` target. +- Archive recovery is a signed bootstrap profile, not a separate full-helper script. + +- [ ] **Step 1: Write failing project-wide bootstrap-route tests** + + Render Windows PowerShell, Windows CMD, macOS/Linux shell, candidate release, runner, and join-page launchers. Require `rdev-bootstrap` and reject direct `rdev--` helper downloads, `Get-Command rdev`, `command -v rdev`, direct `rdev host serve`, direct `rdev-host`, and `rdev-bootstrap upgrade`. Add a release-contract case requiring bootstrap artifacts for every requested supported target. Keep fixture matches scoped to generated connection surfaces so documentation and historical plans do not create false positives. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/httpapi ./internal/release ./internal/connectionrunner ./internal/agentinvite ./internal/bootstrapcmd ./scripts -run 'Test.*BootstrapOnly|TestRelease.*BootstrapTargets' -count=1`. + + Expected: FAIL because shell and PowerShell join scripts still download the full helper, generated runners directly invoke it, `upgrade` remains exposed, and release builds restrict bootstrap to Windows amd64. + +- [ ] **Step 3: Route every generated connection through bootstrap** + + Replace direct helper discovery/download/launch with platform bootstrap package acquisition and signed manifest arguments. Generalize bootstrap platform selection for the supported release targets while retaining the Windows-only final-ZIP budget. Remove the legacy `upgrade` connection command. Make archive recovery select a signed recovery profile through bootstrap. Do not introduce a second downloader; all core and recovery asset downloads call `assetdownload.Download`. + +- [ ] **Step 4: Confirm GREEN and verify** + + Run the focused packages, `rtk test go test ./... -count=1`, cross-build all release targets, format touched Go files, and run `rtk proxy go vet ./...`. + + Expected: PASS; generated connection content contains no legacy full-helper route. + +- [ ] **Step 5: Review and commit** + + Inspect every generated launcher, release artifact list, secret boundary, and compatibility message, then commit `refactor: route connections through rdev-bootstrap`. + +### Task 8: Extend Entry Acceptance Contracts + +**Files:** +- Modify: `internal/acceptance/windows_temporary.go` +- Modify: `internal/acceptance/windows_temporary_package.go` +- Modify: `internal/acceptance/windows_temporary_verify.go` +- Test: corresponding `*_test.go` files +- Modify: `TASKS.md` +- Modify: `README.md` + +**Interfaces:** +- Adds non-sensitive entry evidence for final ZIP size, selected launcher path, fallback attempts, core-start count, network bytes, registration duration, cache hit, and cleanup result. + +- [ ] **Step 1: Write failing acceptance tests** + + Reject missing/oversized ZIP evidence, unordered fallback attempts, core-start count other than one, negative durations/bytes, warm cache miss, cold cache hit, missing active-route failure/reselection evidence, duplicate registration during route change, automatic archive execution, any legacy full-helper route, persistence residue, and reports containing ticket, gateway, token, credential, or private-path markers. + +- [ ] **Step 2: Confirm RED** + + Run `rtk test go test ./internal/acceptance -run 'Test.*Windows.*LayeredEntry' -count=1`. + + Expected: FAIL because the expanded evidence schema is absent. + +- [ ] **Step 3: Implement validation and documentation** + + Strictly decode the reports, enforce the measured invariants before copying evidence, checksum every copied file, and add explicit unchecked real-run items for Windows 10 and 11 amd64, physical Range resume, Defender/file-lock behavior, and process/persistence cleanup. Do not claim a real result without real evidence files. + +- [ ] **Step 4: Run full verification** + + Run focused acceptance tests, `rtk test go test ./... -count=1`, `rtk proxy go vet ./...`, `rtk test go test -coverprofile=.tmp/coverage.out ./...`, and `rtk proxy go tool cover -func=.tmp/coverage.out`. + + Expected: all automated tests pass; real Windows items remain explicitly incomplete until their evidence is collected. + +- [ ] **Step 5: Review and commit** + + Run `rtk git diff --check`, inspect the full task diff and redaction rules, then commit `test: require layered Windows entry evidence`. + +## Follow-Up Plans + +After this plan passes the actual 1 MiB delivery gate, write separate plans for: + +1. signed post-registration optional components and restricted helper dispatch; +2. real Windows 10/11 acceptance execution and release-evidence packaging. diff --git a/docs/superpowers/specs/2026-07-15-fast-reliable-connection-entry-design.md b/docs/superpowers/specs/2026-07-15-fast-reliable-connection-entry-design.md new file mode 100644 index 0000000..5c2c260 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-fast-reliable-connection-entry-design.md @@ -0,0 +1,125 @@ +# Fast, Reliable Connection Entry Design + +## Status + +Approved design. This document defines the connection performance and update +boundary before implementation. + +## Goal + +Make first connection fast and reliable while allowing installed hosts to gain +new capabilities through explicit, verified updates. Connection success, +recovery, and time to first active host take priority over adding new helper +features. + +## Current Constraint + +The current Connection Entry archive embeds every staged candidate artifact, +its manifests, release metadata, and launchers. The target must obtain this +large archive before the visible launcher can verify and start `rdev`. Existing +`rdev update check` and `rdev update plan` only report an available release; +they do not provide an installed host with an atomic, policy-controlled update +path. + +## Design + +### Bootstrap Layer + +Each platform receives a small Connection Entry bootstrapper. It contains only +the logic needed to read a signed release manifest, select a platform runtime, +resume a download, verify a signature and digest, and start a verified runtime. +It never carries ticket codes, gateway URLs, credentials, or persistent service +configuration. + +The bootstrapper uses HTTPS only, follows an allowlisted redirect policy, and +rejects an unsigned or expired manifest. It caches verified content by digest. +On a cache hit it starts the verified runtime without a network download. + +### Runtime and Helper Layers + +The host runtime is a separate, signed platform asset. It is the only runtime +asset required to join a temporary session. Relay helpers, desktop helpers, and +future adapter dependencies are separate signed components identified by +platform, version, digest, and capability. + +The runtime requests optional components only after the signed join manifest, +host policy, and selected connection path require them. Components are cached +in a user-scoped content-addressed directory and shared only when their digest +and platform match. A helper never becomes a first-connection prerequisite +merely because it is available in a release. + +### Update and Rollback + +An update downloads to a new version directory and verifies the release +manifest, signature, digest, platform, and executable health before activation. +Activation is an atomic pointer or directory switch. If the new runtime fails +the startup health check, the bootstrapper continues with the last known-good +runtime and records a structured failure event. + +Temporary mode performs updates only as part of a visible, authorized +Connection Entry run. It must not create a background updater or service. +Managed mode may use an explicit policy-controlled update schedule, but every +check, download, activation, rollback, and component installation is audited. + +### Connection Strategy + +The bootstrapper starts the cached runtime as soon as its signature is +verified. The runtime probes signed gateway candidates concurrently within +bounded timeouts and preserves the existing preferred transport and fallback +behaviour. Optional helper discovery/download runs only when the selected +connection path needs it. + +Connection reports include stage durations and outcomes for cache lookup, +manifest fetch, runtime download/resume, signature verification, gateway probe, +transport selection, host registration, reconnect, and rollback. Reports +redact URLs, tickets, tokens, and local sensitive paths. + +## Non-Goals + +- Binary delta patching is deferred. Small independently-versioned assets and + content-addressed caching achieve most of the benefit with less risk. +- This design does not add background persistence to temporary hosts. +- This design does not bypass platform security controls or make third-party + hosts reachable through inbound public ports. +- It does not claim real network performance until measured acceptance evidence + exists. + +## Compatibility + +The existing self-contained archive remains supported during migration. New +releases produce a bootstrap-compatible signed asset manifest in parallel. +Connection Entry prefers the layered path when all required assets are present +and falls back to the verified archive otherwise. The fallback is removed only +after cross-platform acceptance evidence proves parity. + +## Failure Semantics + +Missing, unsigned, expired, platform-incompatible, or digest-mismatched assets +fail closed before execution. A partial download is retained only as resumable +untrusted data and is never executed. Failed optional helpers must not prevent +core host connection when the selected policy permits another connection path. +No available verified runtime produces a clear, structured bootstrap failure +with recommended recovery actions. + +## Testing and Acceptance + +Unit and CLI contract tests cover manifest selection, cache hits, resumable +downloads, redirect restrictions, signature and digest failures, optional +component deferral, atomic activation, rollback, and audit redaction. + +Acceptance evidence measures cold and warm first connection on Windows first, +then macOS and Linux. It records archive/bootstrap bytes downloaded, time to +host registration, reconnect time, cache hit rate, failed helper isolation, and +successful rollback. These real-run packages become inputs to the formal +release acceptance gate. + +## Delivery Order + +1. Define the signed layered asset manifest and content-addressed cache API. +2. Implement the Windows bootstrapper download, verification, and runtime +cache, retaining the archive fallback. +3. Add optional-helper manifests and lazy installation after connection-path +selection. +4. Add atomic runtime activation, health checks, rollback, and audited update +reports. +5. Add cross-platform compatibility and real acceptance measurements. diff --git a/docs/superpowers/specs/2026-07-15-formal-release-acceptance-gate-design.md b/docs/superpowers/specs/2026-07-15-formal-release-acceptance-gate-design.md new file mode 100644 index 0000000..f9bb2e6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-formal-release-acceptance-gate-design.md @@ -0,0 +1,99 @@ +# Formal Release Acceptance Gate Design + +## Status + +Approved design. This document defines the release-validation boundary before +implementation. + +## Goal + +Keep day-to-day candidate creation fast for a solo developer while making +formal release planning depend on independently verifiable, real-environment +acceptance evidence. A local build, unit test suite, or fixture must never be +presented as proof of a real Windows host, agent runtime, restrictive network, +or managed service run. + +## Scope + +The existing `rdev release prepare-candidate` and ordinary +`rdev release verify-candidate` commands remain the fast development path. +They continue to verify staged artifacts, the release bundle, Skillkit, SBOM, +provenance, and checksums without requiring access to external test machines. + +Formal release planning gains a strict gate. It accepts only a verified release +evidence index and fails closed when any required real-run evidence is missing, +invalid, or does not identify the candidate being planned. The gate applies to +formal release planning and release CI, not normal development candidates. + +This change does not automate installation on third-party devices, publish a +GitHub Release, or implement new host persistence. Operators still perform +real environment runs with explicit authorization. + +## Evidence Matrix + +The formal-release evidence index must record and verify these matrix entries: + +1. A clean Windows Connection Entry run, including bootstrap verification, + join, status, session close, and no-persistence cleanup. +2. Fresh runs using at least two distinct supported agent runtimes. +3. A restrictive-network connection run through a supported connectivity + adapter. +4. Managed-service lifecycle evidence: Windows Service install/start/stop/ + uninstall and Linux systemd user-unit start/reboot-or-reconnect/stop/ + uninstall. +5. A post-release-download verification package for the planned release + version and platform assets. + +Each entry is an existing or dedicated acceptance package whose verifier checks +its manifest, checksums, transcript, audit chain, and release-signature output. +The index stores only the verified package reference, digest, result summary, +candidate version, and artifact or bundle identity. Raw transcripts and audits +remain in the acceptance archive, outside release assets. + +## Data Flow + +1. A developer creates and locally verifies a candidate as today. +2. Authorized operators execute real acceptance runs and package evidence. +3. `rdev acceptance release-evidence-index` verifies each package and writes a + checksummed index. +4. Formal candidate verification validates the candidate plus that index. It + requires every matrix entry, distinct agent runtime identities, and matching + candidate version/bundle identity. +5. GitHub Release planning consumes only a successful formal verification + report. Any failed check produces machine-readable failures and blocks the + plan. + +## CLI and Compatibility + +The implementation should use an explicit formal mode or a distinct formal +verification command. The ordinary candidate commands must preserve their +current behaviour so fast local iteration remains possible. + +The release-planning command must require a successful formal verification +report. It must not offer a flag that suppresses failed evidence checks. A +development-only release plan, if retained, must be visibly marked as +non-publishable and must not be usable by the formal CI workflow. + +## Failure Semantics + +Verification fails closed for a missing package, package verification failure, +missing matrix category, fewer than two distinct agent runtimes, untrusted +digest, candidate version mismatch, bundle identity mismatch, or evidence +marked as simulated. Errors identify the failed matrix entry and the package +check that failed, without emitting sensitive transcript content. + +## Testing + +Unit and CLI contract tests will cover the development fast path, each missing +or malformed matrix entry, duplicate agent runtimes, candidate binding +mismatches, successful formal verification, and blocked release planning. +Fixture packages will prove verifier behaviour only; they cannot satisfy the +formal release CI gate. CI will run the fast path on pull requests and require +the formal evidence index only for an explicitly designated release workflow. + +## Deferred Work + +Collecting the actual external evidence, production trust lifecycle, real +Windows Service execution, and real Linux reboot evidence remains separate +operator work. This gate makes their absence visible and release-blocking; it +does not fabricate or replace them. diff --git a/docs/superpowers/specs/2026-07-16-windows-layered-connection-entry-v2-design.md b/docs/superpowers/specs/2026-07-16-windows-layered-connection-entry-v2-design.md new file mode 100644 index 0000000..2f178df --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-windows-layered-connection-entry-v2-design.md @@ -0,0 +1,270 @@ +# Windows Layered Connection Entry V2 Design + +## Status + +Approved requirements, recorded on 2026-07-16. This design supersedes the +entry-selection and bootstrap-size portions of the 2026-07-15 design while +retaining its signed layered manifest, verified cache, and explicit archive +recovery model. + +## Goals + +- Prefer a visible PowerShell layered path on Windows 10/11 amd64. +- Keep `Start-ConnectionEntry.cmd` as a visible native fallback, not the only + entry path. +- When the command broker is used, try PowerShell normally, retry it with a + process-scoped execution-policy override, and then use the native CMD path. +- Permit automatic path changes only before the core runtime starts, so one + Connection Entry attempt can establish at most one host connection. +- Keep the single core's connection pool dynamic after startup: continuously + probe health and switch away from degraded WSS, long-poll, short-poll, relay, + or other verified routes without launching a second core. +- Reject a delivered handoff whose final compressed size, including the + executable bootstrap, exceeds 1,048,576 bytes. +- Register the minimal core before downloading optional components. +- Route every Connection Entry in the project through `rdev-bootstrap`; no + launcher may download or execute the old full `rdev` helper directly. +- Keep a verified archive profile as an explicit recovery path after all + layered paths fail, but make `rdev-bootstrap` verify, download, and launch + that recovery profile too. + +## Universal Bootstrap Invariant + +`rdev-bootstrap` is the only connection bootstrap for every generated package, +join page, launcher, and supported platform. A Connection Entry launcher may +prepare ACLs, choose a visible shell, and obtain the platform bootstrap, but it +must not: + +- test for or prefer an installed full `rdev` helper; +- download `rdev--` as a connection helper; +- execute `rdev host serve` or `rdev-host` directly; +- keep a separate legacy helper-download implementation; +- use the old `rdev-bootstrap upgrade` path to fetch and execute a full helper. + +Release builds therefore publish `rdev-bootstrap` for every supported target, +and each platform handoff contains or obtains that bootstrap before connection +startup. The bootstrap verifies the signed platform manifest, downloads the +minimal `rdev-host` core, and owns the foreground connection lifecycle. The +Windows delivery alone has the additional 1 MiB final-ZIP limit. + +## Measured Baseline + +The release build uses Windows amd64, `CGO_ENABLED=0`, `-trimpath`, and +`-ldflags "-s -w"`. + +| Artifact | PE bytes | gzip -9 bytes | +| --- | ---: | ---: | +| Current `rdev-bootstrap.exe` | 7,010,304 | 2,875,395 | +| Current `rdev-host.exe` | 7,915,520 | 3,199,332 | +| Empty stripped Go PE | 1,197,568 | 550,600 | +| Ed25519/JSON/SHA verifier with inlining disabled | 2,378,240 | 947,433 | +| Verifier plus foreground core execution | 2,553,344 | 1,018,787 | +| Verifier plus current `assetdownload.Download` | 6,014,976 | 2,346,939 | + +Compiler flags alone cannot close the current 1.8 MiB compressed gap. The +first-contact executable must stop linking the legacy upgrade command and the +standard `net/http`/TLS transport graph. + +## Scope Decomposition + +This design contains three independently reviewable subprojects: + +1. **Entry delivery:** deterministic handoff archive, hard size gate, focused + bootstrap, PowerShell-first broker, native CMD fallback, and single-attempt + state. This is the first implementation plan. +2. **Post-registration components:** signed component catalog, lazy component + resolver, restricted helper process protocol, cancellation, retry, and + cleanup. This gets a separate plan after entry delivery passes its gate. +3. **Windows acceptance:** real Windows 10/11 evidence, physical interruption, + Defender/file-lock behavior, ACL/reparse checks, cleanup, and performance + measurements. This gets a separate plan because it depends on real hosts. + +## Delivered Handoff + +The controller publishes a private deterministic ZIP named +`Windows-ConnectionEntry.zip`. Its complete contents are: + +- `Start-ConnectionEntry.ps1`, the preferred visible layered entry; +- `Start-ConnectionEntry.cmd`, the visible broker and native fallback; +- `rdev-bootstrap.exe`, the focused first-contact executable; +- `rdev-bootstrap.exe.rdev-release.json`, its Ed25519 release manifest; +- `rdev-bootstrap.exe.sha256`; +- `windows-layered-verification.json`; +- an explicit archive-recovery instruction that invokes the same bootstrap + against the separately signed archive profile without automatically running + it. + +The ZIP is written to a temporary sibling, closed, measured, and published by +atomic rename only when its size is at most 1,048,576 bytes. The unpacked +private handoff remains available to existing local workflows, but the ZIP is +the measured delivery unit. Because the ZIP is private session material, its +runnable launchers may carry the signed join-manifest URL and root needed to +start without manual flag assembly. Archive reports, package plan details, +logs, public release assets, and public evidence must not expose ticket codes, +gateway URLs, bearer tokens, credentials, or controller-local absolute paths. +ZIP entry names also follow Windows basename rules: no reserved device name, +control character, forbidden punctuation, trailing dot/space, separator, +drive/ADS colon, superscript device-number alias, or case/normalization +collision. On Windows the temporary archive is created atomically with a +protected DACL limited to the current user, SYSTEM, and Administrators before +any sensitive byte is written; +on other controllers it uses mode `0600`. Extracted launchers reapply and +validate the target-side DACL before reading or executing bootstrap material. + +## Bootstrap Boundary + +`rdev-bootstrap.exe` has one release command: `layered-run`. It performs only: + +1. strict HTTPS manifest fetch with a 1 MiB response bound; +2. Ed25519 release-root verification, validity-window and expected-version + checks; +3. exact `windows/amd64` core selection and same-origin relative URL + resolution; +4. resumable, bounded core download through `assetdownload.Download`; +5. SHA-256 and signed-size verification; +6. private cache validation and final executable lock/recheck; +7. an atomic `core_started` attempt transition immediately before foreground + execution. + +The legacy `upgrade` command and the generated full-helper download paths are +removed from the connection surface. Recovery, cold start, warm start, and +normal reconnect all enter through the same bootstrap verification boundary. + +To preserve `assetdownload.Download` without linking `net/http`, its transport +is separated from its cache, Range, retry, SHA-256, size, and atomic-promotion +logic. The standard HTTP adapter remains the default for full binaries. The +focused Windows bootstrap injects a bounded command transport backed by the +Windows system `curl.exe`; it forbids redirects, credentials, query strings, +fragments, non-HTTPS URLs, and non-system executable lookup. The downloader, +not the scripts or transport, continues to own partial-file offsets, retry +decisions, byte bounds, digest checks, cache promotion, and final placement. + +The final signed ZIP size gate decides release eligibility. A build that +exceeds the limit stops before materialization or publication; no reported +size substitutes for the actual final ZIP size. + +## Entry Selection, Process State, And Dynamic Routes + +Both launchers are visible and documented: + +- `Start-ConnectionEntry.ps1` is the preferred direct entry. +- `Start-ConnectionEntry.cmd` is the broker and native fallback. + +When CMD is launched without a mode, it performs these foreground attempts in +order: + +1. signed-verification PowerShell path under the current policy; +2. the same PowerShell path with a process-scoped + `-ExecutionPolicy Bypass` argument; this changes no registry or machine + policy and still yields to enforced Group Policy; +3. native CMD preparation followed by the focused bootstrap. + +PowerShell catches only failures that occur before the core starts and invokes +the native CMD mode with the same attempt directory. If PowerShell is blocked +before its body runs, the broker observes its nonzero exit and advances. A +direct PowerShell launch that is blocked before execution leaves CMD as the +visible operator fallback. + +Each broker invocation creates a unique private attempt directory by an atomic +directory-create loop. The state file uses this schema: + +```json +{ + "schema_version": "rdev.windows-layered-attempt.v1", + "attempt_id": "opaque local id", + "stage": "pre_core|core_started|core_exited", + "launcher": "powershell|powershell-bypass|cmd", + "updated_at": "RFC3339 timestamp" +} +``` + +The bootstrap writes state through a private temporary file and atomic rename. +Broker fallback is allowed only while `stage=pre_core`. It writes +`core_started` before creating the core process. This state prevents a second +core process; it does not pin the core to one network route. After startup the +core's bounded runtime route pool continues health probes, races eligible +routes, and changes the selected route when the active route becomes slow or +unavailable. HTTPS long-poll, short-poll, and configured verified gateway, +relay, or mesh candidates remain runtime choices under signed policy. A route +change reuses the registered session, endpoint, lease, event cursor, and core +process; it does not create a duplicate host registration. + +The current sequential `gatewayCandidateSet` is only a starting point. The V2 +pool must probe candidates concurrently within a fixed worker bound, record +latency and consecutive failures, switch immediately on active-route failure, +and use hysteresis for latency-only changes to avoid flapping. Event polling +adapters expose long-poll and short-poll under one cursor contract. A transport +that has no maintained standard-library implementation is excluded rather than +implemented with an ad hoc protocol stack. + +If the core process itself later exits, the broker prints an explicit +`rdev-bootstrap` archive-profile command and exits without starting a second +layered or recovery process in that attempt. Closing the visible launcher +cancels the context, terminates its child +process tree, waits for cleanup, and leaves no service, scheduled task, Run +key, firewall rule, or background updater. + +## Manifest And Cache Invariants + +All release and layered manifests fail closed on unknown fields, invalid +schema, missing version, invalid validity window, Ed25519 signature mismatch, +wrong platform, unknown kind, duplicate ID, invalid SHA-256, nonpositive size, +or unsafe path. Asset URLs are same-origin HTTPS relative paths only. + +The user cache remains under `%LOCALAPPDATA%\RemoteDevSkillkit\cache` with +private directory/file permissions corresponding to `0700`/`0600`. UNC paths, +reparse points in every ancestor, non-regular files, oversized partial files, +and identity changes between verification and execution are rejected. Partial +files are untrusted and never executed. Final promotion is atomic; a valid old +file is never removed before its replacement is durable. + +## Post-Registration Components + +The core manifest may describe optional helpers for these capabilities: + +- desktop control; +- file transfer; +- screenshot capture; +- screen recording; +- input control. + +Availability in a release never triggers a pre-registration download. The +core starts the component manager only after signed join-manifest verification +and successful host registration. A component request selects exactly one +platform/capability asset, re-verifies the signed manifest and expected +version, calls `assetdownload.Download`, validates the private cache, and +starts a restricted child only for the lifetime of the requesting session. +Cancellation propagates to download and child execution. Helper failure does +not terminate an otherwise usable core connection. + +## Acceptance Contract + +Unit and cross-compiled Windows tests must prove: + +- final deterministic ZIP at or below 1,048,576 bytes; +- preferred PowerShell success; +- current-policy failure, process-scoped retry, and native CMD fallback; +- exactly one core launch for each broker attempt; +- dynamic route change inside that one core when the active pool member fails; +- no duplicate registration or second core process during a route change; +- no optional component request before registration; +- one requested component verifies, caches, cancels, and retries; +- physical interruption resumes with Range; +- private NTFS ACLs, UNC/reparse rejection, and locked executable launch; +- window/session close leaves no child, service, task, Run key, or updater; +- layered exhaustion prints, but does not execute, the bootstrap archive + profile; +- repository contract tests find no generated direct full-helper download or + direct host connection outside `rdev-bootstrap`; +- cold/warm registration time, actual network bytes, and cache hit rate. + +Real Windows 10 and Windows 11 amd64 reports remain mandatory release evidence. +Synthetic tests establish contracts but do not satisfy that gate. + +## Security And Privacy + +The design adds no hidden persistence, inbound public listener, unrestricted +shell, policy mutation, or privilege escalation. Public release assets and +public reports exclude tickets, gateway values, tokens, credentials, and +private paths. Plan check details and logs use presence/status values, stage +names, sizes, durations, cache booleans, and redacted error classes only. diff --git a/internal/acceptance/fresh_agent_support_session.go b/internal/acceptance/fresh_agent_support_session.go index c65f0b0..1557d9a 100644 --- a/internal/acceptance/fresh_agent_support_session.go +++ b/internal/acceptance/fresh_agent_support_session.go @@ -535,7 +535,7 @@ func freshAgentSupportSessionChecks(input freshAgentSupportSessionCheckInput) [] {Name: "created_session_has_agent_connection_runbook", Passed: stringFromAny(runbook["schema_version"]) == supportsession.AgentConnectionRunbookSchemaVersion && strings.Contains(strings.Join(stringSliceFromAny(runbook["sequence"]), "\n"), "target_handoff_envelope.full_text") && strings.Contains(strings.Join(stringSliceFromAny(runbook["forbidden"]), "\n"), "Agent-authored PowerShell"), Detail: stringFromAny(runbook["phase"])}, {Name: "agent_runbook_starts_with_sessions_connect", Passed: stringFromAny(runbookStandardEntry["mcp_tool"]) == "rdev.sessions.connect" && strings.Contains(strings.Join(stringSliceFromAny(runbookStandardEntry["cli_command"]), " "), "support-session connect"), Detail: fmt.Sprintf("%v", runbookStandardEntry)}, {Name: "agent_runbook_forbids_low_level_invite_first", Passed: strings.Contains(strings.Join(stringSliceFromAny(runbookLowLevelRule["do_not_start_with"]), "\n"), "rdev.invites.create") && strings.Contains(strings.Join(stringSliceFromAny(runbookLowLevelRule["do_not_start_with"]), "\n"), "rdev.connection_entry.plan"), Detail: fmt.Sprintf("%v", runbookLowLevelRule)}, - {Name: "agent_runbook_contains_real_failure_prevention", Passed: stringFromAny(runbookFailurePrevention["schema_version"]) == supportsession.FreshAgentFailurePreventionSchemaVersion && strings.Contains(strings.Join(stringSliceFromAny(runbookFailurePrevention["known_failure_pattern"]), "\n"), "rdev is required") && strings.Contains(strings.Join(stringSliceFromAny(runbookFailurePrevention["required_standard_path"]), "\n"), "cli_start_now_command") && strings.Contains(strings.Join(stringSliceFromAny(runbookFailurePrevention["forbidden_agent_generated_workarounds"]), "\n"), "ExecutionPolicy Bypass"), Detail: fmt.Sprintf("%v", runbookFailurePrevention)}, + {Name: "agent_runbook_contains_real_failure_prevention", Passed: stringFromAny(runbookFailurePrevention["schema_version"]) == supportsession.FreshAgentFailurePreventionSchemaVersion && strings.Contains(strings.Join(stringSliceFromAny(runbookFailurePrevention["known_failure_pattern"]), "\n"), "bootstrap assets") && strings.Contains(strings.Join(stringSliceFromAny(runbookFailurePrevention["required_standard_path"]), "\n"), "cli_start_now_command") && strings.Contains(strings.Join(stringSliceFromAny(runbookFailurePrevention["forbidden_agent_generated_workarounds"]), "\n"), "ExecutionPolicy Bypass"), Detail: fmt.Sprintf("%v", runbookFailurePrevention)}, {Name: "started_payload_has_top_level_handoff", Passed: input.StartedSession["ready_to_send_to_human"] == false && input.StartedSession["ready_to_send"] == false && input.StartedSession["ready_to_activate"] == false && input.StartedSession["ready_to_execute"] == false && stringFromAny(startedHandoff["schema_version"]) == supportsession.UserHandoffSchemaVersion && stringFromAny(startedHandoff["copy_paste"]) == stringFromAny(input.StartedSession["target_command"]), Detail: stringFromAny(startedHandoff["copy_paste_kind"])}, {Name: "started_payload_has_top_level_forwardable_envelope", Passed: stringFromAny(startedEnvelope["schema_version"]) == supportsession.TargetHandoffEnvelopeSchemaVersion && !boolFromAny(startedEnvelope["ready_to_forward"]) && stringFromAny(startedEnvelope["copy_paste"]) == stringFromAny(input.StartedSession["target_command"]) && strings.Contains(strings.ToLower(stringFromAny(startedEnvelope["after_send"])), "do not send"), Detail: stringFromAny(startedEnvelope["copy_paste_kind"])}, {Name: "started_payload_has_top_level_supervision", Passed: stringFromAny(startedSupervision["schema_version"]) == supportsession.ConnectionSupervisionSchemaVersion && stringFromAny(startedSupervision["ticket_code"]) == input.Ticket.Code, Detail: stringFromAny(startedSupervision["continuity_assessment"])}, @@ -572,11 +572,11 @@ func buildBootstrapSelfRepairContract(outDir string, now time.Time, bootstrapCon return nil, nil, err } assets := map[string]string{ - "rdev-windows-amd64.exe": "fake windows rdev helper\n", - "rdev-darwin-arm64": "fake darwin arm64 rdev helper\n", - "rdev-darwin-amd64": "fake darwin amd64 rdev helper\n", - "rdev-linux-amd64": "fake linux amd64 rdev helper\n", - "rdev-linux-arm64": "fake linux arm64 rdev helper\n", + "rdev-bootstrap-windows-amd64.exe": "fake windows bootstrap\n", + "rdev-bootstrap-darwin-arm64": "fake darwin arm64 bootstrap\n", + "rdev-bootstrap-darwin-amd64": "fake darwin amd64 bootstrap\n", + "rdev-bootstrap-linux-amd64": "fake linux amd64 bootstrap\n", + "rdev-bootstrap-linux-arm64": "fake linux arm64 bootstrap\n", } assetPaths := map[string]string{} assetSHA256 := map[string]string{} @@ -606,11 +606,13 @@ func buildBootstrapSelfRepairContract(outDir string, now time.Time, bootstrapCon } server := httpapi.NewServer(gw) server.Assets = httpapi.AssetConfig{ - RdevWindowsAMD64Path: assetPaths["rdev-windows-amd64.exe"], - RdevDarwinARM64Path: assetPaths["rdev-darwin-arm64"], - RdevDarwinAMD64Path: assetPaths["rdev-darwin-amd64"], - RdevLinuxAMD64Path: assetPaths["rdev-linux-amd64"], - RdevLinuxARM64Path: assetPaths["rdev-linux-arm64"], + LayeredReleaseRootPublicKey: "release-root:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + LayeredReleaseVersion: "v2.0.0-acceptance", + RdevBootstrapWindowsAMD64Path: assetPaths["rdev-bootstrap-windows-amd64.exe"], + RdevBootstrapDarwinARM64Path: assetPaths["rdev-bootstrap-darwin-arm64"], + RdevBootstrapDarwinAMD64Path: assetPaths["rdev-bootstrap-darwin-amd64"], + RdevBootstrapLinuxAMD64Path: assetPaths["rdev-bootstrap-linux-amd64"], + RdevBootstrapLinuxARM64Path: assetPaths["rdev-bootstrap-linux-arm64"], } httpServer := httptest.NewServer(server.Handler()) defer httpServer.Close() @@ -665,15 +667,13 @@ func buildBootstrapSelfRepairContract(outDir string, now time.Time, bootstrapCon "shell_script_bytes": len(shellBootstrap), "windows_within_budget": bootstrapTargetBytes > 0 && len(windowsBootstrap) < bootstrapTargetBytes, "shell_within_budget": bootstrapTargetBytes > 0 && len(shellBootstrap) < bootstrapTargetBytes, - "preconnect_endpoint": bootstrapConnector["preconnect_endpoint"], - "preconnect_source": bootstrapConnector["source"], - "preconnect_grants_host_access": boolFromAny(bootstrapConnector["grants_host_access"]), + "bootstrap_grants_host_access": boolFromAny(bootstrapConnector["grants_host_access"]), "can_run_session_tasks_before_full_runner": boolFromAny(bootstrapConnector["can_run_session_tasks"]), "full_runner_phase": bootstrapConnector["full_runner_phase"], - "must_not_skip_full_helper_verification": boolFromAny( - bootstrapConnector["must_not_skip_full_helper_verification"], + "must_not_skip_core_verification": boolFromAny( + bootstrapConnector["must_not_skip_core_verification"], ), - "staged_upgrade_rule": "preconnect is a sub-1MB first-contact signal only; session task execution requires downloading the full helper with retry/backoff, verifying SHA-256, then starting host serve", + "staged_upgrade_rule": "the sub-1MiB bootstrap verifies signed release metadata, downloads the platform core, and starts it exactly once", } report := map[string]any{ "schema_version": "rdev.acceptance.bootstrap-self-repair.v1", @@ -688,15 +688,15 @@ func buildBootstrapSelfRepairContract(outDir string, now time.Time, bootstrapCon forbidden := joinPage + "\n" + windowsBootstrap + "\n" + shellBootstrap checks := []Check{ {Name: "bootstrap_self_repair_join_page_available", Passed: strings.Contains(joinPage, "bootstrap.ps1") && strings.Contains(joinPage, "bootstrap.sh") && strings.Contains(joinPage, "rdev.connection-entry.package-catalog.v1"), Detail: joinBase}, - {Name: "bootstrap_self_repair_windows_downloads_verified_helper", Passed: strings.Contains(windowsBootstrap, "Downloading verified rdev helper") && strings.Contains(windowsBootstrap, "Invoke-RdevWebRequestWithRetry") && strings.Contains(windowsBootstrap, "Get-FileHash") && strings.Contains(windowsBootstrap, ".sha256"), Detail: "PowerShell downloads with retry/backoff and verifies rdev-windows-amd64.exe when rdev is absent"}, - {Name: "bootstrap_self_repair_shell_downloads_verified_helper", Passed: strings.Contains(shellBootstrap, "Downloading verified rdev helper") && strings.Contains(shellBootstrap, "rdev_curl_retry_flags") && strings.Contains(shellBootstrap, "curl $rdev_curl_retry_flags -fsSL") && strings.Contains(shellBootstrap, "shasum -a 256") && strings.Contains(shellBootstrap, ".sha256"), Detail: "shell downloads with retry/backoff and verifies target OS/arch helper when rdev is absent"}, + {Name: "bootstrap_self_repair_windows_downloads_verified_bootstrap", Passed: strings.Contains(windowsBootstrap, "rdev-bootstrap") && strings.Contains(windowsBootstrap, "AllowAutoRedirect = $false") && strings.Contains(windowsBootstrap, "Get-FileHash") && strings.Contains(windowsBootstrap, ".sha256") && strings.Contains(windowsBootstrap, "attempt-check"), Detail: "PowerShell verifies the platform bootstrap before layered startup"}, + {Name: "bootstrap_self_repair_shell_downloads_verified_bootstrap", Passed: strings.Contains(shellBootstrap, "rdev-bootstrap") && strings.Contains(shellBootstrap, "--proto '=https'") && strings.Contains(shellBootstrap, "--retry 3") && strings.Contains(shellBootstrap, "shasum -a 256") && strings.Contains(shellBootstrap, ".sha256"), Detail: "shell verifies the platform bootstrap before layered startup"}, {Name: "bootstrap_self_repair_pins_manifest_root", Passed: strings.Contains(windowsBootstrap, "--manifest-root-public-key") && strings.Contains(shellBootstrap, "--manifest-root-public-key"), Detail: "bootstrap scripts pin the join manifest trust root"}, - {Name: "bootstrap_self_repair_starts_visible_host", Passed: strings.Contains(windowsBootstrap, "host serve") && strings.Contains(shellBootstrap, "host serve") && strings.Contains(windowsBootstrap, "--transport long-poll") && strings.Contains(shellBootstrap, "--transport long-poll") && strings.Contains(windowsBootstrap, "--once=false") && strings.Contains(shellBootstrap, "--once=false"), Detail: "bootstrap scripts start attended host serve with stable long-poll transport"}, + {Name: "bootstrap_self_repair_starts_visible_host", Passed: strings.Contains(windowsBootstrap, "layered-run") && strings.Contains(shellBootstrap, "layered-run") && strings.Contains(windowsBootstrap, "'auto'") && strings.Contains(shellBootstrap, "--transport auto") && strings.Contains(windowsBootstrap, "--once=false") && strings.Contains(shellBootstrap, "--once=false") && !strings.Contains(windowsBootstrap, "host serve") && !strings.Contains(shellBootstrap, "host serve"), Detail: "bootstrap scripts start one attended core through the layered boundary"}, {Name: "bootstrap_self_repair_assets_have_hashes", Passed: allAssetsOK, Detail: fmt.Sprintf("%v", assetResults)}, {Name: "bootstrap_self_repair_no_manual_rdev_requirement", Passed: !strings.Contains(forbidden, "rdev is required") && !strings.Contains(forbidden, "Install the verified rdev release package") && !strings.Contains(forbidden, "ExecutionPolicy Bypass"), Detail: "join/bootstrap surface must not ask the target user to manually install rdev or bypass execution policy"}, {Name: "bootstrap_first_connect_scripts_under_budget", Passed: boolFromAny(bootstrapFirstConnect["windows_within_budget"]) && boolFromAny(bootstrapFirstConnect["shell_within_budget"]), Detail: fmt.Sprintf("windows=%d shell=%d target=%d", len(windowsBootstrap), len(shellBootstrap), bootstrapTargetBytes)}, - {Name: "bootstrap_first_connect_preconnect_does_not_grant_host_access", Passed: stringFromAny(bootstrapConnector["schema_version"]) == supportsession.BootstrapConnectorSchemaVersion && stringFromAny(bootstrapConnector["preconnect_endpoint"]) == "/v1/support-session/preconnect" && !boolFromAny(bootstrapConnector["grants_host_access"]) && !boolFromAny(bootstrapConnector["can_run_session_tasks"]), Detail: fmt.Sprintf("%v", bootstrapConnector)}, - {Name: "bootstrap_first_connect_requires_verified_full_helper_upgrade", Passed: stringFromAny(bootstrapConnector["full_runner_phase"]) == "download-verified-rdev-host" && boolFromAny(bootstrapConnector["must_not_skip_full_helper_verification"]) && strings.Contains(windowsBootstrap, "Get-FileHash") && strings.Contains(shellBootstrap, "shasum -a 256") && strings.Contains(windowsBootstrap, "Invoke-RdevWebRequestWithRetry") && strings.Contains(shellBootstrap, "curl $rdev_curl_retry_flags -fsSL"), Detail: "full helper upgrade must use retry/backoff plus SHA-256 verification before host serve can run session tasks"}, + {Name: "bootstrap_first_connect_bootstrap_does_not_grant_host_access", Passed: stringFromAny(bootstrapConnector["schema_version"]) == supportsession.BootstrapConnectorSchemaVersion && !boolFromAny(bootstrapConnector["grants_host_access"]) && !boolFromAny(bootstrapConnector["can_run_session_tasks"]), Detail: fmt.Sprintf("%v", bootstrapConnector)}, + {Name: "bootstrap_first_connect_uses_verified_core_handoff", Passed: stringFromAny(bootstrapConnector["full_runner_phase"]) == "download-signed-core-after-registration" && boolFromAny(bootstrapConnector["must_not_skip_core_verification"]) && strings.Contains(windowsBootstrap, "Get-FileHash") && strings.Contains(shellBootstrap, "shasum -a 256") && strings.Contains(windowsBootstrap, "--root-public-key") && strings.Contains(shellBootstrap, "--root-public-key"), Detail: "bootstrap verifies signed core metadata before the single core launch"}, } return report, checks, nil } diff --git a/internal/acceptance/fresh_agent_support_session_test.go b/internal/acceptance/fresh_agent_support_session_test.go index 488c916..dde46d8 100644 --- a/internal/acceptance/fresh_agent_support_session_test.go +++ b/internal/acceptance/fresh_agent_support_session_test.go @@ -95,8 +95,8 @@ func TestRunFreshAgentSupportSessionGatesBootstrapFirstConnectEvidence(t *testin for _, name := range []string{ "bootstrap_first_connect_scripts_under_budget", - "bootstrap_first_connect_preconnect_does_not_grant_host_access", - "bootstrap_first_connect_requires_verified_full_helper_upgrade", + "bootstrap_first_connect_bootstrap_does_not_grant_host_access", + "bootstrap_first_connect_uses_verified_core_handoff", } { check, ok := checkByName(report.Checks, name) if !ok { @@ -112,14 +112,14 @@ func TestRunFreshAgentSupportSessionGatesBootstrapFirstConnectEvidence(t *testin if bootstrap["schema_version"] != "rdev.acceptance.bootstrap-first-connect.v1" || bootstrap["windows_within_budget"] != true || bootstrap["shell_within_budget"] != true || - bootstrap["default_first_connect_surface"] != "script-preconnect" || - bootstrap["publishes_native_first_connect_asset"] != false || - bootstrap["preconnect_grants_host_access"] != false || + bootstrap["default_first_connect_surface"] != "signed-native-bootstrap" || + bootstrap["publishes_native_first_connect_asset"] != true || + bootstrap["bootstrap_grants_host_access"] != false || bootstrap["can_run_session_tasks_before_full_runner"] != false || - !strings.Contains(stringFromAny(bootstrap["staged_upgrade_rule"]), "SHA-256") || - nativeBootstrap["availability"] != "optional-if-rdev-bootstrap-is-already-installed-or-published" || - nativeBootstrap["published_by_support_session_assets"] != false || - nativeBootstrap["default_first_connect_surface"] != "script-preconnect" { + !strings.Contains(stringFromAny(bootstrap["staged_upgrade_rule"]), "signed release metadata") || + nativeBootstrap["availability"] != "published-first-connect-asset" || + nativeBootstrap["published_by_support_session_assets"] != true || + nativeBootstrap["default_first_connect_surface"] != "signed-native-bootstrap" { t.Fatalf("expected bootstrap first-connect evidence, got %#v", bootstrap) } } diff --git a/internal/acceptance/managed_mac.go b/internal/acceptance/managed_mac.go index 2f3cefa..f8320aa 100644 --- a/internal/acceptance/managed_mac.go +++ b/internal/acceptance/managed_mac.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd/windowsentry" "github.com/EitanWong/remote-dev-skillkit/internal/codexadapter" "github.com/EitanWong/remote-dev-skillkit/internal/controlplane" "github.com/EitanWong/remote-dev-skillkit/internal/gateway" @@ -569,11 +570,14 @@ func prepareAcceptanceOut(outDir string) error { if len(entries) > 0 { return fmt.Errorf("out directory must be empty: %s", outDir) } - return nil + return windowsentry.ProtectPrivatePath(outDir, true) } else if !os.IsNotExist(err) { return err } - return os.MkdirAll(outDir, 0o700) + if err := os.MkdirAll(outDir, 0o700); err != nil { + return err + } + return windowsentry.ProtectPrivatePath(outDir, true) } func writeReport(path string, report ManagedMacReport) error { diff --git a/internal/acceptance/managed_mac_service.go b/internal/acceptance/managed_mac_service.go index fd76543..bbf6e19 100644 --- a/internal/acceptance/managed_mac_service.go +++ b/internal/acceptance/managed_mac_service.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd/windowsentry" "github.com/EitanWong/remote-dev-skillkit/internal/service" "github.com/EitanWong/remote-dev-skillkit/internal/workspace" ) @@ -352,7 +353,10 @@ func writeAcceptanceFile(path string, content []byte, force bool) error { if err := file.Close(); err != nil { return err } - return os.Chmod(path, 0o600) + if err := os.Chmod(path, 0o600); err != nil { + return err + } + return windowsentry.ProtectPrivatePath(path, false) } func writeManagedMacServicePlan(path string, plan ManagedMacServicePlan) error { diff --git a/internal/acceptance/relay_adapter_package.go b/internal/acceptance/relay_adapter_package.go index 2259379..cf851dc 100644 --- a/internal/acceptance/relay_adapter_package.go +++ b/internal/acceptance/relay_adapter_package.go @@ -186,7 +186,7 @@ func PackageRelayAdapterEvidence(opts RelayAdapterPackageOptions) (RelayAdapterA files = append(files, entry) } - files = append(files, copyOptionalEvidence(outDir, "evidence/runner-result.json", "runner-result", opts.RunnerResultPath, redactor, add)...) + files = append(files, copyRunnerResultEvidence(outDir, opts.RunnerResultPath, redactor, add)...) files = append(files, copyOptionalEvidence(outDir, "evidence/helper-transcript.txt", "helper-transcript", opts.HelperTranscriptPath, redactor, add)...) files = append(files, copyOptionalEvidence(outDir, "evidence/gateway-status.json", "gateway-status", opts.GatewayStatusPath, redactor, add)...) files = append(files, copyOptionalEvidence(outDir, "evidence/host-status.json", "host-status", opts.HostStatusPath, redactor, add)...) @@ -236,6 +236,113 @@ func PackageRelayAdapterEvidence(opts RelayAdapterPackageOptions) (RelayAdapterA return pkg, nil } +func copyRunnerResultEvidence(root, source string, redactor *shelladapter.ArtifactRedactor, add func(string, bool, string)) []AcceptancePackageFile { + if strings.TrimSpace(source) == "" { + add("runner-result_copied", false, "missing") + return nil + } + content, err := os.ReadFile(source) + if err == nil && acceptanceEvidencePath("evidence/runner-result.json") && evidenceContentIsPlaceholder(content) { + err = fmt.Errorf("evidence placeholder must be replaced before packaging: %s", source) + } + if err == nil && redactor != nil { + content = []byte(redactor.Redact(string(content))) + } + if err == nil { + content, err = sanitizeRunnerResultEvidence(content) + } + if err != nil { + add("runner-result_copied", false, packageEvidenceErrorDetail(err)) + return nil + } + entry, err := writePackageContent(root, "evidence/runner-result.json", "runner-result", content, source) + if err != nil { + add("runner-result_copied", false, packageEvidenceErrorDetail(err)) + return nil + } + add("runner-result_copied", true, entry.Path) + return []AcceptancePackageFile{entry} +} + +func sanitizeRunnerResultEvidence(content []byte) ([]byte, error) { + var value any + if err := json.Unmarshal(content, &value); err != nil { + return nil, fmt.Errorf("decode runner result evidence: %w", err) + } + value = sanitizeRunnerResultValue(value, "") + clean, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("encode runner result evidence: %w", err) + } + return append(clean, '\n'), nil +} + +func sanitizeRunnerResultValue(value any, key string) any { + switch typed := value.(type) { + case map[string]any: + clean := make(map[string]any, len(typed)) + for childKey, child := range typed { + if runnerResultSensitiveKey(childKey) { + clean[childKey] = runnerResultRedaction(childKey) + continue + } + clean[childKey] = sanitizeRunnerResultValue(child, childKey) + } + return clean + case []any: + clean := make([]any, len(typed)) + for index, child := range typed { + clean[index] = sanitizeRunnerResultValue(child, key) + } + return clean + case string: + return sanitizeRunnerResultString(typed, key) + default: + return value + } +} + +func runnerResultSensitiveKey(key string) bool { + lower := strings.ToLower(strings.TrimSpace(key)) + if lower == "selected_path" || lower == "path_id" { + return false + } + return lower == "manifest_path" || lower == "workspace_root" || lower == "path" || + strings.HasSuffix(lower, "_path") || strings.HasSuffix(lower, "_url") +} + +func runnerResultRedaction(key string) string { + if strings.HasSuffix(strings.ToLower(strings.TrimSpace(key)), "_url") { + return "[REDACTED:gateway_url]" + } + return "[REDACTED:private_path]" +} + +func sanitizeRunnerResultString(value, key string) string { + trimmed := strings.TrimSpace(value) + lower := strings.ToLower(trimmed) + if strings.HasSuffix(strings.ToLower(strings.TrimSpace(key)), "_url") || + strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + return "[REDACTED:gateway_url]" + } + if runnerResultLooksLikePath(trimmed) || strings.Contains(lower, "/users/") || + strings.Contains(lower, "/home/") || strings.Contains(lower, `\users\`) { + return "[REDACTED:private_path]" + } + if strings.Contains(lower, "://") { + return "[REDACTED:gateway_url]" + } + return value +} + +func runnerResultLooksLikePath(value string) bool { + if value == "" || filepath.IsAbs(value) { + return value != "" + } + return len(value) >= 3 && ((value[0] >= 'a' && value[0] <= 'z') || (value[0] >= 'A' && value[0] <= 'Z')) && + value[1] == ':' && (value[2] == '\\' || value[2] == '/') +} + func resolveRelayEvidenceDir(opts RelayAdapterPackageOptions) (RelayAdapterPackageOptions, error) { if strings.TrimSpace(opts.EvidenceDirPath) == "" { return opts, nil diff --git a/internal/acceptance/relay_adapter_package_test.go b/internal/acceptance/relay_adapter_package_test.go index 70c19bb..aa0fa16 100644 --- a/internal/acceptance/relay_adapter_package_test.go +++ b/internal/acceptance/relay_adapter_package_test.go @@ -73,6 +73,64 @@ func TestPackageAndVerifyRelayAdapterEvidence(t *testing.T) { } } +func TestPackageRelayAdapterEvidenceRedactsRunnerPrivateSurface(t *testing.T) { + root := t.TempDir() + relayDir := filepath.Join(root, "relay") + if _, err := relayadapter.Build(relayadapter.Options{ + OutDir: relayDir, + AdapterKind: "wireguard", + GeneratedAt: time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + evidence := writeRelayAdapterEvidenceForTest(t, root, "existing-wireguard-vpn") + privatePath := filepath.Join(string(filepath.Separator), "Users", "example", "private", "runner-manifest.json") + privateCache := filepath.Join(string(filepath.Separator), "Users", "example", "private", "cache") + runnerResult, err := json.MarshalIndent(map[string]any{ + "schema_version": "rdev.connection-entry.runner-result.v1", + "manifest_path": privatePath, + "selected_path": "existing-wireguard-vpn", + "selected_gateway_url": "https://gateway.example.invalid/v1", + "bootstrap_args": []string{"rdev-bootstrap", "--cache-dir", privateCache, "--gateway", "https://gateway.example.invalid/v1"}, + "tool_results": []map[string]any{{"name": "rdev-bootstrap", "found": true, "path": privatePath}}, + "helper_started": true, + }, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(evidence.runnerResult, append(runnerResult, '\n'), 0o600); err != nil { + t.Fatal(err) + } + + pkg, err := PackageRelayAdapterEvidence(RelayAdapterPackageOptions{ + RelayAdapterPackagePath: relayDir, + OutDir: filepath.Join(root, "package"), + EvidenceDirPath: evidence.dir, + Now: time.Date(2026, 7, 4, 12, 5, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + if !pkg.OK() { + t.Fatalf("expected package ok: %#v", pkg.Checks) + } + verification, err := VerifyRelayAdapterAcceptancePackage(pkg.OutDir) + if err != nil { + t.Fatal(err) + } + if !verification.OK() { + t.Fatalf("expected sanitized runner evidence to verify: %#v", verification.Checks) + } + + copied, err := os.ReadFile(filepath.Join(pkg.OutDir, "evidence", "runner-result.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(copied), privatePath) || strings.Contains(string(copied), "gateway.example.invalid") { + t.Fatalf("runner evidence retained private surface: %s", copied) + } +} + func TestVerifyRelayAdapterEvidenceRejectsMissingConnection(t *testing.T) { root := t.TempDir() relayDir := filepath.Join(root, "relay") diff --git a/internal/acceptance/windows_layered_entry_evidence_test.go b/internal/acceptance/windows_layered_entry_evidence_test.go new file mode 100644 index 0000000..188c4f7 --- /dev/null +++ b/internal/acceptance/windows_layered_entry_evidence_test.go @@ -0,0 +1,98 @@ +package acceptance + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestValidateWindowsLayeredEntryEvidence(t *testing.T) { + plan := WindowsTemporaryPlan{ + HandoffArchiveSizeBytes: 1045000, + HandoffArchiveSHA256: strings.Repeat("a", 64), + } + content := marshalWindowsLayeredEntryEvidenceForTest(t, windowsLayeredEntryEvidenceForTest()) + if err := validateWindowsLayeredEntryEvidence(content, plan); err != nil { + t.Fatalf("expected complete layered entry evidence to pass: %v", err) + } +} + +func TestValidateWindowsLayeredEntryEvidenceRejectsInvalidContracts(t *testing.T) { + plan := WindowsTemporaryPlan{ + HandoffArchiveSizeBytes: 1045000, + HandoffArchiveSHA256: strings.Repeat("a", 64), + } + tests := []struct { + name string + want string + edit func(map[string]any) + }{ + {name: "oversized zip", want: "size", edit: func(v map[string]any) { v["handoff_zip_size_bytes"] = float64((1 << 20) + 1) }}, + {name: "unordered fallback", want: "fallback", edit: func(v map[string]any) { v["fallback_attempts"] = []any{"cmd", "powershell"} }}, + {name: "duplicate core", want: "core", edit: func(v map[string]any) { v["core_start_count"] = float64(2) }}, + {name: "negative duration", want: "duration", edit: func(v map[string]any) { v["registration_duration_ms"] = float64(-1) }}, + {name: "negative bytes", want: "network", edit: func(v map[string]any) { v["network_bytes"] = float64(-1) }}, + {name: "range not resumed", want: "range", edit: func(v map[string]any) { v["range_resumed"] = false }}, + {name: "private acl missing", want: "acl", edit: func(v map[string]any) { v["private_acl"] = false }}, + {name: "reparse accepted", want: "reparse", edit: func(v map[string]any) { v["reparse_rejected"] = false }}, + {name: "defender lock missing", want: "defender", edit: func(v map[string]any) { v["defender_lock_verified"] = false }}, + {name: "route not reselected", want: "route", edit: func(v map[string]any) { v["route_reselected"] = false }}, + {name: "duplicate registration", want: "registration", edit: func(v map[string]any) { v["registration_count"] = float64(2) }}, + {name: "automatic archive execution", want: "archive", edit: func(v map[string]any) { v["archive_recovery_executed"] = true }}, + {name: "legacy route", want: "bootstrap", edit: func(v map[string]any) { v["bootstrap_only"] = false }}, + {name: "persistence residue", want: "persistence", edit: func(v map[string]any) { v["persistence_residue"] = []any{"service"} }}, + {name: "cleanup missing", want: "cleanup", edit: func(v map[string]any) { v["cleanup_complete"] = false }}, + {name: "private path", want: "private path", edit: func(v map[string]any) { v["note"] = `C:\Users\example\private` }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := windowsLayeredEntryEvidenceForTest() + test.edit(value) + err := validateWindowsLayeredEntryEvidence(marshalWindowsLayeredEntryEvidenceForTest(t, value), plan) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.want)) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func windowsLayeredEntryEvidenceForTest() map[string]any { + return map[string]any{ + "schema_version": WindowsLayeredEntryEvidenceSchemaVersion, + "windows_release": "11", + "architecture": "amd64", + "handoff_zip_size_bytes": float64(1045000), + "handoff_zip_sha256": strings.Repeat("a", 64), + "selected_launcher": "cmd", + "fallback_attempts": []any{"powershell", "powershell-bypass", "cmd"}, + "core_start_count": float64(1), + "network_bytes": float64(2048), + "registration_duration_ms": float64(750), + "cache_hit": false, + "range_interrupted": true, + "range_resumed": true, + "range_bytes": float64(1024), + "private_acl": true, + "unc_rejected": true, + "reparse_rejected": true, + "defender_lock_verified": true, + "active_route_failed": true, + "route_reselected": true, + "registration_count": float64(1), + "session_identity_stable": true, + "event_cursor_stable": true, + "archive_recovery_executed": false, + "bootstrap_only": true, + "persistence_residue": []any{}, + "cleanup_complete": true, + } +} + +func marshalWindowsLayeredEntryEvidenceForTest(t *testing.T, value map[string]any) []byte { + t.Helper() + content, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return content +} diff --git a/internal/acceptance/windows_layered_entry_test.go b/internal/acceptance/windows_layered_entry_test.go new file mode 100644 index 0000000..e410514 --- /dev/null +++ b/internal/acceptance/windows_layered_entry_test.go @@ -0,0 +1,151 @@ +package acceptance + +import ( + "archive/zip" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +func TestRunWindowsTemporaryPlanUsesLayeredHandoffOnly(t *testing.T) { + archivePath := writeWindowsLayeredAcceptanceArchive(t) + outDir := filepath.Join(t.TempDir(), "acceptance") + plan, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ + OutDir: outDir, + HandoffArchivePath: archivePath, + Now: time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + if plan.HandoffArchiveSHA256 == "" || plan.HandoffArchiveSizeBytes <= 0 || plan.HandoffArchiveSizeBytes > 1<<20 { + t.Fatalf("invalid measured handoff summary: %#v", plan) + } + if plan.PowerShellLauncher != "Start-ConnectionEntry.ps1" || plan.CommandLauncher != "Start-ConnectionEntry.cmd" { + t.Fatalf("unexpected visible launchers: %#v", plan) + } + if !slices.Equal(plan.FallbackOrder, []string{"powershell", "powershell-bypass", "cmd"}) { + t.Fatalf("fallback order = %#v", plan.FallbackOrder) + } + content, err := os.ReadFile(filepath.Join(outDir, "windows-temporary-plan.json")) + if err != nil { + t.Fatal(err) + } + lower := strings.ToLower(string(content)) + for _, forbidden := range []string{"rdev-host.exe", "host_download_url", "ticket_code", "gateway_url", filepath.ToSlash(filepath.Dir(archivePath))} { + if strings.Contains(lower, strings.ToLower(forbidden)) { + t.Fatalf("layered acceptance plan leaked legacy/private value %q:\n%s", forbidden, content) + } + } + if !strings.Contains(lower, "rdev-bootstrap") { + t.Fatalf("layered acceptance plan omitted bootstrap boundary:\n%s", content) + } +} + +func TestPackageWindowsTemporaryEvidenceDoesNotPublishPrivateSourcePaths(t *testing.T) { + fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": true}`) + outDir := filepath.Join(fixture.root, "package") + pkg, err := PackageWindowsTemporaryEvidence(windowsTemporaryLayeredPackageOptions(fixture, outDir, fixture.coldLayeredRunPath, fixture.warmLayeredRunPath)) + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(outDir, "package.json")) + if err != nil { + t.Fatal(err) + } + public := string(content) + for _, privatePath := range []string{fixture.root, fixture.planPath, fixture.transcriptPath, fixture.releaseVerificationPath, fixture.auditPath} { + if strings.Contains(public, privatePath) { + t.Fatalf("public package leaked private source path %q:\n%s", privatePath, public) + } + } + if filepath.IsAbs(pkg.OutDir) || filepath.IsAbs(pkg.PlanPath) || filepath.IsAbs(pkg.PlanVerification.PlanPath) { + t.Fatalf("package result contains absolute public paths: %#v", pkg) + } + for _, file := range pkg.Files { + if file.Source != "" { + t.Fatalf("package file retained private source path: %#v", file) + } + } +} + +func TestRunWindowsTemporaryPlanRejectsOversizedUncompressedEntry(t *testing.T) { + archivePath := writeWindowsLayeredAcceptanceArchiveWithBootstrapSize(t, 9<<20) + _, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ + OutDir: filepath.Join(t.TempDir(), "acceptance"), + HandoffArchivePath: archivePath, + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "uncompressed") { + t.Fatalf("expected oversized uncompressed entry rejection, got %v", err) + } +} + +func TestRunWindowsTemporaryPlanRejectsLegacyHelperAfterLauncherScanPrefix(t *testing.T) { + powerShell := "& $PSScriptRoot\\rdev-bootstrap.exe layered-run\n# " + strings.Repeat("x", 1<<20) + "\nrdev-host.exe\n" + archivePath := writeWindowsLayeredAcceptanceArchiveWithPowerShell(t, powerShell) + _, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ + OutDir: filepath.Join(t.TempDir(), "acceptance"), + HandoffArchivePath: archivePath, + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "legacy") { + t.Fatalf("expected hidden legacy helper rejection, got %v", err) + } +} + +func writeWindowsLayeredAcceptanceArchive(t *testing.T) string { + return writeWindowsLayeredAcceptanceArchiveWithBootstrapSize(t, len("bootstrap fixture")) +} + +func writeWindowsLayeredAcceptanceArchiveWithBootstrapSize(t *testing.T, bootstrapSize int) string { + return writeWindowsLayeredAcceptanceArchiveFixture(t, bootstrapSize, "& $PSScriptRoot\\rdev-bootstrap.exe layered-run\n") +} + +func writeWindowsLayeredAcceptanceArchiveWithPowerShell(t *testing.T, powerShell string) string { + return writeWindowsLayeredAcceptanceArchiveFixture(t, len("bootstrap fixture"), powerShell) +} + +func writeWindowsLayeredAcceptanceArchiveFixture(t *testing.T, bootstrapSize int, powerShell string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "Windows-ConnectionEntry.zip") + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + writer := zip.NewWriter(file) + files := map[string]string{ + "Start-ConnectionEntry.ps1": powerShell, + "Start-ConnectionEntry.cmd": "@echo off\r\n%~dp0rdev-bootstrap.exe layered-run\r\n", + "rdev-bootstrap.exe": strings.Repeat("x", bootstrapSize), + "rdev-bootstrap.exe.rdev-release.json": "{}", + "rdev-bootstrap.exe.sha256": strings.Repeat("a", 64), + "windows-layered-verification.json": "{}", + "ARCHIVE-RECOVERY.txt": "rdev-bootstrap.exe layered-run\n", + } + for _, name := range []string{ + "Start-ConnectionEntry.ps1", + "Start-ConnectionEntry.cmd", + "rdev-bootstrap.exe", + "rdev-bootstrap.exe.rdev-release.json", + "rdev-bootstrap.exe.sha256", + "windows-layered-verification.json", + "ARCHIVE-RECOVERY.txt", + } { + entry, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte(files[name])); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/acceptance/windows_temporary.go b/internal/acceptance/windows_temporary.go index f090cfb..f2c8dcf 100644 --- a/internal/acceptance/windows_temporary.go +++ b/internal/acceptance/windows_temporary.go @@ -1,73 +1,77 @@ package acceptance import ( + "archive/zip" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" + "io" "os" "path/filepath" + "slices" "strings" "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd/windowsentry" ) const WindowsTemporaryPlanSchemaVersion = "rdev.acceptance.windows-temporary-plan.v1" +const maxWindowsTemporaryHandoffBytes int64 = 1 << 20 + +// The closed ZIP remains subject to the 1 MiB delivery gate. These separate +// limits also bound decompression work while allowing the signed PE bootstrap +// to be larger than its compressed representation. +const ( + maxWindowsTemporaryHandoffEntryBytes int64 = 8 << 20 + maxWindowsTemporaryHandoffExpandedBytes int64 = 16 << 20 +) + +const ( + windowsTemporaryColdLayeredRunEvidence = "cold-layered-run.json from a real clean Windows cold run with from_cache=false." + windowsTemporaryWarmLayeredRunEvidence = "warm-layered-run.json from the immediate cached Windows rerun with from_cache=true." +) + +func windowsTemporaryRequiredEvidence() []string { + return []string{ + "Measured Windows-ConnectionEntry.zip size and SHA-256 from the delivered handoff.", + "Visible launcher attempt order and exactly one rdev-bootstrap core-start transition.", + "Signed bootstrap and layered core verification output.", + "Session registration, route reselection, task, revoke, and cancellation audit events.", + "No-persistence inspection output for services, scheduled tasks, Run keys, startup folders, and firewall rules.", + windowsTemporaryColdLayeredRunEvidence, + windowsTemporaryWarmLayeredRunEvidence, + } +} + type WindowsTemporaryOptions struct { - OutDir string - GatewayURL string - TicketCode string - DownloadURL string - ExpectedSHA256 string - BootstrapScriptPath string - BootstrapScriptURL string - BootstrapScriptExpectedSHA256 string - ManifestURL string - ManifestRootPublicKey string - ReleaseManifestURL string - ReleaseBundleURL string - ReleaseBundleRequiredArtifacts string - ReleaseRootPublicKey string - VerifierDownloadURL string - VerifierExpectedSHA256 string - TrustPin string - HostName string - // MaxRetries is the number of re-registration attempts after an unexpected - // host exit. Zero means use the bootstrap script default (5). - MaxRetries int - Force bool - Now time.Time + OutDir string + HandoffArchivePath string + Force bool + Now time.Time } type WindowsTemporaryPlan struct { - SchemaVersion string `json:"schema_version"` - GeneratedAt time.Time `json:"generated_at"` - Platform string `json:"platform"` - OutDir string `json:"out_dir"` - BootstrapScriptPath string `json:"bootstrap_script_path,omitempty"` - BootstrapScriptURL string `json:"bootstrap_script_url,omitempty"` - BootstrapScriptSHA256 string `json:"bootstrap_script_sha256,omitempty"` - LauncherPath string `json:"launcher_path"` - GatewayURL string `json:"gateway_url"` - TicketCode string `json:"ticket_code"` - ManifestURL string `json:"manifest_url,omitempty"` - ManifestRootPublicKey string `json:"manifest_root_public_key,omitempty"` - ReleaseManifestURL string `json:"release_manifest_url,omitempty"` - ReleaseBundleURL string `json:"release_bundle_url,omitempty"` - ReleaseBundleRequiredArtifacts string `json:"release_bundle_required_artifacts,omitempty"` - ReleaseRootPublicKey string `json:"release_root_public_key,omitempty"` - VerifierDownloadURL string `json:"verifier_download_url,omitempty"` - VerifierExpectedSHA256 string `json:"verifier_expected_sha256,omitempty"` - TrustPin string `json:"trust_pin,omitempty"` - HostName string `json:"host_name,omitempty"` - HostDownloadURL string `json:"host_download_url"` - HostExpectedSHA256 string `json:"host_expected_sha256"` - Checks []Check `json:"checks"` - Commands []WindowsAcceptanceCommand `json:"commands"` - NoPersistenceChecks []WindowsAcceptanceCommand `json:"no_persistence_checks"` - DenialProbes []WindowsDenialProbe `json:"denial_probes"` - RecommendedActions []string `json:"recommended_actions"` - RequiredEvidence []string `json:"required_evidence"` + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Platform string `json:"platform"` + OutDir string `json:"-"` + HandoffArchivePath string `json:"handoff_archive_path"` + HandoffArchiveSHA256 string `json:"handoff_archive_sha256"` + HandoffArchiveSizeBytes int64 `json:"handoff_archive_size_bytes"` + PowerShellLauncher string `json:"powershell_launcher"` + CommandLauncher string `json:"command_launcher"` + PreferredLauncher string `json:"preferred_launcher"` + FallbackOrder []string `json:"fallback_order"` + BootstrapCommand string `json:"bootstrap_command"` + ArchiveRecoveryAutomatic bool `json:"archive_recovery_automatic"` + Checks []Check `json:"checks"` + Commands []WindowsAcceptanceCommand `json:"commands"` + NoPersistenceChecks []WindowsAcceptanceCommand `json:"no_persistence_checks"` + DenialProbes []WindowsDenialProbe `json:"denial_probes"` + RecommendedActions []string `json:"recommended_actions"` + RequiredEvidence []string `json:"required_evidence"` } type WindowsAcceptanceCommand struct { @@ -99,169 +103,75 @@ func RunWindowsTemporaryPlan(opts WindowsTemporaryOptions) (WindowsTemporaryPlan if err := prepareAcceptanceOut(outDir); err != nil { return WindowsTemporaryPlan{}, err } - resolved, err := resolveWindowsTemporaryOptions(outDir, opts) + sourceArchive, archiveContent, archiveSHA256, archiveSize, err := inspectWindowsLayeredAcceptanceArchive(opts.HandoffArchivePath) if err != nil { return WindowsTemporaryPlan{}, err } - launcher := windowsTemporaryLauncher(resolved) - if err := writeAcceptanceFile(resolved.LauncherPath, []byte(launcher), opts.Force); err != nil { + archivePath := filepath.Join(outDir, "Windows-ConnectionEntry.zip") + if err := writeAcceptanceFile(archivePath, archiveContent, opts.Force); err != nil { return WindowsTemporaryPlan{}, err } plan := WindowsTemporaryPlan{ - SchemaVersion: WindowsTemporaryPlanSchemaVersion, - GeneratedAt: now.UTC(), - Platform: "windows", - OutDir: outDir, - BootstrapScriptPath: resolved.BootstrapScriptPath, - BootstrapScriptURL: resolved.BootstrapScriptURL, - BootstrapScriptSHA256: resolved.BootstrapScriptExpectedSHA256, - LauncherPath: resolved.LauncherPath, - GatewayURL: resolved.GatewayURL, - TicketCode: resolved.TicketCode, - ManifestURL: resolved.ManifestURL, - ManifestRootPublicKey: resolved.ManifestRootPublicKey, - ReleaseManifestURL: resolved.ReleaseManifestURL, - ReleaseBundleURL: resolved.ReleaseBundleURL, - ReleaseBundleRequiredArtifacts: resolved.ReleaseBundleRequiredArtifacts, - ReleaseRootPublicKey: resolved.ReleaseRootPublicKey, - VerifierDownloadURL: resolved.VerifierDownloadURL, - VerifierExpectedSHA256: resolved.VerifierExpectedSHA256, - TrustPin: resolved.TrustPin, - HostName: resolved.HostName, - HostDownloadURL: resolved.DownloadURL, - HostExpectedSHA256: resolved.ExpectedSHA256, - Commands: windowsTemporaryCommands(resolved), - NoPersistenceChecks: windowsNoPersistenceChecks(), - DenialProbes: windowsDenialProbes(), + SchemaVersion: WindowsTemporaryPlanSchemaVersion, + GeneratedAt: now.UTC(), + Platform: "windows/amd64", + OutDir: outDir, + HandoffArchivePath: filepath.Base(archivePath), + HandoffArchiveSHA256: archiveSHA256, + HandoffArchiveSizeBytes: archiveSize, + PowerShellLauncher: "Start-ConnectionEntry.ps1", + CommandLauncher: "Start-ConnectionEntry.cmd", + PreferredLauncher: "powershell", + FallbackOrder: []string{"powershell", "powershell-bypass", "cmd"}, + BootstrapCommand: "rdev-bootstrap layered-run", + ArchiveRecoveryAutomatic: false, + Commands: windowsTemporaryCommands(), + NoPersistenceChecks: windowsNoPersistenceChecks(), + DenialProbes: windowsDenialProbes(), RecommendedActions: []string{ - "Review the generated PowerShell launcher and bootstrap script before using them on a Windows host.", - "Run the launcher in a clean Windows 10/11 VM or target support host as a visible foreground session.", + "Verify the delivered archive digest and inspect both visible launchers before the Windows run.", + "Extract the private handoff on a clean Windows 10/11 host and start with the visible PowerShell launcher.", "Accept only the scoped temporary endpoint expected by this session.", "Run bounded diagnostic and repair tasks, then collect evidence and audit exports.", "Revoke the temporary host and run every no-persistence check before publishing the transcript.", }, - RequiredEvidence: []string{ - "PowerShell transcript for bootstrap and foreground host startup.", - "SHA-256 verification output for the bootstrap script, verifier, and rdev-host binary.", - "Signed release manifest or release bundle verification output from rdev-verify.", - "Session join, endpoint trust, task, host-denial probe, revoke, and cancellation audit events.", - "No-persistence inspection output for services, scheduled tasks, Run keys, startup folders, and firewall rules.", - }, + RequiredEvidence: windowsTemporaryRequiredEvidence(), } - plan.Checks = windowsTemporaryChecks(plan, resolved) + plan.Checks = windowsTemporaryChecks(plan, sourceArchive) if err := writeWindowsTemporaryPlan(filepath.Join(outDir, "windows-temporary-plan.json"), plan); err != nil { return WindowsTemporaryPlan{}, err } return plan, nil } -type windowsTemporaryResolvedOptions struct { - OutDir string - LauncherPath string - GatewayURL string - TicketCode string - DownloadURL string - ExpectedSHA256 string - BootstrapScriptPath string - BootstrapScriptURL string - BootstrapScriptExpectedSHA256 string - ManifestURL string - ManifestRootPublicKey string - ReleaseManifestURL string - ReleaseBundleURL string - ReleaseBundleRequiredArtifacts string - ReleaseRootPublicKey string - VerifierDownloadURL string - VerifierExpectedSHA256 string - TrustPin string - HostName string - MaxRetries int -} - -func resolveWindowsTemporaryOptions(outDir string, opts WindowsTemporaryOptions) (windowsTemporaryResolvedOptions, error) { - bootstrapScriptPath := strings.TrimSpace(opts.BootstrapScriptPath) - if bootstrapScriptPath == "" { - bootstrapScriptPath = filepath.Join("scripts", "bootstrap", "windows-temporary.ps1") - } - if bootstrapScriptPath != "" && !filepath.IsAbs(bootstrapScriptPath) { - abs, err := filepath.Abs(bootstrapScriptPath) - if err != nil { - return windowsTemporaryResolvedOptions{}, err - } - bootstrapScriptPath = abs - } - bootstrapHash := strings.TrimSpace(opts.BootstrapScriptExpectedSHA256) - if bootstrapHash == "" && strings.TrimSpace(bootstrapScriptPath) != "" { - if hash, err := fileSHA256(bootstrapScriptPath); err == nil { - bootstrapHash = hash - } - } - releaseBundleURL := strings.TrimSpace(opts.ReleaseBundleURL) - releaseBundleRequiredArtifacts := strings.TrimSpace(opts.ReleaseBundleRequiredArtifacts) - if releaseBundleURL == "" { - releaseBundleRequiredArtifacts = "" - } else if releaseBundleRequiredArtifacts == "" { - releaseBundleRequiredArtifacts = "rdev-host.exe,rdev-verify.exe" - } - return windowsTemporaryResolvedOptions{ - OutDir: outDir, - LauncherPath: filepath.Join(outDir, "run-windows-temporary.ps1"), - GatewayURL: strings.TrimSpace(opts.GatewayURL), - TicketCode: strings.TrimSpace(opts.TicketCode), - DownloadURL: strings.TrimSpace(opts.DownloadURL), - ExpectedSHA256: strings.ToLower(strings.TrimSpace(opts.ExpectedSHA256)), - BootstrapScriptPath: bootstrapScriptPath, - BootstrapScriptURL: strings.TrimSpace(opts.BootstrapScriptURL), - BootstrapScriptExpectedSHA256: strings.ToLower(bootstrapHash), - ManifestURL: strings.TrimSpace(opts.ManifestURL), - ManifestRootPublicKey: strings.TrimSpace(opts.ManifestRootPublicKey), - ReleaseManifestURL: strings.TrimSpace(opts.ReleaseManifestURL), - ReleaseBundleURL: releaseBundleURL, - ReleaseBundleRequiredArtifacts: releaseBundleRequiredArtifacts, - ReleaseRootPublicKey: strings.TrimSpace(opts.ReleaseRootPublicKey), - VerifierDownloadURL: strings.TrimSpace(opts.VerifierDownloadURL), - VerifierExpectedSHA256: strings.ToLower(strings.TrimSpace(opts.VerifierExpectedSHA256)), - TrustPin: strings.TrimSpace(opts.TrustPin), - HostName: strings.TrimSpace(opts.HostName), - MaxRetries: opts.MaxRetries, - }, nil -} - -func windowsTemporaryChecks(plan WindowsTemporaryPlan, opts windowsTemporaryResolvedOptions) []Check { - _, scriptErr := os.Stat(opts.BootstrapScriptPath) +func windowsTemporaryChecks(plan WindowsTemporaryPlan, sourceArchive string) []Check { return []Check{ - {Name: "launcher_written", Passed: pathExists(plan.LauncherPath), Detail: plan.LauncherPath}, - {Name: "bootstrap_script_available", Passed: scriptErr == nil || opts.BootstrapScriptURL != "", Detail: firstNonEmptyString(opts.BootstrapScriptURL, opts.BootstrapScriptPath)}, - {Name: "bootstrap_script_hash_available", Passed: opts.BootstrapScriptExpectedSHA256 != "", Detail: opts.BootstrapScriptExpectedSHA256}, - {Name: "gateway_url", Passed: opts.GatewayURL != "", Detail: opts.GatewayURL}, - {Name: "ticket_code", Passed: opts.TicketCode != "", Detail: opts.TicketCode}, - {Name: "host_download_url", Passed: opts.DownloadURL != "", Detail: opts.DownloadURL}, - {Name: "host_sha256", Passed: isHexSHA256(opts.ExpectedSHA256), Detail: opts.ExpectedSHA256}, - {Name: "release_manifest_or_bundle_url", Passed: opts.ReleaseManifestURL != "" || opts.ReleaseBundleURL != "", Detail: firstNonEmptyString(opts.ReleaseBundleURL, opts.ReleaseManifestURL)}, - {Name: "release_bundle_required_artifacts", Passed: opts.ReleaseBundleURL == "" || opts.ReleaseBundleRequiredArtifacts != "", Detail: opts.ReleaseBundleRequiredArtifacts}, - {Name: "release_root_public_key", Passed: opts.ReleaseRootPublicKey != ""}, - {Name: "verifier_download_url", Passed: opts.VerifierDownloadURL != "", Detail: opts.VerifierDownloadURL}, - {Name: "verifier_sha256", Passed: isHexSHA256(opts.VerifierExpectedSHA256), Detail: opts.VerifierExpectedSHA256}, + {Name: "handoff_archive_staged", Passed: sourceArchive != "", Detail: filepath.Base(plan.HandoffArchivePath)}, + {Name: "handoff_archive_sha256", Passed: isHexSHA256(plan.HandoffArchiveSHA256), Detail: plan.HandoffArchiveSHA256}, + {Name: "handoff_archive_size", Passed: plan.HandoffArchiveSizeBytes > 0 && plan.HandoffArchiveSizeBytes <= maxWindowsTemporaryHandoffBytes, Detail: fmt.Sprintf("%d", plan.HandoffArchiveSizeBytes)}, + {Name: "powershell_preferred", Passed: plan.PreferredLauncher == "powershell" && plan.PowerShellLauncher == "Start-ConnectionEntry.ps1", Detail: plan.PreferredLauncher}, + {Name: "fallback_order", Passed: slices.Equal(plan.FallbackOrder, []string{"powershell", "powershell-bypass", "cmd"}), Detail: strings.Join(plan.FallbackOrder, ",")}, + {Name: "bootstrap_only", Passed: plan.BootstrapCommand == "rdev-bootstrap layered-run", Detail: plan.BootstrapCommand}, + {Name: "archive_recovery_not_automatic", Passed: !plan.ArchiveRecoveryAutomatic}, {Name: "no_persistence_checks_present", Passed: len(plan.NoPersistenceChecks) >= 5}, {Name: "denial_probes_present", Passed: len(plan.DenialProbes) >= 4}, } } -func windowsTemporaryCommands(opts windowsTemporaryResolvedOptions) []WindowsAcceptanceCommand { - launcherCommand := "powershell.exe -NoProfile -File " + powershellQuote(opts.LauncherPath) +func windowsTemporaryCommands() []WindowsAcceptanceCommand { return []WindowsAcceptanceCommand{ { - Name: "review_launcher", - Purpose: "Inspect the generated launcher before sending or running it.", - Shell: "Get-Content -LiteralPath " + powershellQuote(opts.LauncherPath), - Argv: []string{"powershell.exe", "-NoProfile", "-Command", "Get-Content -LiteralPath " + powershellQuote(opts.LauncherPath)}, + Name: "review_handoff", + Purpose: "Verify the delivered ZIP digest and inspect both visible launchers before execution.", + Shell: "Get-FileHash -Algorithm SHA256 -LiteralPath '.\\Windows-ConnectionEntry.zip'", + Argv: []string{"powershell.exe", "-NoProfile", "-Command", "Get-FileHash -Algorithm SHA256 -LiteralPath '.\\Windows-ConnectionEntry.zip'"}, Manual: true, }, { Name: "run_foreground_temporary_host", - Purpose: "Run the attended temporary host bootstrap as a visible foreground session.", - Shell: launcherCommand, - Argv: []string{"powershell.exe", "-NoProfile", "-File", opts.LauncherPath}, + Purpose: "Extract the private handoff and run its preferred visible PowerShell entry.", + Shell: "powershell.exe -NoProfile -File '.\\Start-ConnectionEntry.ps1'", + Argv: []string{"powershell.exe", "-NoProfile", "-File", ".\\Start-ConnectionEntry.ps1"}, Manual: true, }, { @@ -279,18 +189,101 @@ func windowsTemporaryCommands(opts windowsTemporaryResolvedOptions) []WindowsAcc } } +func inspectWindowsLayeredAcceptanceArchive(path string) (string, []byte, string, int64, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", nil, "", 0, fmt.Errorf("Windows layered handoff archive is required") + } + abs, err := filepath.Abs(path) + if err != nil { + return "", nil, "", 0, err + } + info, err := os.Lstat(abs) + if err != nil { + return "", nil, "", 0, err + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsTemporaryHandoffBytes { + return "", nil, "", 0, fmt.Errorf("Windows layered handoff archive must be a regular file at or below %d bytes", maxWindowsTemporaryHandoffBytes) + } + reader, err := zip.OpenReader(abs) + if err != nil { + return "", nil, "", 0, err + } + defer reader.Close() + required := map[string]bool{ + "Start-ConnectionEntry.ps1": false, + "Start-ConnectionEntry.cmd": false, + "rdev-bootstrap.exe": false, + "rdev-bootstrap.exe.rdev-release.json": false, + "rdev-bootstrap.exe.sha256": false, + "windows-layered-verification.json": false, + "ARCHIVE-RECOVERY.txt": false, + } + seen := make(map[string]bool, len(reader.File)) + var expandedSize int64 + for _, file := range reader.File { + if file.Name != filepath.Base(file.Name) || seen[strings.ToLower(file.Name)] { + return "", nil, "", 0, fmt.Errorf("Windows layered handoff contains an unsafe or duplicate entry") + } + if !file.Mode().IsRegular() || file.UncompressedSize64 == 0 || file.UncompressedSize64 > uint64(maxWindowsTemporaryHandoffEntryBytes) { + return "", nil, "", 0, fmt.Errorf("Windows layered handoff contains an entry above the uncompressed size limit") + } + if file.UncompressedSize64 > uint64(maxWindowsTemporaryHandoffExpandedBytes)-uint64(expandedSize) { + return "", nil, "", 0, fmt.Errorf("Windows layered handoff exceeds the uncompressed size limit") + } + expandedSize += int64(file.UncompressedSize64) + seen[strings.ToLower(file.Name)] = true + if _, ok := required[file.Name]; !ok { + return "", nil, "", 0, fmt.Errorf("Windows layered handoff contains unexpected entry %q", file.Name) + } + required[file.Name] = true + entry, err := file.Open() + if err != nil { + return "", nil, "", 0, err + } + content, readErr := io.ReadAll(io.LimitReader(entry, maxWindowsTemporaryHandoffEntryBytes+1)) + closeErr := entry.Close() + if readErr != nil || closeErr != nil || uint64(len(content)) != file.UncompressedSize64 { + return "", nil, "", 0, fmt.Errorf("read and verify Windows layered handoff entry") + } + if file.Name != "Start-ConnectionEntry.ps1" && file.Name != "Start-ConnectionEntry.cmd" { + continue + } + lower := strings.ToLower(string(content)) + if !strings.Contains(lower, "rdev-bootstrap") { + return "", nil, "", 0, fmt.Errorf("Windows layered launcher %q does not use rdev-bootstrap", file.Name) + } + for _, forbidden := range []string{"rdev host serve", "rdev-host.exe", "rdev-bootstrap upgrade", "get-command rdev"} { + if strings.Contains(lower, forbidden) { + return "", nil, "", 0, fmt.Errorf("Windows layered launcher %q contains a legacy helper path", file.Name) + } + } + } + for name, present := range required { + if !present { + return "", nil, "", 0, fmt.Errorf("Windows layered handoff is missing %q", name) + } + } + content, err := os.ReadFile(abs) + if err != nil { + return "", nil, "", 0, err + } + digest := sha256.Sum256(content) + return abs, content, hex.EncodeToString(digest[:]), int64(len(content)), nil +} + func windowsNoPersistenceChecks() []WindowsAcceptanceCommand { return []WindowsAcceptanceCommand{ { Name: "services", Purpose: "Confirm temporary mode did not install a Windows Service.", - Shell: "Get-Service | Where-Object { $_.Name -match 'rdev|remote-dev' -or $_.DisplayName -match 'rdev|Remote Dev' } | Select-Object Name, Status, StartType, DisplayName", + Shell: "Get-Service | Where-Object { $_.Name -match '(^|[-_.])rdev($|[-_.])|remote[- ]?dev' -or $_.DisplayName -match '(^|[-_. ])rdev($|[-_. ])|remote[- ]?dev' } | Select-Object Name, Status, StartType, DisplayName", Manual: true, }, { Name: "scheduled_tasks", Purpose: "Confirm temporary mode did not create scheduled tasks.", - Shell: "Get-ScheduledTask | Where-Object { $_.TaskName -match 'rdev|remote-dev' -or $_.TaskPath -match 'rdev|remote-dev' } | Select-Object TaskPath, TaskName, State", + Shell: "Get-ScheduledTask | Where-Object { $_.TaskName -match '(^|[-_.])rdev($|[-_.])|remote[- ]?dev' -or $_.TaskPath -match '(^|[-_.\\])rdev($|[-_.\\])|remote[- ]?dev' } | Select-Object TaskPath, TaskName, State", Manual: true, }, { @@ -308,13 +301,13 @@ func windowsNoPersistenceChecks() []WindowsAcceptanceCommand { { Name: "startup_folders", Purpose: "Confirm temporary mode did not add startup-folder shortcuts or scripts.", - Shell: "Get-ChildItem \"$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\", \"$env:ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\StartUp\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'rdev|remote-dev' }", + Shell: "Get-ChildItem \"$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\", \"$env:ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\StartUp\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(^|[-_.])rdev($|[-_.])|remote[- ]?dev' }", Manual: true, }, { Name: "firewall_rules", Purpose: "Confirm temporary mode did not add firewall rules.", - Shell: "Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -match 'rdev|Remote Dev' -or $_.Name -match 'rdev|remote-dev' } | Select-Object DisplayName, Enabled, Direction, Action", + Shell: "Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -match '(^|[-_. ])rdev($|[-_. ])|remote[- ]?dev' -or $_.Name -match '(^|[-_.])rdev($|[-_.])|remote[- ]?dev' } | Select-Object DisplayName, Enabled, Direction, Action", Manual: true, }, } @@ -330,70 +323,6 @@ func windowsDenialProbes() []WindowsDenialProbe { } } -func windowsTemporaryLauncher(opts windowsTemporaryResolvedOptions) string { - var builder strings.Builder - builder.WriteString("# Generated by rdev acceptance windows-temporary.\n") - builder.WriteString("# Inspect this file before running it on a Windows host.\n") - builder.WriteString("$ErrorActionPreference = 'Stop'\n") - builder.WriteString("$bootstrap = ") - if opts.BootstrapScriptURL != "" { - builder.WriteString("Join-Path $env:TEMP 'rdev-windows-temporary.ps1'\n") - builder.WriteString("Invoke-WebRequest -Uri ") - builder.WriteString(powershellQuote(opts.BootstrapScriptURL)) - builder.WriteString(" -OutFile $bootstrap -UseBasicParsing\n") - if opts.BootstrapScriptExpectedSHA256 != "" { - builder.WriteString("$actualBootstrap = (Get-FileHash -Algorithm SHA256 -Path $bootstrap).Hash.ToLowerInvariant()\n") - builder.WriteString("$expectedBootstrap = ") - builder.WriteString(powershellQuote(opts.BootstrapScriptExpectedSHA256)) - builder.WriteString("\n") - builder.WriteString("if ($actualBootstrap -ne $expectedBootstrap) { throw \"bootstrap SHA256 mismatch expected=$expectedBootstrap actual=$actualBootstrap\" }\n") - } - } else { - builder.WriteString(powershellQuote(opts.BootstrapScriptPath)) - builder.WriteString("\n") - } - builder.WriteString("& $bootstrap") - appendPowerShellArg(&builder, "GatewayUrl", opts.GatewayURL) - appendPowerShellArg(&builder, "TicketCode", opts.TicketCode) - appendPowerShellArg(&builder, "DownloadUrl", opts.DownloadURL) - appendPowerShellArg(&builder, "ExpectedSha256", opts.ExpectedSHA256) - appendPowerShellArg(&builder, "ManifestUrl", opts.ManifestURL) - appendPowerShellArg(&builder, "ManifestRootPublicKey", opts.ManifestRootPublicKey) - appendPowerShellArg(&builder, "ReleaseManifestUrl", opts.ReleaseManifestURL) - appendPowerShellArg(&builder, "ReleaseBundleUrl", opts.ReleaseBundleURL) - appendPowerShellArg(&builder, "ReleaseBundleRequiredArtifacts", opts.ReleaseBundleRequiredArtifacts) - appendPowerShellArg(&builder, "ReleaseRootPublicKey", opts.ReleaseRootPublicKey) - appendPowerShellArg(&builder, "VerifierDownloadUrl", opts.VerifierDownloadURL) - appendPowerShellArg(&builder, "VerifierExpectedSha256", opts.VerifierExpectedSHA256) - appendPowerShellArg(&builder, "TrustPin", opts.TrustPin) - appendPowerShellArg(&builder, "HostName", opts.HostName) - if opts.MaxRetries > 0 { - appendPowerShellIntArg(&builder, "MaxRetries", opts.MaxRetries) - } - builder.WriteString("\n") - return builder.String() -} - -func appendPowerShellArg(builder *strings.Builder, name string, value string) { - if value == "" { - return - } - builder.WriteString(" `\n -") - builder.WriteString(name) - builder.WriteByte(' ') - builder.WriteString(powershellQuote(value)) -} - -func appendPowerShellIntArg(builder *strings.Builder, name string, value int) { - if value == 0 { - return - } - builder.WriteString(" `\n -") - builder.WriteString(name) - builder.WriteByte(' ') - builder.WriteString(fmt.Sprintf("%d", value)) -} - func powershellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } @@ -404,7 +333,10 @@ func writeWindowsTemporaryPlan(path string, plan WindowsTemporaryPlan) error { return err } content = append(content, '\n') - return os.WriteFile(path, content, 0o600) + if err := os.WriteFile(path, content, 0o600); err != nil { + return err + } + return windowsentry.ProtectPrivatePath(path, false) } func fileSHA256(path string) (string, error) { diff --git a/internal/acceptance/windows_temporary_package.go b/internal/acceptance/windows_temporary_package.go index 1388aa4..9e45772 100644 --- a/internal/acceptance/windows_temporary_package.go +++ b/internal/acceptance/windows_temporary_package.go @@ -4,28 +4,34 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "sort" "strings" "time" + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd/windowsentry" "github.com/EitanWong/remote-dev-skillkit/internal/shelladapter" ) const WindowsTemporaryPackageSchemaVersion = "rdev.acceptance-package.windows-temporary.v1" type WindowsTemporaryPackageOptions struct { - PlanPath string - OutDir string - TranscriptPath string - ReleaseVerificationPath string - AuditPath string - NoPersistenceDir string - DenialProbesDir string - NotesPath string - Now time.Time + PlanPath string + OutDir string + TranscriptPath string + ReleaseVerificationPath string + AuditPath string + NoPersistenceDir string + DenialProbesDir string + NotesPath string + ColdLayeredRunPath string + WarmLayeredRunPath string + LayeredEntryEvidencePath string + Now time.Time } type WindowsTemporaryPackage struct { @@ -73,9 +79,6 @@ func PackageWindowsTemporaryEvidence(opts WindowsTemporaryPackageOptions) (Windo if err != nil { return WindowsTemporaryPackage{}, err } - if err := prepareAcceptanceOut(outDir); err != nil { - return WindowsTemporaryPackage{}, err - } planPath, err := filepath.Abs(opts.PlanPath) if err != nil { return WindowsTemporaryPackage{}, err @@ -88,6 +91,12 @@ func PackageWindowsTemporaryEvidence(opts WindowsTemporaryPackageOptions) (Windo if err != nil { return WindowsTemporaryPackage{}, err } + if !verification.OK() { + return WindowsTemporaryPackage{}, fmt.Errorf("Windows temporary plan verification failed") + } + if err := prepareAcceptanceOut(outDir); err != nil { + return WindowsTemporaryPackage{}, err + } now := opts.Now if now.IsZero() { now = time.Now() @@ -95,12 +104,13 @@ func PackageWindowsTemporaryEvidence(opts WindowsTemporaryPackageOptions) (Windo pkg := WindowsTemporaryPackage{ SchemaVersion: WindowsTemporaryPackageSchemaVersion, GeneratedAt: now.UTC(), - OutDir: outDir, - PlanPath: planPath, + OutDir: ".", + PlanPath: "plan/windows-temporary-plan.json", PlanSchema: plan.SchemaVersion, PlanVerification: verification, RequiredEvidence: plan.RequiredEvidence, } + pkg.PlanVerification.PlanPath = pkg.PlanPath add := func(name string, passed bool, detail string) { pkg.Checks = append(pkg.Checks, Check{Name: name, Passed: passed, Detail: detail}) } @@ -109,33 +119,34 @@ func PackageWindowsTemporaryEvidence(opts WindowsTemporaryPackageOptions) (Windo var files []WindowsTemporaryPackageFile if entry, err := copyPackageFile(outDir, "plan/windows-temporary-plan.json", "plan", planPath, nil); err != nil { - return WindowsTemporaryPackage{}, err - } else { - files = append(files, entry) - } - launcherPath := resolvePlanPath(filepath.Dir(planPath), plan.LauncherPath, "run-windows-temporary.ps1") - if entry, err := copyPackageFile(outDir, "plan/run-windows-temporary.ps1", "launcher", launcherPath, nil); err != nil { - add("launcher_copied", false, err.Error()) + return WindowsTemporaryPackage{}, fmt.Errorf("copy Windows temporary plan") } else { files = append(files, entry) - add("launcher_copied", true, entry.Path) } + archivePath := filepath.Join(filepath.Dir(planPath), "Windows-ConnectionEntry.zip") + _, _, archiveSHA256, archiveSize, archiveErr := inspectWindowsLayeredAcceptanceArchive(archivePath) + add("handoff_archive_verified_not_packaged", + archiveErr == nil && strings.EqualFold(archiveSHA256, plan.HandoffArchiveSHA256) && archiveSize == plan.HandoffArchiveSizeBytes, + filepath.Base(archivePath), + ) files = append(files, copyOptionalEvidence(outDir, "evidence/transcript.txt", "transcript", opts.TranscriptPath, redactor, add)...) files = append(files, copyOptionalEvidence(outDir, "evidence/release-verification.txt", "release-verification", opts.ReleaseVerificationPath, redactor, add)...) files = append(files, copyOptionalEvidence(outDir, "evidence/audit.txt", "audit", opts.AuditPath, redactor, add)...) files = append(files, copyNotesEvidence(outDir, opts.NotesPath, redactor, add)...) + files = append(files, copyWindowsLayeredRunEvidencePair(outDir, opts.ColdLayeredRunPath, opts.WarmLayeredRunPath, add)...) + files = append(files, copyWindowsLayeredEntryEvidence(outDir, opts.LayeredEntryEvidencePath, plan, add)...) noPersistenceFiles, noPersistenceNames := copyEvidenceDir(outDir, "evidence/no-persistence", "no-persistence", opts.NoPersistenceDir, redactor, add) files = append(files, noPersistenceFiles...) denialFiles, denialNames := copyEvidenceDir(outDir, "evidence/denial-probes", "denial-probe", opts.DenialProbesDir, redactor, add) files = append(files, denialFiles...) - add("transcript_present", fileEntryKindPresent(files, "transcript"), opts.TranscriptPath) + add("transcript_present", fileEntryKindPresent(files, "transcript"), layeredRunPresenceDetail(strings.TrimSpace(opts.TranscriptPath) != "")) releaseOK := releaseVerificationOK(outDir, "evidence/release-verification.txt") - add("release_verification_present", fileEntryKindPresent(files, "release-verification"), opts.ReleaseVerificationPath) - add("release_verification_ok", releaseOK, opts.ReleaseVerificationPath) - add("audit_present", fileEntryKindPresent(files, "audit"), opts.AuditPath) + add("release_verification_present", fileEntryKindPresent(files, "release-verification"), layeredRunPresenceDetail(strings.TrimSpace(opts.ReleaseVerificationPath) != "")) + add("release_verification_ok", releaseOK, layeredRunPresenceDetail(strings.TrimSpace(opts.ReleaseVerificationPath) != "")) + add("audit_present", fileEntryKindPresent(files, "audit"), layeredRunPresenceDetail(strings.TrimSpace(opts.AuditPath) != "")) add("no_persistence_evidence_complete", namesCoverWindowsCommands(noPersistenceNames, plan.NoPersistenceChecks), missingWindowsEvidenceNames(noPersistenceNames, windowsCommandNames(plan.NoPersistenceChecks))) add("denial_probe_evidence_complete", namesCoverDenialProbes(denialNames, plan.DenialProbes), missingDenialEvidenceNames(denialNames, plan.DenialProbes)) @@ -169,16 +180,38 @@ func PackageWindowsTemporaryEvidence(opts WindowsTemporaryPackageOptions) (Windo return pkg, nil } +func copyWindowsLayeredEntryEvidence(root, source string, plan WindowsTemporaryPlan, add func(string, bool, string)) []WindowsTemporaryPackageFile { + if strings.TrimSpace(source) == "" { + add("layered_entry_evidence_present", false, "missing") + add("layered_entry_evidence_valid", false, "missing") + return nil + } + content, err := readWindowsLayeredRunReport(source) + if err != nil { + add("layered_entry_evidence_present", false, layeredRunValidationDetail(err)) + add("layered_entry_evidence_valid", false, layeredRunValidationDetail(err)) + return nil + } + add("layered_entry_evidence_present", true, "provided") + if err := validateWindowsLayeredEntryEvidence(content, plan); err != nil { + add("layered_entry_evidence_valid", false, err.Error()) + return nil + } + entry, err := writePackageContent(root, "evidence/layered-entry-evidence.json", "layered-entry-evidence", append(content, '\n'), "") + if err != nil { + add("layered_entry_evidence_valid", false, "evidence could not be copied") + return nil + } + add("layered_entry_evidence_valid", true, "validated") + return []WindowsTemporaryPackageFile{entry} +} + func readWindowsTemporaryPlan(path string) (WindowsTemporaryPlan, error) { content, err := os.ReadFile(path) if err != nil { return WindowsTemporaryPlan{}, err } - var plan WindowsTemporaryPlan - if err := json.Unmarshal(content, &plan); err != nil { - return WindowsTemporaryPlan{}, err - } - return plan, nil + return decodeWindowsTemporaryPlan(content) } func copyOptionalEvidence(root, bundlePath, kind, source string, redactor *shelladapter.ArtifactRedactor, add func(string, bool, string)) []WindowsTemporaryPackageFile { @@ -188,7 +221,7 @@ func copyOptionalEvidence(root, bundlePath, kind, source string, redactor *shell } entry, err := copyPackageFile(root, bundlePath, kind, source, redactor) if err != nil { - add(kind+"_copied", false, err.Error()) + add(kind+"_copied", false, packageEvidenceErrorDetail(err)) return nil } add(kind+"_copied", true, entry.Path) @@ -201,13 +234,105 @@ func copyNotesEvidence(root, source string, redactor *shelladapter.ArtifactRedac } entry, err := copyPackageFile(root, "evidence/notes.txt", "notes", source, redactor) if err != nil { - add("notes_copied", false, err.Error()) + add("notes_copied", false, packageEvidenceErrorDetail(err)) return nil } add("notes_copied", true, entry.Path) return []WindowsTemporaryPackageFile{entry} } +func copyWindowsLayeredRunEvidencePair(root, coldSource, warmSource string, add func(string, bool, string)) []WindowsTemporaryPackageFile { + coldPresent := strings.TrimSpace(coldSource) != "" + warmPresent := strings.TrimSpace(warmSource) != "" + add("cold_layered_run_present", coldPresent, layeredRunPresenceDetail(coldPresent)) + add("warm_layered_run_present", warmPresent, layeredRunPresenceDetail(warmPresent)) + + coldContent, coldReport, coldErr := readAndValidateWindowsLayeredRunReport(coldSource, coldPresent, false) + warmContent, warmReport, warmErr := readAndValidateWindowsLayeredRunReport(warmSource, warmPresent, true) + if coldErr != nil || warmErr != nil { + add("cold_layered_run_valid", coldErr == nil, layeredRunValidationDetail(coldErr)) + add("warm_layered_run_valid", warmErr == nil, layeredRunValidationDetail(warmErr)) + add("layered_run_pair_valid", false, "both reports must be valid") + return nil + } + + add("cold_layered_run_valid", true, "validated") + add("warm_layered_run_valid", true, "validated") + if *coldReport.AssetID != *warmReport.AssetID || *coldReport.Bytes != *warmReport.Bytes { + add("layered_run_pair_valid", false, "asset_id and bytes must match") + return nil + } + + coldEntry, err := writePackageContent(root, "evidence/cold-layered-run.json", "cold-layered-run", coldContent, "") + if err != nil { + add("layered_run_pair_valid", false, "reports could not be copied") + return nil + } + warmEntry, err := writePackageContent(root, "evidence/warm-layered-run.json", "warm-layered-run", warmContent, "") + if err != nil { + _ = os.Remove(filepath.Join(root, filepath.FromSlash(coldEntry.Path))) + add("layered_run_pair_valid", false, "reports could not be copied") + return nil + } + add("layered_run_pair_valid", true, "asset_id and bytes match") + return []WindowsTemporaryPackageFile{coldEntry, warmEntry} +} + +func readAndValidateWindowsLayeredRunReport(source string, present, expectedFromCache bool) ([]byte, windowsLayeredRunReport, error) { + if !present { + return nil, windowsLayeredRunReport{}, fmt.Errorf("missing") + } + content, err := readWindowsLayeredRunReport(source) + if err != nil { + return nil, windowsLayeredRunReport{}, err + } + report, err := decodeValidatedWindowsLayeredRunReport(content, expectedFromCache) + if err != nil { + return nil, windowsLayeredRunReport{}, err + } + return content, report, nil +} + +func layeredRunPresenceDetail(present bool) string { + if present { + return "provided" + } + return "missing" +} + +func layeredRunValidationDetail(err error) string { + if err == nil { + return "validated" + } + if err.Error() == "missing" { + return "missing" + } + if strings.Contains(err.Error(), "exceeds") { + return "report exceeds size limit" + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return "report could not be read" + } + return err.Error() +} + +func readWindowsLayeredRunReport(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, maxWindowsLayeredRunReportBytes+1)) + if err != nil { + return nil, err + } + if len(content) > maxWindowsLayeredRunReportBytes { + return nil, fmt.Errorf("layered run report exceeds %d bytes", maxWindowsLayeredRunReportBytes) + } + return content, nil +} + func copyEvidenceDir(root, bundleDir, kind, sourceDir string, redactor *shelladapter.ArtifactRedactor, add func(string, bool, string)) ([]WindowsTemporaryPackageFile, []string) { if strings.TrimSpace(sourceDir) == "" { add(kind+"_dir_copied", false, "missing") @@ -215,7 +340,7 @@ func copyEvidenceDir(root, bundleDir, kind, sourceDir string, redactor *shellada } entries, err := os.ReadDir(sourceDir) if err != nil { - add(kind+"_dir_copied", false, err.Error()) + add(kind+"_dir_copied", false, packageEvidenceErrorDetail(err)) return nil, nil } var files []WindowsTemporaryPackageFile @@ -231,7 +356,7 @@ func copyEvidenceDir(root, bundleDir, kind, sourceDir string, redactor *shellada source := filepath.Join(sourceDir, entry.Name()) copied, err := copyPackageFile(root, filepath.ToSlash(filepath.Join(bundleDir, name)), kind, source, redactor) if err != nil { - add(kind+"_dir_copied", false, err.Error()) + add(kind+"_dir_copied", false, packageEvidenceErrorDetail(err)) return files, names } files = append(files, copied) @@ -267,16 +392,28 @@ func writePackageContent(root, bundlePath, kind string, content []byte, source s if err := os.WriteFile(path, content, 0o600); err != nil { return WindowsTemporaryPackageFile{}, err } + if err := windowsentry.ProtectPrivatePath(path, false); err != nil { + return WindowsTemporaryPackageFile{}, err + } sum := sha256.Sum256(content) return WindowsTemporaryPackageFile{ Path: filepath.ToSlash(clean), Kind: kind, SHA256: "sha256:" + hex.EncodeToString(sum[:]), SizeBytes: len(content), - Source: source, }, nil } +func packageEvidenceErrorDetail(err error) string { + if err == nil { + return "" + } + if strings.Contains(err.Error(), "placeholder") { + return "evidence placeholder must be replaced" + } + return "evidence could not be read or copied" +} + func writePackageChecksums(root string, files []WindowsTemporaryPackageFile) ([]byte, WindowsTemporaryPackageFile, error) { var builder strings.Builder for _, file := range files { diff --git a/internal/acceptance/windows_temporary_package_test.go b/internal/acceptance/windows_temporary_package_test.go new file mode 100644 index 0000000..8b02320 --- /dev/null +++ b/internal/acceptance/windows_temporary_package_test.go @@ -0,0 +1,344 @@ +package acceptance + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd/windowsentry" +) + +func TestValidateWindowsLayeredRunReportAcceptsFocusedWindowsOutput(t *testing.T) { + for _, fromCache := range []bool{false, true} { + report := map[string]any{ + "schema_version": windowsentry.RunReportSchemaVersion, + "asset_id": "rdev-host-windows-amd64", + "from_cache": fromCache, + "resumed": !fromCache, + "bytes": int64(4096), + } + if err := validateWindowsLayeredRunReport(marshalWindowsLayeredRunReportForTest(t, report), fromCache); err != nil { + t.Fatalf("focused Windows report from_cache=%t rejected: %v", fromCache, err) + } + } +} + +func TestPackageWindowsTemporaryEvidenceIncludesColdAndWarmLayeredReports(t *testing.T) { + fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": true}`) + outDir := filepath.Join(fixture.root, "package") + + pkg, err := PackageWindowsTemporaryEvidence(windowsTemporaryLayeredPackageOptions(fixture, outDir, fixture.coldLayeredRunPath, fixture.warmLayeredRunPath)) + if err != nil { + t.Fatal(err) + } + if !pkg.OK() { + t.Fatalf("expected layered evidence package checks to pass: %#v", pkg.Checks) + } + assertWindowsTemporaryPackageCheck(t, pkg.Checks, "cold_layered_run_valid", true) + assertWindowsTemporaryPackageCheck(t, pkg.Checks, "warm_layered_run_valid", true) + for _, check := range pkg.Checks { + if strings.HasPrefix(check.Name, "cold_layered_run_") || strings.HasPrefix(check.Name, "warm_layered_run_") { + if strings.Contains(check.Detail, fixture.root) { + t.Fatalf("layered report check %s leaks its private source path: %q", check.Name, check.Detail) + } + } + } + + for _, report := range []struct { + path string + fromCache bool + }{ + {path: "evidence/cold-layered-run.json", fromCache: false}, + {path: "evidence/warm-layered-run.json", fromCache: true}, + } { + packagedPath := filepath.Join(outDir, filepath.FromSlash(report.path)) + content, err := os.ReadFile(packagedPath) + if err != nil { + t.Fatalf("read packaged report %s: %v", report.path, err) + } + if err := validateWindowsLayeredRunReport(content, report.fromCache); err != nil { + t.Fatalf("packaged report %s is invalid: %v", report.path, err) + } + if !windowsTemporaryPackageContainsPath(pkg.Files, report.path) { + t.Fatalf("package manifest does not include %s: %#v", report.path, pkg.Files) + } + if !windowsTemporaryRequiredEvidenceContains(pkg.RequiredEvidence, filepath.Base(report.path)) { + t.Fatalf("required evidence does not include %s: %#v", filepath.Base(report.path), pkg.RequiredEvidence) + } + } + + checksums, err := os.ReadFile(filepath.Join(outDir, "checksums.txt")) + if err != nil { + t.Fatal(err) + } + for _, reportPath := range []string{"evidence/cold-layered-run.json", "evidence/warm-layered-run.json"} { + if !strings.Contains(string(checksums), reportPath) { + t.Fatalf("checksums.txt does not include %s:\n%s", reportPath, checksums) + } + } +} + +func TestPackageWindowsTemporaryEvidenceRejectsMissingLayeredReports(t *testing.T) { + fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": true}`) + outDir := filepath.Join(fixture.root, "package") + + pkg, err := PackageWindowsTemporaryEvidence(windowsTemporaryLayeredPackageOptions(fixture, outDir, "", "")) + if err != nil { + t.Fatal(err) + } + if pkg.OK() { + t.Fatal("expected missing cold and warm layered reports to fail package verification") + } + assertWindowsTemporaryPackageCheck(t, pkg.Checks, "cold_layered_run_present", false) + assertWindowsTemporaryPackageCheck(t, pkg.Checks, "warm_layered_run_present", false) + for _, reportPath := range []string{"evidence/cold-layered-run.json", "evidence/warm-layered-run.json"} { + if _, err := os.Stat(filepath.Join(outDir, filepath.FromSlash(reportPath))); !os.IsNotExist(err) { + t.Fatalf("missing report unexpectedly copied to %s: %v", reportPath, err) + } + } +} + +func TestPackageWindowsTemporaryEvidenceRejectsMissingLayeredEntryEvidence(t *testing.T) { + fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": true}`) + options := windowsTemporaryLayeredPackageOptions(fixture, filepath.Join(fixture.root, "package"), fixture.coldLayeredRunPath, fixture.warmLayeredRunPath) + options.LayeredEntryEvidencePath = "" + pkg, err := PackageWindowsTemporaryEvidence(options) + if err != nil { + t.Fatal(err) + } + if pkg.OK() { + t.Fatal("missing layered entry evidence must fail package verification") + } + assertWindowsTemporaryPackageCheck(t, pkg.Checks, "layered_entry_evidence_present", false) + assertWindowsTemporaryPackageCheck(t, pkg.Checks, "layered_entry_evidence_valid", false) +} + +func TestPackageWindowsTemporaryEvidenceRejectsInvalidLayeredReports(t *testing.T) { + tests := []struct { + name string + mutate func(cold, warm map[string]any) + failedCheck string + absentPath string + }{ + { + name: "reversed cold cache semantics", + mutate: func(cold, _ map[string]any) { + cold["from_cache"] = true + }, + failedCheck: "cold_layered_run_valid", + absentPath: "evidence/cold-layered-run.json", + }, + { + name: "reversed warm cache semantics", + mutate: func(_, warm map[string]any) { + warm["from_cache"] = false + }, + failedCheck: "warm_layered_run_valid", + absentPath: "evidence/warm-layered-run.json", + }, + { + name: "missing signature verification stage", + mutate: func(cold, _ map[string]any) { + removeWindowsLayeredRunStageForTest(cold, "signature-verification") + }, + failedCheck: "cold_layered_run_valid", + absentPath: "evidence/cold-layered-run.json", + }, + { + name: "wrong report schema", + mutate: func(cold, _ map[string]any) { + cold["schema_version"] = "rdev.layered-run-report.v0" + }, + failedCheck: "cold_layered_run_valid", + absentPath: "evidence/cold-layered-run.json", + }, + { + name: "negative stage duration", + mutate: func(_, warm map[string]any) { + setWindowsLayeredRunStageDurationForTest(warm, "runtime-download", -1) + }, + failedCheck: "warm_layered_run_valid", + absentPath: "evidence/warm-layered-run.json", + }, + { + name: "ticket text", + mutate: func(cold, _ map[string]any) { + cold["note"] = "ticket_code=ABCD-1234" + }, + failedCheck: "cold_layered_run_valid", + absentPath: "evidence/cold-layered-run.json", + }, + { + name: "gateway text", + mutate: func(_, warm map[string]any) { + warm["note"] = "gateway_url=https://api.example.com/v1" + }, + failedCheck: "warm_layered_run_valid", + absentPath: "evidence/warm-layered-run.json", + }, + { + name: "token text", + mutate: func(cold, _ map[string]any) { + cold["note"] = "token=secret-value" + }, + failedCheck: "cold_layered_run_valid", + absentPath: "evidence/cold-layered-run.json", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": true}`) + cold := windowsLayeredRunReportForTest(false) + warm := windowsLayeredRunReportForTest(true) + test.mutate(cold, warm) + coldPath := writeWindowsLayeredRunReportForTest(t, fixture.root, "cold-layered-run.json", cold) + warmPath := writeWindowsLayeredRunReportForTest(t, fixture.root, "warm-layered-run.json", warm) + outDir := filepath.Join(fixture.root, "package") + + pkg, err := PackageWindowsTemporaryEvidence(windowsTemporaryLayeredPackageOptions(fixture, outDir, coldPath, warmPath)) + if err != nil { + t.Fatal(err) + } + if pkg.OK() { + t.Fatalf("expected invalid layered report to fail package verification: %#v", pkg.Checks) + } + assertWindowsTemporaryPackageCheck(t, pkg.Checks, test.failedCheck, false) + if _, err := os.Stat(filepath.Join(outDir, filepath.FromSlash(test.absentPath))); !os.IsNotExist(err) { + t.Fatalf("invalid report was copied to %s: %v", test.absentPath, err) + } + }) + } +} + +func TestPackageWindowsTemporaryEvidenceRejectsMismatchedLayeredRuntimePair(t *testing.T) { + fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": true}`) + cold := windowsLayeredRunReportForTest(false) + warm := windowsLayeredRunReportForTest(true) + warm["bytes"] = int64(8192) + coldPath := writeWindowsLayeredRunReportForTest(t, fixture.root, "pair-cold-layered-run.json", cold) + warmPath := writeWindowsLayeredRunReportForTest(t, fixture.root, "pair-warm-layered-run.json", warm) + outDir := filepath.Join(fixture.root, "package") + + pkg, err := PackageWindowsTemporaryEvidence(windowsTemporaryLayeredPackageOptions(fixture, outDir, coldPath, warmPath)) + if err != nil { + t.Fatal(err) + } + if pkg.OK() { + t.Fatalf("expected mismatched cold/warm runtime reports to fail: %#v", pkg.Checks) + } + assertWindowsTemporaryPackageCheck(t, pkg.Checks, "layered_run_pair_valid", false) + for _, reportPath := range []string{"evidence/cold-layered-run.json", "evidence/warm-layered-run.json"} { + if _, err := os.Stat(filepath.Join(outDir, filepath.FromSlash(reportPath))); !os.IsNotExist(err) { + t.Fatalf("mismatched report pair was copied to %s: %v", reportPath, err) + } + } +} + +func windowsTemporaryLayeredPackageOptions(fixture windowsTemporaryPackageFixture, outDir, coldPath, warmPath string) WindowsTemporaryPackageOptions { + return WindowsTemporaryPackageOptions{ + PlanPath: fixture.planPath, + OutDir: outDir, + TranscriptPath: fixture.transcriptPath, + ReleaseVerificationPath: fixture.releaseVerificationPath, + AuditPath: fixture.auditPath, + NoPersistenceDir: fixture.noPersistenceDir, + DenialProbesDir: fixture.denialProbesDir, + ColdLayeredRunPath: coldPath, + WarmLayeredRunPath: warmPath, + LayeredEntryEvidencePath: fixture.layeredEntryEvidencePath, + Now: time.Date(2026, 7, 16, 9, 0, 0, 0, time.UTC), + } +} + +func windowsLayeredRunReportForTest(fromCache bool) map[string]any { + return map[string]any{ + "schema_version": "rdev.layered-run-report.v1", + "asset_id": "rdev-host-windows-amd64", + "from_cache": fromCache, + "resumed": false, + "bytes": int64(4096), + "stages": []any{ + map[string]any{"name": "manifest-fetch", "duration_ms": int64(4)}, + map[string]any{"name": "signature-verification", "duration_ms": int64(2)}, + map[string]any{"name": "runtime-download", "duration_ms": int64(8)}, + map[string]any{"name": "runtime-launch-preparation", "duration_ms": int64(1)}, + }, + } +} + +func writeWindowsLayeredRunReportForTest(t *testing.T, dir, name string, report map[string]any) string { + t.Helper() + content := marshalWindowsLayeredRunReportForTest(t, report) + path := filepath.Join(dir, name) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func marshalWindowsLayeredRunReportForTest(t *testing.T, report map[string]any) []byte { + t.Helper() + content, err := json.MarshalIndent(report, "", " ") + if err != nil { + t.Fatal(err) + } + return append(content, '\n') +} + +func removeWindowsLayeredRunStageForTest(report map[string]any, name string) { + stages, _ := report["stages"].([]any) + filtered := make([]any, 0, len(stages)) + for _, value := range stages { + stage, _ := value.(map[string]any) + if stage["name"] != name { + filtered = append(filtered, value) + } + } + report["stages"] = filtered +} + +func setWindowsLayeredRunStageDurationForTest(report map[string]any, name string, duration int64) { + stages, _ := report["stages"].([]any) + for _, value := range stages { + stage, _ := value.(map[string]any) + if stage["name"] == name { + stage["duration_ms"] = duration + return + } + } +} + +func assertWindowsTemporaryPackageCheck(t *testing.T, checks []Check, name string, passed bool) { + t.Helper() + for _, check := range checks { + if check.Name == name { + if check.Passed != passed { + t.Fatalf("check %s passed=%t, want %t: %#v", name, check.Passed, passed, check) + } + return + } + } + t.Fatalf("missing package check %s: %#v", name, checks) +} + +func windowsTemporaryPackageContainsPath(files []WindowsTemporaryPackageFile, path string) bool { + for _, file := range files { + if file.Path == path && file.SizeBytes > 0 && strings.HasPrefix(file.SHA256, "sha256:") { + return true + } + } + return false +} + +func windowsTemporaryRequiredEvidenceContains(required []string, name string) bool { + for _, value := range required { + if strings.Contains(value, name) { + return true + } + } + return false +} diff --git a/internal/acceptance/windows_temporary_test.go b/internal/acceptance/windows_temporary_test.go index b982f25..14ffa69 100644 --- a/internal/acceptance/windows_temporary_test.go +++ b/internal/acceptance/windows_temporary_test.go @@ -1,6 +1,7 @@ package acceptance import ( + "encoding/json" "os" "path/filepath" "strings" @@ -8,212 +9,62 @@ import ( "time" ) -func TestRunWindowsTemporaryPlanWritesLauncherAndChecks(t *testing.T) { - out := filepath.Join(t.TempDir(), "windows-temporary") - script := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } - plan, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ - OutDir: out, - GatewayURL: "https://api.example.com/v1", - TicketCode: "ABCD-1234", - DownloadURL: "https://agent.example.com/rdev-host.exe", - ExpectedSHA256: strings.Repeat("a", 64), - BootstrapScriptPath: script, - ManifestURL: "https://agent.example.com/j/ABCD-1234/manifest", - ManifestRootPublicKey: "manifest-root:abc", - ReleaseManifestURL: "https://agent.example.com/rdev-host.exe.rdev-release.json", - ReleaseRootPublicKey: "release-root:abc", - VerifierDownloadURL: "https://agent.example.com/rdev-verify.exe", - VerifierExpectedSHA256: strings.Repeat("b", 64), - TrustPin: "sha256:" + strings.Repeat("c", 64), - HostName: "acceptance-windows", - Now: time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatal(err) - } - if plan.SchemaVersion != WindowsTemporaryPlanSchemaVersion { - t.Fatalf("unexpected schema %q", plan.SchemaVersion) - } - if !allChecksPassed(plan.Checks) { - t.Fatalf("expected all checks to pass: %#v", plan.Checks) - } - if _, err := os.Stat(filepath.Join(out, "windows-temporary-plan.json")); err != nil { - t.Fatalf("expected plan file: %v", err) - } - launcher, err := os.ReadFile(plan.LauncherPath) - if err != nil { - t.Fatal(err) - } - launcherText := string(launcher) - for _, expected := range []string{ - "-GatewayUrl 'https://api.example.com/v1'", - "-TicketCode 'ABCD-1234'", - "-ReleaseManifestUrl 'https://agent.example.com/rdev-host.exe.rdev-release.json'", - "-VerifierDownloadUrl 'https://agent.example.com/rdev-verify.exe'", - } { - if !strings.Contains(launcherText, expected) { - t.Fatalf("expected launcher to contain %q:\n%s", expected, launcherText) - } - } - commands := joinedWindowsCommands(plan.Commands) + joinedWindowsCommands(plan.NoPersistenceChecks) - for _, expected := range []string{ - "powershell.exe -NoProfile -File", - "Get-Service", - "Get-ScheduledTask", - "CurrentVersion\\Run", - "Get-NetFirewallRule", - } { - if !strings.Contains(commands, expected) { - t.Fatalf("expected command containing %q in %s", expected, commands) - } - } - if len(plan.DenialProbes) < 4 { - t.Fatalf("expected denial probes: %#v", plan.DenialProbes) - } -} - func TestVerifyWindowsTemporaryPlan(t *testing.T) { - out := filepath.Join(t.TempDir(), "windows-temporary") - script := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } - plan, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ - OutDir: out, - GatewayURL: "https://api.example.com/v1", - TicketCode: "ABCD-1234", - DownloadURL: "https://agent.example.com/rdev-host.exe", - ExpectedSHA256: strings.Repeat("a", 64), - BootstrapScriptPath: script, - ReleaseManifestURL: "https://agent.example.com/rdev-host.exe.rdev-release.json", - ReleaseRootPublicKey: "release-root:abc", - VerifierDownloadURL: "https://agent.example.com/rdev-verify.exe", - VerifierExpectedSHA256: strings.Repeat("b", 64), - }) - if err != nil { - t.Fatal(err) - } - verification, err := VerifyWindowsTemporaryPlan(filepath.Join(out, "windows-temporary-plan.json")) - if err != nil { - t.Fatal(err) - } - if !verification.OK() { - t.Fatalf("expected verification ok: %#v", verification.Checks) - } - - if err := os.WriteFile(plan.LauncherPath, []byte("New-Service rdev\n"), 0o600); err != nil { - t.Fatal(err) - } - verification, err = VerifyWindowsTemporaryPlan(filepath.Join(out, "windows-temporary-plan.json")) - if err != nil { - t.Fatal(err) - } - if verification.OK() { - t.Fatalf("expected tampered launcher verification to fail") - } - if !strings.Contains(failedCheckNames(verification.Checks), "launcher_has_no_forbidden_side_effects") { - t.Fatalf("expected forbidden side-effect failure: %#v", verification.Checks) - } -} - -func TestVerifyWindowsTemporaryPlanWithReleaseBundle(t *testing.T) { - out := filepath.Join(t.TempDir(), "windows-temporary") - script := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } - plan, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ - OutDir: out, - GatewayURL: "https://api.example.com/v1", - TicketCode: "ABCD-1234", - DownloadURL: "https://agent.example.com/rdev-host.exe", - ExpectedSHA256: strings.Repeat("a", 64), - BootstrapScriptPath: script, - ReleaseBundleURL: "https://agent.example.com/release-bundle.json", - ReleaseRootPublicKey: "release-root:abc", - VerifierDownloadURL: "https://agent.example.com/rdev-verify.exe", - VerifierExpectedSHA256: strings.Repeat("b", 64), - }) - if err != nil { - t.Fatal(err) - } - if !allChecksPassed(plan.Checks) { - t.Fatalf("expected all checks to pass: %#v", plan.Checks) - } - if plan.ReleaseManifestURL != "" { - t.Fatalf("expected bundle-only plan to omit release manifest, got %q", plan.ReleaseManifestURL) - } - if plan.ReleaseBundleRequiredArtifacts != "rdev-host.exe,rdev-verify.exe" { - t.Fatalf("unexpected required artifacts %q", plan.ReleaseBundleRequiredArtifacts) - } - launcherBytes, err := os.ReadFile(plan.LauncherPath) - if err != nil { + root := t.TempDir() + planOut := filepath.Join(root, "plan") + if _, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ + OutDir: planOut, + HandoffArchivePath: writeWindowsLayeredAcceptanceArchive(t), + Now: time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC), + }); err != nil { t.Fatal(err) } - launcher := string(launcherBytes) - for _, expected := range []string{ - "-ReleaseBundleUrl 'https://agent.example.com/release-bundle.json'", - "-ReleaseBundleRequiredArtifacts 'rdev-host.exe,rdev-verify.exe'", - } { - if !strings.Contains(launcher, expected) { - t.Fatalf("expected launcher to contain %q:\n%s", expected, launcher) - } - } - if strings.Contains(launcher, "-ReleaseManifestUrl") { - t.Fatalf("bundle-only launcher should not include release manifest:\n%s", launcher) - } - verification, err := VerifyWindowsTemporaryPlan(filepath.Join(out, "windows-temporary-plan.json")) + verification, err := VerifyWindowsTemporaryPlan(filepath.Join(planOut, "windows-temporary-plan.json")) if err != nil { t.Fatal(err) } if !verification.OK() { - t.Fatalf("expected verification ok: %#v", verification.Checks) + t.Fatalf("expected layered handoff plan verification to pass: %#v", verification.Checks) } } -func TestVerifyWindowsTemporaryPlanWithRemoteBootstrapPin(t *testing.T) { - out := filepath.Join(t.TempDir(), "windows-temporary") - missingScript := filepath.Join(t.TempDir(), "missing-windows-temporary.ps1") +func TestVerifyWindowsTemporaryPlanRejectsTamperedHandoff(t *testing.T) { + root := t.TempDir() + planOut := filepath.Join(root, "plan") if _, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ - OutDir: out, - GatewayURL: "https://api.example.com/v1", - TicketCode: "ABCD-1234", - DownloadURL: "https://agent.example.com/rdev-host.exe", - ExpectedSHA256: strings.Repeat("a", 64), - BootstrapScriptPath: missingScript, - BootstrapScriptURL: "https://agent.example.com/windows-temporary.ps1", - BootstrapScriptExpectedSHA256: strings.Repeat("c", 64), - ReleaseManifestURL: "https://agent.example.com/rdev-host.exe.rdev-release.json", - ReleaseRootPublicKey: "release-root:abc", - VerifierDownloadURL: "https://agent.example.com/rdev-verify.exe", - VerifierExpectedSHA256: strings.Repeat("b", 64), + OutDir: planOut, + HandoffArchivePath: writeWindowsLayeredAcceptanceArchive(t), }); err != nil { t.Fatal(err) } - verification, err := VerifyWindowsTemporaryPlan(filepath.Join(out, "windows-temporary-plan.json")) + if err := os.WriteFile(filepath.Join(planOut, "Windows-ConnectionEntry.zip"), []byte("tampered"), 0o600); err != nil { + t.Fatal(err) + } + verification, err := VerifyWindowsTemporaryPlan(filepath.Join(planOut, "windows-temporary-plan.json")) if err != nil { t.Fatal(err) } - if !verification.OK() { - t.Fatalf("expected verification ok with remote bootstrap pin: %#v", verification.Checks) + if verification.OK() || !strings.Contains(failedCheckNames(verification.Checks), "handoff_archive") { + t.Fatalf("tampered handoff must fail verification: %#v", verification.Checks) } } func TestPackageWindowsTemporaryEvidencePackagesAndRedacts(t *testing.T) { fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": true, "token": "sk-abcdefghijklmnop"}`) + packageDir := filepath.Join(fixture.root, "package") pkg, err := PackageWindowsTemporaryEvidence(WindowsTemporaryPackageOptions{ - PlanPath: fixture.planPath, - OutDir: filepath.Join(fixture.root, "package"), - TranscriptPath: fixture.transcriptPath, - ReleaseVerificationPath: fixture.releaseVerificationPath, - AuditPath: fixture.auditPath, - NoPersistenceDir: fixture.noPersistenceDir, - DenialProbesDir: fixture.denialProbesDir, - Now: time.Date(2026, 6, 29, 13, 0, 0, 0, time.UTC), + PlanPath: fixture.planPath, + OutDir: packageDir, + TranscriptPath: fixture.transcriptPath, + ReleaseVerificationPath: fixture.releaseVerificationPath, + AuditPath: fixture.auditPath, + NoPersistenceDir: fixture.noPersistenceDir, + DenialProbesDir: fixture.denialProbesDir, + ColdLayeredRunPath: fixture.coldLayeredRunPath, + WarmLayeredRunPath: fixture.warmLayeredRunPath, + LayeredEntryEvidencePath: fixture.layeredEntryEvidencePath, + Now: time.Date(2026, 6, 29, 13, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) @@ -225,18 +76,20 @@ func TestPackageWindowsTemporaryEvidencePackagesAndRedacts(t *testing.T) { t.Fatalf("unexpected schema %q", pkg.SchemaVersion) } for _, path := range []string{ - filepath.Join(pkg.OutDir, "package.json"), - filepath.Join(pkg.OutDir, "checksums.txt"), - filepath.Join(pkg.OutDir, "plan", "windows-temporary-plan.json"), - filepath.Join(pkg.OutDir, "plan", "run-windows-temporary.ps1"), - filepath.Join(pkg.OutDir, "evidence", "no-persistence", "scheduled-tasks.txt"), - filepath.Join(pkg.OutDir, "evidence", "denial-probes", "package-install.txt"), + filepath.Join(packageDir, "package.json"), + filepath.Join(packageDir, "checksums.txt"), + filepath.Join(packageDir, "plan", "windows-temporary-plan.json"), + filepath.Join(packageDir, "evidence", "no-persistence", "scheduled-tasks.txt"), + filepath.Join(packageDir, "evidence", "denial-probes", "package-install.txt"), } { if _, err := os.Stat(path); err != nil { t.Fatalf("expected packaged file %s: %v", path, err) } } - releaseEvidence, err := os.ReadFile(filepath.Join(pkg.OutDir, "evidence", "release-verification.txt")) + if _, err := os.Stat(filepath.Join(packageDir, "Windows-ConnectionEntry.zip")); !os.IsNotExist(err) { + t.Fatal("private handoff archive must not enter public acceptance evidence") + } + releaseEvidence, err := os.ReadFile(filepath.Join(packageDir, "evidence", "release-verification.txt")) if err != nil { t.Fatal(err) } @@ -252,58 +105,50 @@ func TestPackageWindowsTemporaryEvidenceRejectsFailedReleaseVerification(t *test fixture := writeWindowsTemporaryPackageFixture(t, `{"ok": false}`) pkg, err := PackageWindowsTemporaryEvidence(WindowsTemporaryPackageOptions{ - PlanPath: fixture.planPath, - OutDir: filepath.Join(fixture.root, "package"), - TranscriptPath: fixture.transcriptPath, - ReleaseVerificationPath: fixture.releaseVerificationPath, - AuditPath: fixture.auditPath, - NoPersistenceDir: fixture.noPersistenceDir, - DenialProbesDir: fixture.denialProbesDir, + PlanPath: fixture.planPath, + OutDir: filepath.Join(fixture.root, "package"), + TranscriptPath: fixture.transcriptPath, + ReleaseVerificationPath: fixture.releaseVerificationPath, + AuditPath: fixture.auditPath, + NoPersistenceDir: fixture.noPersistenceDir, + DenialProbesDir: fixture.denialProbesDir, + ColdLayeredRunPath: fixture.coldLayeredRunPath, + WarmLayeredRunPath: fixture.warmLayeredRunPath, + LayeredEntryEvidencePath: fixture.layeredEntryEvidencePath, }) if err != nil { t.Fatal(err) } if pkg.OK() { - t.Fatalf("expected failed release verification to fail package checks") + t.Fatal("failed release verification must fail package checks") } if !strings.Contains(failedCheckNames(pkg.Checks), "release_verification_ok") { t.Fatalf("expected release_verification_ok failure: %#v", pkg.Checks) } - if len(pkg.RecommendedActions) == 0 { - t.Fatalf("expected recommended actions for failed package") - } } type windowsTemporaryPackageFixture struct { - root string - planPath string - transcriptPath string - releaseVerificationPath string - auditPath string - noPersistenceDir string - denialProbesDir string + root string + planPath string + transcriptPath string + releaseVerificationPath string + auditPath string + noPersistenceDir string + denialProbesDir string + coldLayeredRunPath string + warmLayeredRunPath string + layeredEntryEvidencePath string } func writeWindowsTemporaryPackageFixture(t *testing.T, releaseVerification string) windowsTemporaryPackageFixture { t.Helper() root := t.TempDir() - script := filepath.Join(root, "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } planOut := filepath.Join(root, "plan") - if _, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ - OutDir: planOut, - GatewayURL: "https://api.example.com/v1", - TicketCode: "ABCD-1234", - DownloadURL: "https://agent.example.com/rdev-host.exe", - ExpectedSHA256: strings.Repeat("a", 64), - BootstrapScriptPath: script, - ReleaseBundleURL: "https://agent.example.com/release-bundle.json", - ReleaseRootPublicKey: "release-root:abc", - VerifierDownloadURL: "https://agent.example.com/rdev-verify.exe", - VerifierExpectedSHA256: strings.Repeat("b", 64), - }); err != nil { + plan, err := RunWindowsTemporaryPlan(WindowsTemporaryOptions{ + OutDir: planOut, + HandoffArchivePath: writeWindowsLayeredAcceptanceArchive(t), + }) + if err != nil { t.Fatal(err) } transcriptPath := filepath.Join(root, "transcript.txt") @@ -335,14 +180,30 @@ func writeWindowsTemporaryPackageFixture(t *testing.T, releaseVerification strin "gui.control.txt", "credential.change.txt", }) + coldLayeredRunPath := writeWindowsLayeredRunReportForTest(t, root, "cold-layered-run.json", windowsLayeredRunReportForTest(false)) + warmLayeredRunPath := writeWindowsLayeredRunReportForTest(t, root, "warm-layered-run.json", windowsLayeredRunReportForTest(true)) + layeredEntryEvidence := windowsLayeredEntryEvidenceForTest() + layeredEntryEvidence["handoff_zip_size_bytes"] = float64(plan.HandoffArchiveSizeBytes) + layeredEntryEvidence["handoff_zip_sha256"] = plan.HandoffArchiveSHA256 + layeredEntryEvidenceContent, err := json.Marshal(layeredEntryEvidence) + if err != nil { + t.Fatal(err) + } + layeredEntryEvidencePath := filepath.Join(root, "layered-entry-evidence.json") + if err := os.WriteFile(layeredEntryEvidencePath, append(layeredEntryEvidenceContent, '\n'), 0o600); err != nil { + t.Fatal(err) + } return windowsTemporaryPackageFixture{ - root: root, - planPath: filepath.Join(planOut, "windows-temporary-plan.json"), - transcriptPath: transcriptPath, - releaseVerificationPath: releaseVerificationPath, - auditPath: auditPath, - noPersistenceDir: noPersistenceDir, - denialProbesDir: denialProbesDir, + root: root, + planPath: filepath.Join(planOut, "windows-temporary-plan.json"), + transcriptPath: transcriptPath, + releaseVerificationPath: releaseVerificationPath, + auditPath: auditPath, + noPersistenceDir: noPersistenceDir, + denialProbesDir: denialProbesDir, + coldLayeredRunPath: coldLayeredRunPath, + warmLayeredRunPath: warmLayeredRunPath, + layeredEntryEvidencePath: layeredEntryEvidencePath, } } @@ -357,12 +218,3 @@ func writeEvidenceFiles(t *testing.T, dir string, names []string) { } } } - -func joinedWindowsCommands(commands []WindowsAcceptanceCommand) string { - var builder strings.Builder - for _, command := range commands { - builder.WriteString(command.Shell) - builder.WriteByte('\n') - } - return builder.String() -} diff --git a/internal/acceptance/windows_temporary_verify.go b/internal/acceptance/windows_temporary_verify.go index b10a26a..a97d758 100644 --- a/internal/acceptance/windows_temporary_verify.go +++ b/internal/acceptance/windows_temporary_verify.go @@ -1,15 +1,76 @@ package acceptance import ( + "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" + "slices" "strings" "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd" + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd/windowsentry" + "github.com/EitanWong/remote-dev-skillkit/internal/shelladapter" ) const WindowsTemporaryPlanVerificationSchemaVersion = "rdev.acceptance-verification.windows-temporary-plan.v1" +const WindowsLayeredEntryEvidenceSchemaVersion = "rdev.acceptance.windows-layered-entry-evidence.v1" + +const maxWindowsLayeredRunReportBytes = 64 << 10 + +var windowsLayeredRunStageNames = []string{ + "manifest-fetch", + "signature-verification", + "runtime-download", + "runtime-launch-preparation", +} + +type windowsLayeredRunReport struct { + SchemaVersion *string `json:"schema_version"` + AssetID *string `json:"asset_id"` + FromCache *bool `json:"from_cache"` + Resumed *bool `json:"resumed"` + Bytes *int64 `json:"bytes"` + Stages *[]windowsLayeredRunReportStage `json:"stages"` +} + +type windowsLayeredRunReportStage struct { + Name *string `json:"name"` + DurationMS *int64 `json:"duration_ms"` +} + +type windowsLayeredEntryEvidence struct { + SchemaVersion string `json:"schema_version"` + WindowsRelease string `json:"windows_release"` + Architecture string `json:"architecture"` + HandoffZIPSizeBytes int64 `json:"handoff_zip_size_bytes"` + HandoffZIPSHA256 string `json:"handoff_zip_sha256"` + SelectedLauncher string `json:"selected_launcher"` + FallbackAttempts []string `json:"fallback_attempts"` + CoreStartCount int `json:"core_start_count"` + NetworkBytes int64 `json:"network_bytes"` + RegistrationDurationMS int64 `json:"registration_duration_ms"` + CacheHit bool `json:"cache_hit"` + RangeInterrupted bool `json:"range_interrupted"` + RangeResumed bool `json:"range_resumed"` + RangeBytes int64 `json:"range_bytes"` + PrivateACL bool `json:"private_acl"` + UNCRejected bool `json:"unc_rejected"` + ReparseRejected bool `json:"reparse_rejected"` + DefenderLockVerified bool `json:"defender_lock_verified"` + ActiveRouteFailed bool `json:"active_route_failed"` + RouteReselected bool `json:"route_reselected"` + RegistrationCount int `json:"registration_count"` + SessionIdentityStable bool `json:"session_identity_stable"` + EventCursorStable bool `json:"event_cursor_stable"` + ArchiveRecoveryExecuted bool `json:"archive_recovery_executed"` + BootstrapOnly bool `json:"bootstrap_only"` + PersistenceResidue []string `json:"persistence_residue"` + CleanupComplete bool `json:"cleanup_complete"` +} type WindowsTemporaryPlanVerification struct { SchemaVersion string `json:"schema_version"` @@ -44,18 +105,25 @@ func VerifyWindowsTemporaryPlan(planPath string) (WindowsTemporaryPlanVerificati if err != nil { return WindowsTemporaryPlanVerification{}, err } - var plan WindowsTemporaryPlan - if err := json.Unmarshal(content, &plan); err != nil { + plan, err := decodeWindowsTemporaryPlan(content) + if err != nil { return WindowsTemporaryPlanVerification{}, err } baseDir := filepath.Dir(abs) - launcherPath := resolvePlanPath(baseDir, plan.LauncherPath, "run-windows-temporary.ps1") - launcherContent, launcherErr := os.ReadFile(launcherPath) - launcherText := string(launcherContent) + archivePath := filepath.Join(baseDir, "Windows-ConnectionEntry.zip") + archivePathSafe := plan.HandoffArchivePath == "Windows-ConnectionEntry.zip" + var archiveSHA256 string + var archiveSize int64 + var archiveErr error + if archivePathSafe { + _, _, archiveSHA256, archiveSize, archiveErr = inspectWindowsLayeredAcceptanceArchive(archivePath) + } else { + archiveErr = fmt.Errorf("handoff archive path is not the generated basename") + } verification := WindowsTemporaryPlanVerification{ SchemaVersion: WindowsTemporaryPlanVerificationSchemaVersion, - PlanPath: abs, + PlanPath: filepath.Base(abs), PlanSchema: plan.SchemaVersion, GeneratedAt: time.Now().UTC(), } @@ -65,116 +133,474 @@ func VerifyWindowsTemporaryPlan(planPath string) (WindowsTemporaryPlanVerificati add("plan_schema", plan.SchemaVersion == WindowsTemporaryPlanSchemaVersion, plan.SchemaVersion) add("plan_checks_passed", allChecksPassed(plan.Checks), failedCheckNames(plan.Checks)) - add("platform_windows", plan.Platform == "windows", plan.Platform) - add("launcher_exists", launcherErr == nil, launcherPath) - add("launcher_private_file_mode", fileModePrivate(launcherPath), launcherPath) - add("launcher_matches_plan", launcherErr == nil && launcherMatchesWindowsPlan(launcherText, plan), "") - add("launcher_has_no_forbidden_side_effects", launcherErr == nil && !containsForbiddenWindowsLauncherOperation(launcherText), forbiddenWindowsLauncherDetail(launcherText)) - add("bootstrap_hash_pinned_or_matches", bootstrapHashPinnedOrMatches(plan), plan.BootstrapScriptSHA256) - add("host_sha256_valid", isHexSHA256(plan.HostExpectedSHA256), plan.HostExpectedSHA256) - add("verifier_sha256_valid", isHexSHA256(plan.VerifierExpectedSHA256), plan.VerifierExpectedSHA256) - add("release_manifest_or_bundle_present", plan.ReleaseManifestURL != "" || plan.ReleaseBundleURL != "", firstNonEmptyString(plan.ReleaseBundleURL, plan.ReleaseManifestURL)) - add("release_bundle_required_artifacts_present", plan.ReleaseBundleURL == "" || plan.ReleaseBundleRequiredArtifacts != "", plan.ReleaseBundleRequiredArtifacts) - add("release_root_present", plan.ReleaseRootPublicKey != "", "") - add("verifier_download_present", plan.VerifierDownloadURL != "", plan.VerifierDownloadURL) + add("platform_windows_amd64", plan.Platform == "windows/amd64", plan.Platform) + add("handoff_archive_path_safe", archivePathSafe, plan.HandoffArchivePath) + add("handoff_archive_exists", archiveErr == nil, filepath.Base(archivePath)) + add("handoff_archive_private_file_mode", archiveErr == nil && fileModePrivate(archivePath), filepath.Base(archivePath)) + add("handoff_archive_sha256_matches", archiveErr == nil && strings.EqualFold(archiveSHA256, plan.HandoffArchiveSHA256), plan.HandoffArchiveSHA256) + add("handoff_archive_size_matches", archiveErr == nil && archiveSize == plan.HandoffArchiveSizeBytes && archiveSize <= maxWindowsTemporaryHandoffBytes, fmt.Sprintf("%d", plan.HandoffArchiveSizeBytes)) + add("powershell_launcher_preferred", plan.PowerShellLauncher == "Start-ConnectionEntry.ps1" && plan.PreferredLauncher == "powershell", plan.PreferredLauncher) + add("command_launcher_present", plan.CommandLauncher == "Start-ConnectionEntry.cmd", plan.CommandLauncher) + add("fallback_order", slices.Equal(plan.FallbackOrder, []string{"powershell", "powershell-bypass", "cmd"}), strings.Join(plan.FallbackOrder, ",")) + add("bootstrap_only", plan.BootstrapCommand == "rdev-bootstrap layered-run", plan.BootstrapCommand) + add("archive_recovery_not_automatic", !plan.ArchiveRecoveryAutomatic, "") add("foreground_command_present", commandNamed(plan.Commands, "run_foreground_temporary_host"), "") add("transcript_commands_present", commandNamed(plan.Commands, "start_transcript") && commandNamed(plan.Commands, "stop_transcript"), "") add("no_persistence_checks_complete", windowsNoPersistenceChecksComplete(plan.NoPersistenceChecks), missingWindowsNoPersistenceChecks(plan.NoPersistenceChecks)) add("denial_probes_complete", windowsDenialProbesComplete(plan.DenialProbes), missingWindowsDenialProbes(plan.DenialProbes)) - add("required_evidence_complete", len(plan.RequiredEvidence) >= 5, "") + add("required_evidence_complete", windowsTemporaryRequiredEvidenceComplete(plan.RequiredEvidence), missingWindowsTemporaryRequiredEvidence(plan.RequiredEvidence)) if !verification.OK() { verification.RecommendedActions = []string{ "Regenerate the Windows temporary acceptance plan in a fresh output directory.", - "Inspect run-windows-temporary.ps1 for unexpected side effects before sending it to a target user.", + "Inspect the measured handoff archive and its two visible launchers before sending it to a target user.", "Do not run or publish this Windows acceptance plan until verification passes.", } } return verification, nil } -func resolvePlanPath(baseDir, path, fallback string) string { - if filepath.IsAbs(path) { - return path +func decodeWindowsTemporaryPlan(content []byte) (WindowsTemporaryPlan, error) { + if len(content) == 0 { + return WindowsTemporaryPlan{}, fmt.Errorf("Windows temporary plan is empty") } - if strings.TrimSpace(path) != "" { - return filepath.Join(baseDir, path) + if err := rejectDuplicateJSONKeys(content); err != nil { + return WindowsTemporaryPlan{}, fmt.Errorf("invalid Windows temporary plan JSON: %w", err) } - return filepath.Join(baseDir, fallback) + if detail := windowsLayeredRunPrivateContentDetail(content); detail != "" { + return WindowsTemporaryPlan{}, fmt.Errorf("Windows temporary plan contains %s", detail) + } + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var plan WindowsTemporaryPlan + if err := decoder.Decode(&plan); err != nil { + return WindowsTemporaryPlan{}, fmt.Errorf("decode Windows temporary plan: %w", err) + } + if err := requireJSONEOF(decoder); err != nil { + return WindowsTemporaryPlan{}, err + } + return plan, nil } -func fileModePrivate(path string) bool { - info, err := os.Stat(path) +func validateWindowsLayeredRunReport(content []byte, expectedFromCache bool) error { + if len(content) == 0 { + return fmt.Errorf("layered run report is empty") + } + if len(content) > maxWindowsLayeredRunReportBytes { + return fmt.Errorf("layered run report exceeds %d bytes", maxWindowsLayeredRunReportBytes) + } + if err := rejectDuplicateJSONKeys(content); err != nil { + return fmt.Errorf("invalid layered run report JSON: %w", err) + } + if detail := windowsLayeredRunPrivateContentDetail(content); detail != "" { + return fmt.Errorf("layered run report contains %s", detail) + } + reportSchema, err := validateWindowsLayeredRunFieldNames(content) if err != nil { - return false + return err + } + + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var report windowsLayeredRunReport + if err := decoder.Decode(&report); err != nil { + return fmt.Errorf("decode layered run report: %w", err) + } + if err := requireJSONEOF(decoder); err != nil { + return err + } + if report.SchemaVersion == nil || *report.SchemaVersion != reportSchema { + return fmt.Errorf("unsupported layered run report schema") + } + if report.AssetID == nil || *report.AssetID != "rdev-host-windows-amd64" { + return fmt.Errorf("unexpected Windows layered runtime asset_id") + } + if report.FromCache == nil { + return fmt.Errorf("from_cache is required") } - return info.Mode().Perm()&0o077 == 0 + if *report.FromCache != expectedFromCache { + return fmt.Errorf("from_cache=%t, want %t", *report.FromCache, expectedFromCache) + } + if report.Resumed == nil { + return fmt.Errorf("resumed is required") + } + if expectedFromCache && *report.Resumed { + return fmt.Errorf("resumed must be false when from_cache=true") + } + if report.Bytes == nil || *report.Bytes <= 0 { + return fmt.Errorf("bytes must be positive") + } + if reportSchema == windowsentry.RunReportSchemaVersion { + return nil + } + if report.Stages == nil { + return fmt.Errorf("stages are required") + } + stages := *report.Stages + for index, expectedName := range windowsLayeredRunStageNames { + if index >= len(stages) { + return fmt.Errorf("stage %d must be %q: missing", index, expectedName) + } + stage := stages[index] + if stage.Name == nil || *stage.Name != expectedName { + return fmt.Errorf("stage %d must be %q", index, expectedName) + } + if stage.DurationMS == nil { + return fmt.Errorf("stage %q duration_ms is required", expectedName) + } + if *stage.DurationMS < 0 { + return fmt.Errorf("stage %q duration_ms must be nonnegative", expectedName) + } + } + if len(stages) != len(windowsLayeredRunStageNames) { + return fmt.Errorf("layered run report must contain exactly %d stages", len(windowsLayeredRunStageNames)) + } + return nil } -func launcherMatchesWindowsPlan(launcher string, plan WindowsTemporaryPlan) bool { +func validateWindowsLayeredEntryEvidence(content []byte, plan WindowsTemporaryPlan) error { + if len(content) == 0 || len(content) > maxWindowsLayeredRunReportBytes { + return fmt.Errorf("layered entry evidence is empty or exceeds %d bytes", maxWindowsLayeredRunReportBytes) + } + if err := rejectDuplicateJSONKeys(content); err != nil { + return fmt.Errorf("invalid layered entry evidence JSON: %w", err) + } + if detail := windowsLayeredRunPrivateContentDetail(content); detail != "" { + return fmt.Errorf("layered entry evidence contains %s", detail) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(content, &fields); err != nil || fields == nil { + return fmt.Errorf("layered entry evidence must be a JSON object") + } + if err := requireExactJSONFields(fields, []string{ + "schema_version", + "windows_release", + "architecture", + "handoff_zip_size_bytes", + "handoff_zip_sha256", + "selected_launcher", + "fallback_attempts", + "core_start_count", + "network_bytes", + "registration_duration_ms", + "cache_hit", + "range_interrupted", + "range_resumed", + "range_bytes", + "private_acl", + "unc_rejected", + "reparse_rejected", + "defender_lock_verified", + "active_route_failed", + "route_reselected", + "registration_count", + "session_identity_stable", + "event_cursor_stable", + "archive_recovery_executed", + "bootstrap_only", + "persistence_residue", + "cleanup_complete", + }, "layered entry evidence"); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var evidence windowsLayeredEntryEvidence + if err := decoder.Decode(&evidence); err != nil { + return fmt.Errorf("decode layered entry evidence: %w", err) + } + if err := requireJSONEOF(decoder); err != nil { + return err + } + if evidence.SchemaVersion != WindowsLayeredEntryEvidenceSchemaVersion { + return fmt.Errorf("unsupported layered entry evidence schema") + } + if evidence.WindowsRelease != "10" && evidence.WindowsRelease != "11" { + return fmt.Errorf("windows_release must be 10 or 11") + } + if evidence.Architecture != "amd64" { + return fmt.Errorf("architecture must be amd64") + } + if evidence.HandoffZIPSizeBytes <= 0 || evidence.HandoffZIPSizeBytes > maxWindowsTemporaryHandoffBytes || + evidence.HandoffZIPSizeBytes != plan.HandoffArchiveSizeBytes { + return fmt.Errorf("handoff ZIP size does not match the measured plan") + } + if !isHexSHA256(evidence.HandoffZIPSHA256) || !strings.EqualFold(evidence.HandoffZIPSHA256, plan.HandoffArchiveSHA256) { + return fmt.Errorf("handoff ZIP SHA-256 does not match the measured plan") + } + expectedAttempts := map[string][]string{ + "powershell": {"powershell"}, + "powershell-bypass": {"powershell", "powershell-bypass"}, + "cmd": {"powershell", "powershell-bypass", "cmd"}, + }[evidence.SelectedLauncher] + if expectedAttempts == nil || !slices.Equal(evidence.FallbackAttempts, expectedAttempts) { + return fmt.Errorf("fallback attempts are unordered or do not end at selected_launcher") + } + if evidence.CoreStartCount != 1 { + return fmt.Errorf("core_start_count must be exactly one") + } + if evidence.NetworkBytes < 0 { + return fmt.Errorf("network_bytes must be nonnegative") + } + if evidence.RegistrationDurationMS < 0 { + return fmt.Errorf("registration duration must be nonnegative") + } + if !evidence.RangeInterrupted || !evidence.RangeResumed || evidence.RangeBytes <= 0 { + return fmt.Errorf("Range interruption and resume evidence is required") + } + if !evidence.PrivateACL { + return fmt.Errorf("private ACL evidence is required") + } + if !evidence.UNCRejected { + return fmt.Errorf("UNC rejection evidence is required") + } + if !evidence.ReparseRejected { + return fmt.Errorf("reparse rejection evidence is required") + } + if !evidence.DefenderLockVerified { + return fmt.Errorf("Defender/file-lock evidence is required") + } + if !evidence.ActiveRouteFailed || !evidence.RouteReselected || !evidence.SessionIdentityStable || !evidence.EventCursorStable { + return fmt.Errorf("route reselection continuity evidence is required") + } + if evidence.RegistrationCount != 1 { + return fmt.Errorf("registration_count must be exactly one") + } + if evidence.ArchiveRecoveryExecuted { + return fmt.Errorf("archive recovery must not execute automatically") + } + if !evidence.BootstrapOnly { + return fmt.Errorf("bootstrap_only must be true") + } + if len(evidence.PersistenceResidue) != 0 { + return fmt.Errorf("persistence residue must be empty") + } + if !evidence.CleanupComplete { + return fmt.Errorf("cleanup_complete must be true") + } + return nil +} + +func decodeValidatedWindowsLayeredRunReport(content []byte, expectedFromCache bool) (windowsLayeredRunReport, error) { + if err := validateWindowsLayeredRunReport(content, expectedFromCache); err != nil { + return windowsLayeredRunReport{}, err + } + var report windowsLayeredRunReport + if err := json.Unmarshal(content, &report); err != nil { + return windowsLayeredRunReport{}, fmt.Errorf("decode validated layered run report") + } + return report, nil +} + +func validateWindowsLayeredRunFieldNames(content []byte) (string, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(content, &fields); err != nil || fields == nil { + return "", fmt.Errorf("layered run report must be a JSON object") + } + var schema string + if err := json.Unmarshal(fields["schema_version"], &schema); err != nil { + return "", fmt.Errorf("layered run report schema_version must be a string") + } required := []string{ - "-GatewayUrl " + powershellQuote(plan.GatewayURL), - "-TicketCode " + powershellQuote(plan.TicketCode), - "-DownloadUrl " + powershellQuote(plan.HostDownloadURL), - "-ExpectedSha256 " + powershellQuote(plan.HostExpectedSHA256), - "-ReleaseRootPublicKey " + powershellQuote(plan.ReleaseRootPublicKey), - "-VerifierDownloadUrl " + powershellQuote(plan.VerifierDownloadURL), - "-VerifierExpectedSha256 " + powershellQuote(plan.VerifierExpectedSHA256), + "schema_version", + "asset_id", + "from_cache", + "resumed", + "bytes", + } + switch schema { + case bootstrapcmd.LayeredRunReportSchemaVersion: + required = append(required, "stages") + case windowsentry.RunReportSchemaVersion: + default: + return "", fmt.Errorf("unsupported layered run report schema") + } + if err := requireExactJSONFields(fields, required, "layered run report"); err != nil { + return "", err } - if plan.ReleaseManifestURL != "" { - required = append(required, "-ReleaseManifestUrl "+powershellQuote(plan.ReleaseManifestURL)) + if schema == windowsentry.RunReportSchemaVersion { + return schema, nil } - if plan.ReleaseBundleURL != "" { - required = append(required, "-ReleaseBundleUrl "+powershellQuote(plan.ReleaseBundleURL)) - if plan.ReleaseBundleRequiredArtifacts != "" { - required = append(required, "-ReleaseBundleRequiredArtifacts "+powershellQuote(plan.ReleaseBundleRequiredArtifacts)) + + var stages []json.RawMessage + if err := json.Unmarshal(fields["stages"], &stages); err != nil { + return "", fmt.Errorf("layered run report stages must be an array") + } + for _, content := range stages { + var stageFields map[string]json.RawMessage + if err := json.Unmarshal(content, &stageFields); err != nil || stageFields == nil { + return "", fmt.Errorf("layered run report stage must be a JSON object") + } + if err := requireExactJSONFields(stageFields, []string{"name", "duration_ms"}, "layered run report stage"); err != nil { + return "", err } } - for _, value := range required { - if !strings.Contains(launcher, value) { - return false + return schema, nil +} + +func requireExactJSONFields(fields map[string]json.RawMessage, required []string, context string) error { + allowed := make(map[string]bool, len(required)) + for _, name := range required { + allowed[name] = true + } + for name := range fields { + if !allowed[name] { + return fmt.Errorf("unknown field in %s", context) } } - return strings.Contains(launcher, "& $bootstrap") + for _, name := range required { + if _, ok := fields[name]; !ok { + return fmt.Errorf("missing %s field %q", context, name) + } + } + return nil } -func containsForbiddenWindowsLauncherOperation(launcher string) bool { - return forbiddenWindowsLauncherDetail(launcher) != "" +func rejectDuplicateJSONKeys(content []byte) error { + decoder := json.NewDecoder(bytes.NewReader(content)) + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON values") + } + return err + } + return nil } -func forbiddenWindowsLauncherDetail(launcher string) string { - lower := strings.ToLower(launcher) - for _, pattern := range []string{ - "set-executionpolicy", - "new-service", - "sc.exe create", - "register-scheduledtask", - "new-itemproperty", - "set-itemproperty", - "currentversion\\run", - "startup\\", - "new-netfirewallrule", - "netsh advfirewall firewall add", - "-verb runas", +func walkUniqueJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := map[string]bool{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("object key is not a string") + } + if seen[key] { + return fmt.Errorf("duplicate JSON object key") + } + seen[key] = true + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + case '[': + for decoder.More() { + if err := walkUniqueJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + default: + return fmt.Errorf("unexpected JSON delimiter") + } +} + +func requireJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("layered run report contains trailing JSON") + } + return fmt.Errorf("decode trailing layered run report content: %w", err) + } + return nil +} + +func windowsLayeredRunPrivateContentDetail(content []byte) string { + lower := strings.ToLower(string(content)) + for _, forbidden := range []struct { + detail string + patterns []string + }{ + {detail: "ticket pattern", patterns: []string{`"ticket`, "ticket_code=", "ticket-code=", "ticket="}}, + {detail: "gateway pattern", patterns: []string{`"gateway`, "gateway_url=", "gateway-url=", "gateway="}}, + {detail: "token pattern", patterns: []string{`"token`, `"access_token`, "token=", "access_token="}}, + {detail: "private path", patterns: []string{`c:\\users\\`, `:\\users\\`, `:\\documents and settings\\`, "/users/", "/home/", `\\\\`}}, + {detail: "redacted content", patterns: []string{"[redacted:", `"redacted"`, `"redaction_counts"`}}, } { - if strings.Contains(lower, pattern) { - return pattern + for _, pattern := range forbidden.patterns { + if strings.Contains(lower, pattern) { + return forbidden.detail + } } } + redactor := shelladapter.NewArtifactRedactor() + _ = redactor.Redact(string(content)) + if redactor.Redacted() { + return "token or secret pattern" + } return "" } -func bootstrapHashPinnedOrMatches(plan WindowsTemporaryPlan) bool { - if strings.TrimSpace(plan.BootstrapScriptSHA256) == "" { - return false +func windowsTemporaryRequiredEvidenceComplete(evidence []string) bool { + required := windowsTemporaryRequiredEvidence() + return len(evidence) == len(required) && missingWindowsTemporaryRequiredEvidence(evidence) == "" +} + +func missingWindowsTemporaryRequiredEvidence(evidence []string) string { + seen := make(map[string]bool, len(evidence)) + for _, item := range evidence { + seen[item] = true } - if strings.TrimSpace(plan.BootstrapScriptPath) != "" { - hash, err := fileSHA256(plan.BootstrapScriptPath) - if err == nil { - return strings.EqualFold(hash, plan.BootstrapScriptSHA256) + var missing []string + requiredEvidence := windowsTemporaryRequiredEvidence() + for _, required := range requiredEvidence { + if !seen[required] { + missing = append(missing, requiredEvidenceName(required)) } } - return strings.TrimSpace(plan.BootstrapScriptURL) != "" && isHexSHA256(plan.BootstrapScriptSHA256) + if len(evidence) != len(requiredEvidence) && len(missing) == 0 { + missing = append(missing, "unexpected-or-duplicate-evidence") + } + return strings.Join(missing, ",") +} + +func requiredEvidenceName(requirement string) string { + if strings.Contains(requirement, "cold-layered-run.json") { + return "cold-layered-run.json" + } + if strings.Contains(requirement, "warm-layered-run.json") { + return "warm-layered-run.json" + } + words := strings.Fields(requirement) + if len(words) == 0 { + return "required-evidence" + } + return strings.Trim(words[0], "`.,") +} + +func resolvePlanPath(baseDir, path, fallback string) string { + if filepath.IsAbs(path) { + return path + } + if strings.TrimSpace(path) != "" { + return filepath.Join(baseDir, path) + } + return filepath.Join(baseDir, fallback) +} + +func fileModePrivate(path string) bool { + return windowsentry.ValidatePrivatePath(path, false) == nil } func commandNamed(commands []WindowsAcceptanceCommand, name string) bool { diff --git a/internal/acceptance/windows_temporary_verify_test.go b/internal/acceptance/windows_temporary_verify_test.go new file mode 100644 index 0000000..821faf2 --- /dev/null +++ b/internal/acceptance/windows_temporary_verify_test.go @@ -0,0 +1,231 @@ +package acceptance + +import ( + "bytes" + "strings" + "testing" +) + +func TestValidateWindowsLayeredRunReportAcceptsColdAndWarmEvidence(t *testing.T) { + for _, fromCache := range []bool{false, true} { + report := windowsLayeredRunReportForTest(fromCache) + content := marshalWindowsLayeredRunReportForTest(t, report) + if err := validateWindowsLayeredRunReport(content, fromCache); err != nil { + t.Fatalf("expected from_cache=%t report to validate: %v", fromCache, err) + } + } +} + +func TestValidateWindowsLayeredRunReportAcceptsResumedColdEvidence(t *testing.T) { + report := windowsLayeredRunReportForTest(false) + report["resumed"] = true + if err := validateWindowsLayeredRunReport(marshalWindowsLayeredRunReportForTest(t, report), false); err != nil { + t.Fatalf("expected a resumed cold download report to validate: %v", err) + } +} + +func TestValidateWindowsLayeredRunReportRejectsResumedWarmEvidence(t *testing.T) { + report := windowsLayeredRunReportForTest(true) + report["resumed"] = true + err := validateWindowsLayeredRunReport(marshalWindowsLayeredRunReportForTest(t, report), true) + if err == nil { + t.Fatal("expected a cache-hit report with resumed=true to fail") + } + if !strings.Contains(strings.ToLower(err.Error()), "resumed") { + t.Fatalf("error %q does not explain resumed cache-hit failure", err) + } +} + +func TestWindowsTemporaryRequiredEvidenceRejectsArbitraryBaseEntries(t *testing.T) { + evidence := []string{ + "arbitrary evidence one", + "arbitrary evidence two", + "arbitrary evidence three", + "arbitrary evidence four", + "arbitrary evidence five", + windowsTemporaryColdLayeredRunEvidence, + windowsTemporaryWarmLayeredRunEvidence, + } + if windowsTemporaryRequiredEvidenceComplete(evidence) { + t.Fatal("expected arbitrary base evidence entries to fail the Windows temporary plan contract") + } +} + +func TestValidateWindowsLayeredRunReportRejectsInvalidEvidence(t *testing.T) { + tests := []struct { + name string + expectedFromCache bool + mutate func(map[string]any) + wantError string + }{ + { + name: "cold report claims cache hit", + expectedFromCache: false, + mutate: func(report map[string]any) { + report["from_cache"] = true + }, + wantError: "from_cache", + }, + { + name: "cold report omits cache field", + expectedFromCache: false, + mutate: func(report map[string]any) { + delete(report, "from_cache") + }, + wantError: "from_cache", + }, + { + name: "warm report claims cache miss", + expectedFromCache: true, + mutate: func(report map[string]any) { + report["from_cache"] = false + }, + wantError: "from_cache", + }, + { + name: "wrong schema", + expectedFromCache: false, + mutate: func(report map[string]any) { + report["schema_version"] = "rdev.layered-run-report.v0" + }, + wantError: "schema", + }, + { + name: "unknown benign field", + expectedFromCache: false, + mutate: func(report map[string]any) { + report["unexpected"] = "safe" + }, + wantError: "unknown field", + }, + { + name: "missing signature verification", + expectedFromCache: false, + mutate: func(report map[string]any) { + removeWindowsLayeredRunStageForTest(report, "signature-verification") + }, + wantError: "signature-verification", + }, + { + name: "unexpected stage name", + expectedFromCache: true, + mutate: func(report map[string]any) { + stages, _ := report["stages"].([]any) + stage, _ := stages[2].(map[string]any) + stage["name"] = "runtime-resolution" + }, + wantError: "runtime-download", + }, + { + name: "stages out of order", + expectedFromCache: false, + mutate: func(report map[string]any) { + stages, _ := report["stages"].([]any) + stages[0], stages[1] = stages[1], stages[0] + }, + wantError: "manifest-fetch", + }, + { + name: "negative duration", + expectedFromCache: false, + mutate: func(report map[string]any) { + setWindowsLayeredRunStageDurationForTest(report, "manifest-fetch", -1) + }, + wantError: "duration", + }, + { + name: "missing duration", + expectedFromCache: false, + mutate: func(report map[string]any) { + stages, _ := report["stages"].([]any) + stage, _ := stages[0].(map[string]any) + delete(stage, "duration_ms") + }, + wantError: "duration", + }, + { + name: "ticket text", + expectedFromCache: false, + mutate: func(report map[string]any) { + report["note"] = "ticket_code=ABCD-1234" + }, + wantError: "ticket", + }, + { + name: "gateway text", + expectedFromCache: true, + mutate: func(report map[string]any) { + report["note"] = "gateway_url=https://api.example.com/v1" + }, + wantError: "gateway", + }, + { + name: "token text", + expectedFromCache: false, + mutate: func(report map[string]any) { + report["note"] = "token=secret-value" + }, + wantError: "token", + }, + { + name: "private Windows path", + expectedFromCache: false, + mutate: func(report map[string]any) { + report["note"] = `C:\Users\Alice\AppData\Local\RemoteDevSkillkit` + }, + wantError: "private path", + }, + { + name: "pre-redacted content", + expectedFromCache: false, + mutate: func(report map[string]any) { + report["note"] = "[REDACTED:secret_json]" + }, + wantError: "redacted", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + report := windowsLayeredRunReportForTest(test.expectedFromCache) + test.mutate(report) + content := marshalWindowsLayeredRunReportForTest(t, report) + err := validateWindowsLayeredRunReport(content, test.expectedFromCache) + if err == nil { + t.Fatal("expected layered run report validation to fail") + } + if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.wantError)) { + t.Fatalf("error %q does not explain %q failure", err, test.wantError) + } + }) + } +} + +func TestValidateWindowsLayeredRunReportRejectsDuplicateKeys(t *testing.T) { + content := marshalWindowsLayeredRunReportForTest(t, windowsLayeredRunReportForTest(false)) + content = bytes.Replace(content, []byte(`"from_cache": false`), []byte(`"from_cache": true, "from_cache": false`), 1) + err := validateWindowsLayeredRunReport(content, false) + if err == nil { + t.Fatal("expected duplicate from_cache keys to fail") + } + if !strings.Contains(strings.ToLower(err.Error()), "duplicate") { + t.Fatalf("error %q does not explain duplicate key failure", err) + } +} + +func TestValidateWindowsLayeredRunReportRejectsCaseAliasFields(t *testing.T) { + content := marshalWindowsLayeredRunReportForTest(t, windowsLayeredRunReportForTest(false)) + content = bytes.Replace( + content, + []byte(`"asset_id": "rdev-host-windows-amd64"`), + []byte(`"asset_id": "https://private.example.invalid/session/ABCD-1234", "ASSET_ID": "rdev-host-windows-amd64"`), + 1, + ) + err := validateWindowsLayeredRunReport(content, false) + if err == nil { + t.Fatal("expected case-aliased asset_id fields to fail") + } + if !strings.Contains(strings.ToLower(err.Error()), "field") { + t.Fatalf("error %q does not explain exact field-name failure", err) + } +} diff --git a/internal/agentinvite/invite.go b/internal/agentinvite/invite.go index 79721bd..1423b42 100644 --- a/internal/agentinvite/invite.go +++ b/internal/agentinvite/invite.go @@ -13,46 +13,51 @@ import ( const SchemaVersion = "rdev.agent-invite.v1" type Options struct { - GatewayURL string - JoinURL string - ManifestURL string - ManifestRootPublicKey string - Ticket model.Ticket - Transport string - NetworkScope string - AuthorityProfile string - Once bool - RequireHostActivation bool - RdevCommand string - CreatedAt time.Time + GatewayURL string + JoinURL string + ManifestURL string + ManifestRootPublicKey string + LayeredAssetsManifestURL string + LayeredReleaseRootPublicKey string + LayeredReleaseVersion string + Ticket model.Ticket + Transport string + NetworkScope string + AuthorityProfile string + Once bool + RequireHostActivation bool + CreatedAt time.Time } type Invite struct { - SchemaVersion string `json:"schema_version"` - GatewayURL string `json:"gateway_url"` - JoinURL string `json:"join_url"` - ManifestURL string `json:"manifest_url"` - ManifestRootPublicKey string `json:"manifest_root_public_key,omitempty"` - Ticket model.Ticket `json:"ticket"` - Transport string `json:"transport"` - TransportPlan TransportPlan `json:"transport_plan"` - ConnectionPlan ConnectionPlan `json:"connection_plan"` - AuthorityProfile AuthorityProfile `json:"authority_profile"` - ConnectionEntry ConnectionEntry `json:"connection_entry"` - ConnectionEntryPlan ConnectionEntryPlan `json:"connection_entry_plan"` - HostContextPlan HostContextPlan `json:"host_context_plan"` - ProvisioningPlan ProvisioningPlan `json:"agent_provisioning_plan"` - CollaborationPlan CollaborationPlan `json:"agent_collaboration_plan"` - LocalizationPlan LocalizationPlan `json:"localization_plan"` - ManagedDevPlan ManagedDevPlan `json:"managed_development_plan"` - HostCommand string `json:"host_command"` - FallbackCommands []string `json:"fallback_commands"` - HumanNextActions []string `json:"human_next_actions"` - AgentNextActions []string `json:"agent_next_actions"` - ConnectivityChecks []string `json:"connectivity_checks"` - MCPTools map[string]string `json:"mcp_tools"` - RequiresHumanAction []string `json:"requires_human_action"` - CreatedAt time.Time `json:"created_at"` + SchemaVersion string `json:"schema_version"` + GatewayURL string `json:"gateway_url"` + JoinURL string `json:"join_url"` + ManifestURL string `json:"manifest_url"` + ManifestRootPublicKey string `json:"manifest_root_public_key,omitempty"` + LayeredAssetsManifestURL string `json:"layered_assets_manifest_url,omitempty"` + LayeredReleaseRootPublicKey string `json:"layered_release_root_public_key,omitempty"` + LayeredReleaseVersion string `json:"layered_release_version,omitempty"` + Ticket model.Ticket `json:"ticket"` + Transport string `json:"transport"` + TransportPlan TransportPlan `json:"transport_plan"` + ConnectionPlan ConnectionPlan `json:"connection_plan"` + AuthorityProfile AuthorityProfile `json:"authority_profile"` + ConnectionEntry ConnectionEntry `json:"connection_entry"` + ConnectionEntryPlan ConnectionEntryPlan `json:"connection_entry_plan"` + HostContextPlan HostContextPlan `json:"host_context_plan"` + ProvisioningPlan ProvisioningPlan `json:"agent_provisioning_plan"` + CollaborationPlan CollaborationPlan `json:"agent_collaboration_plan"` + LocalizationPlan LocalizationPlan `json:"localization_plan"` + ManagedDevPlan ManagedDevPlan `json:"managed_development_plan"` + HostCommand string `json:"host_command"` + FallbackCommands []string `json:"fallback_commands"` + HumanNextActions []string `json:"human_next_actions"` + AgentNextActions []string `json:"agent_next_actions"` + ConnectivityChecks []string `json:"connectivity_checks"` + MCPTools map[string]string `json:"mcp_tools"` + RequiresHumanAction []string `json:"requires_human_action"` + CreatedAt time.Time `json:"created_at"` } type TransportPlan struct { @@ -264,28 +269,24 @@ func New(opts Options) (Invite, error) { if !validAuthorityProfile(authorityProfile) { return Invite{}, fmt.Errorf("unsupported authority profile %q", authorityProfile) } - rdevCommand := strings.TrimSpace(opts.RdevCommand) - if rdevCommand == "" { - rdevCommand = "rdev" - } createdAt := opts.CreatedAt if createdAt.IsZero() { createdAt = time.Now().UTC() } manifestRootPublicKey := strings.TrimSpace(opts.ManifestRootPublicKey) - hostCommand := hostServeCommand(rdevCommand, manifestURL, manifestRootPublicKey, transport, opts.Once) - transportPlan := newTransportPlan(rdevCommand, manifestURL, manifestRootPublicKey, transport, opts.Once) + connectionEntry := newConnectionEntry(joinURL) + hostCommand := connectionEntry.OneLineCommands["macos_linux_sh"] + transportPlan := newTransportPlan(hostCommand, transport) connectionPlan := newConnectionPlan(gatewayURL, networkScope) authority := newAuthorityProfile(authorityProfile) - connectionEntry := newConnectionEntry(joinURL) connectionEntryPlan := newConnectionEntryPlan(gatewayURL, manifestURL, manifestRootPublicKey, transport) hostContextPlan := newHostContextPlan() provisioningPlan := newProvisioningPlan() collaborationPlan := newCollaborationPlan() localizationPlan := newLocalizationPlan() managedDevPlan := newManagedDevPlan() - fallbackCommands := fallbackCommandsFromPlan(transportPlan, transport) + var fallbackCommands []string agentActions := []string{ "Probe the local runtime with rdev doctor and inspect configured gateway reachability before giving the human a connection entry link, script, or package.", @@ -299,25 +300,28 @@ func New(opts Options) (Invite, error) { } return Invite{ - SchemaVersion: SchemaVersion, - GatewayURL: gatewayURL, - JoinURL: joinURL, - ManifestURL: manifestURL, - ManifestRootPublicKey: manifestRootPublicKey, - Ticket: opts.Ticket, - Transport: transport, - TransportPlan: transportPlan, - ConnectionPlan: connectionPlan, - AuthorityProfile: authority, - ConnectionEntry: connectionEntry, - ConnectionEntryPlan: connectionEntryPlan, - HostContextPlan: hostContextPlan, - ProvisioningPlan: provisioningPlan, - CollaborationPlan: collaborationPlan, - LocalizationPlan: localizationPlan, - ManagedDevPlan: managedDevPlan, - HostCommand: hostCommand, - FallbackCommands: fallbackCommands, + SchemaVersion: SchemaVersion, + GatewayURL: gatewayURL, + JoinURL: joinURL, + ManifestURL: manifestURL, + ManifestRootPublicKey: manifestRootPublicKey, + LayeredAssetsManifestURL: strings.TrimSpace(opts.LayeredAssetsManifestURL), + LayeredReleaseRootPublicKey: strings.TrimSpace(opts.LayeredReleaseRootPublicKey), + LayeredReleaseVersion: strings.TrimSpace(opts.LayeredReleaseVersion), + Ticket: opts.Ticket, + Transport: transport, + TransportPlan: transportPlan, + ConnectionPlan: connectionPlan, + AuthorityProfile: authority, + ConnectionEntry: connectionEntry, + ConnectionEntryPlan: connectionEntryPlan, + HostContextPlan: hostContextPlan, + ProvisioningPlan: provisioningPlan, + CollaborationPlan: collaborationPlan, + LocalizationPlan: localizationPlan, + ManagedDevPlan: managedDevPlan, + HostCommand: hostCommand, + FallbackCommands: fallbackCommands, HumanNextActions: []string{ "Open connection_entry.entry_url or run the generated connection entry package on the target machine that needs help.", "Keep the visible connection session open until the Agent reports completion.", @@ -632,12 +636,12 @@ func newConnectionEntry(joinURL string) ConnectionEntry { AutomationLevel: "one-entry-minimal-steps-after-consent", PackageCatalog: model.NewConnectionEntryPackageCatalog(joinURL), OneLineCommands: map[string]string{ - "macos_linux_sh": "curl -fsSL " + shellQuote(bootstrapBase+"/bootstrap.sh") + " | sh", - "windows_powershell": "powershell -NoProfile -Command \"irm '" + powershellSingleQuoteValue(bootstrapBase+"/bootstrap.ps1") + "' | iex\"", + "macos_linux_sh": "curl -fsSL " + shellQuote(bootstrapBase+"/bootstrap.sh") + " | sh # rdev-bootstrap layered entry", + "windows_powershell": "powershell -NoProfile -Command \"irm '" + powershellSingleQuoteValue(bootstrapBase+"/bootstrap.ps1") + "' | iex; # rdev-bootstrap layered entry\"", }, InstallPrerequisites: []string{ "preferred: a signed self-contained Remote Dev Skillkit connection entry package is available for the target OS", - "fallback: rdev binary is already installed or made available by the published connection entry package", + "fallback: the platform rdev-bootstrap is supplied by the published connection entry package or bootstrap script", "target-side user runs one visible connection entry on the machine that needs help", "outbound HTTPS or configured fallback connectivity to the gateway", }, @@ -698,7 +702,7 @@ func newConnectionEntryPlan(gatewayURL, manifestURL, manifestRootPublicKey, tran "linux-amd64/linux-arm64 tar.gz or AppImage-style package containing rdev, release-bundle.json, checksums, and a visible connection launcher", }, RequiredContents: []string{ - "target-platform rdev/rdev-host binary", + "target-platform rdev-bootstrap and signed core runtime", "signed release-bundle.json and required artifact manifests", "pinned release root public key", "pinned manifest root public key: " + placeholderIfEmpty(manifestRootPublicKey, ""), @@ -837,7 +841,7 @@ func validAuthorityProfile(profile string) bool { } } -func newTransportPlan(rdevCommand, manifestURL, manifestRootPublicKey, transport string, once bool) TransportPlan { +func newTransportPlan(connectionEntryCommand, transport string) TransportPlan { transports := []string{transport} if transport == "auto" { transports = []string{"wss", "long-poll", "poll"} @@ -848,7 +852,7 @@ func newTransportPlan(rdevCommand, manifestURL, manifestRootPublicKey, transport Transport: candidate, Priority: i + 1, Reason: transportReason(candidate), - HostCommand: hostServeCommand(rdevCommand, manifestURL, manifestRootPublicKey, candidate, once), + HostCommand: connectionEntryCommand, FailureHints: transportFailureHints(candidate), }) } @@ -1137,33 +1141,6 @@ func gatewayLooksLAN(rawURL string) bool { return ip.IsPrivate() || ip.IsLinkLocalUnicast() } -func fallbackCommandsFromPlan(plan TransportPlan, selected string) []string { - if selected != "auto" { - return nil - } - values := make([]string, 0, len(plan.Candidates)-1) - for _, candidate := range plan.Candidates { - if candidate.Transport == "wss" { - continue - } - values = append(values, candidate.HostCommand) - } - return values -} - -func hostServeCommand(rdevCommand, manifestURL, manifestRootPublicKey, transport string, once bool) string { - command := fmt.Sprintf("%s host serve --manifest-url %s --transport %s", rdevCommand, shellQuote(manifestURL), shellQuote(transport)) - if strings.TrimSpace(manifestRootPublicKey) != "" { - command += " --manifest-root-public-key " + shellQuote(manifestRootPublicKey) - } - if once { - command += " --once" - } else { - command += " --once=false --max-tasks 0" - } - return command -} - func transportReason(transport string) string { switch transport { case "wss": diff --git a/internal/agentinvite/invite_test.go b/internal/agentinvite/invite_test.go index 7f81b29..bae9e5f 100644 --- a/internal/agentinvite/invite_test.go +++ b/internal/agentinvite/invite_test.go @@ -3,14 +3,49 @@ package agentinvite import ( "strings" "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/model" ) -func TestHostServeCommandUsesContinuousTaskLoop(t *testing.T) { - command := hostServeCommand("rdev", "https://gateway.test/manifest", "manifest-dev:key", "auto", false) - if !strings.Contains(command, "--once=false") { - t.Fatalf("expected continuous host command, got %q", command) +func TestConnectionEntryUsesBootstrapScript(t *testing.T) { + entry := newConnectionEntry("https://gateway.test/join/TARGET") + for platform, command := range entry.OneLineCommands { + if !strings.Contains(command, "rdev-bootstrap") { + t.Fatalf("%s command does not identify the bootstrap boundary: %q", platform, command) + } + } +} + +func TestConnectionEntryCommandsUseBootstrapOnly(t *testing.T) { + invite, err := New(Options{ + GatewayURL: "https://gateway.example.test", + ManifestURL: "https://gateway.example.test/v1/tickets/TARGET/manifest", + ManifestRootPublicKey: "root:" + strings.Repeat("a", 43), + Ticket: testInviteTicket(t), + Transport: "auto", + }) + if err != nil { + t.Fatal(err) } - if !strings.Contains(command, "--max-tasks 0") { - t.Fatalf("expected unlimited task loop, got %q", command) + for platform, command := range invite.ConnectionEntry.OneLineCommands { + if !strings.Contains(command, "rdev-bootstrap") { + t.Fatalf("%s command must invoke rdev-bootstrap: %q", platform, command) + } + for _, forbidden := range []string{"host serve", "rdev-host", "command -v rdev", "Get-Command rdev", "rdev-bootstrap upgrade"} { + if strings.Contains(command, forbidden) { + t.Fatalf("%s command contains legacy path %q: %q", platform, forbidden, command) + } + } + } +} + +func testInviteTicket(t *testing.T) model.Ticket { + t.Helper() + now := time.Now().UTC() + ticket, err := model.NewTicket(model.HostModeAttendedTemporary, 600, []string{"shell.user"}, "test", now) + if err != nil { + t.Fatal(err) } + return ticket } diff --git a/internal/assetdownload/atomic_replace_other.go b/internal/assetdownload/atomic_replace_other.go new file mode 100644 index 0000000..eaac299 --- /dev/null +++ b/internal/assetdownload/atomic_replace_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package assetdownload + +import "os" + +func atomicReplace(sourcePath, destinationPath string) error { + return os.Rename(sourcePath, destinationPath) +} diff --git a/internal/assetdownload/atomic_replace_windows.go b/internal/assetdownload/atomic_replace_windows.go new file mode 100644 index 0000000..08f76f2 --- /dev/null +++ b/internal/assetdownload/atomic_replace_windows.go @@ -0,0 +1,9 @@ +//go:build windows && !rdev_bootstrap_focused + +package assetdownload + +import "golang.org/x/sys/windows" + +func atomicReplace(sourcePath, destinationPath string) error { + return windows.Rename(sourcePath, destinationPath) +} diff --git a/internal/assetdownload/atomic_replace_windows_focused.go b/internal/assetdownload/atomic_replace_windows_focused.go new file mode 100644 index 0000000..f784e32 --- /dev/null +++ b/internal/assetdownload/atomic_replace_windows_focused.go @@ -0,0 +1,38 @@ +//go:build windows && rdev_bootstrap_focused + +package assetdownload + +import ( + "syscall" + "unsafe" +) + +const ( + moveFileReplaceExisting = 0x1 + moveFileWriteThrough = 0x8 +) + +var moveFileExW = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") + +func atomicReplace(sourcePath, destinationPath string) error { + source, err := syscall.UTF16PtrFromString(sourcePath) + if err != nil { + return err + } + destination, err := syscall.UTF16PtrFromString(destinationPath) + if err != nil { + return err + } + result, _, callErr := moveFileExW.Call( + uintptr(unsafe.Pointer(source)), + uintptr(unsafe.Pointer(destination)), + moveFileReplaceExisting|moveFileWriteThrough, + ) + if result != 0 { + return nil + } + if callErr != syscall.Errno(0) { + return callErr + } + return syscall.EINVAL +} diff --git a/internal/assetdownload/downloader.go b/internal/assetdownload/downloader.go index 633e865..565d1ac 100644 --- a/internal/assetdownload/downloader.go +++ b/internal/assetdownload/downloader.go @@ -7,13 +7,19 @@ import ( "errors" "fmt" "io" - "net/http" "os" "path/filepath" "strings" "time" ) +const ( + downloadStatusOK = 200 + downloadStatusPartialContent = 206 + downloadStatusRequestTimeout = 408 + downloadStatusTooManyRequests = 429 +) + type Mirror struct { URL string Kind string @@ -25,7 +31,8 @@ type Options struct { OutputPath string CachePath string ExpectedSHA256 string - Client *http.Client + ExpectedSize int64 + Transport Transport MaxAttempts int } @@ -70,12 +77,18 @@ func Download(ctx context.Context, opts Options) (Result, error) { if outputPath == "" { return Result{}, fmt.Errorf("output path is required") } + if opts.ExpectedSize < 0 { + return Result{}, fmt.Errorf("expected size must not be negative") + } + if opts.Transport == nil { + return Result{}, fmt.Errorf("transport is required") + } if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { return Result{}, err } result := Result{OutputPath: outputPath} if cachePath := strings.TrimSpace(opts.CachePath); cachePath != "" { - if sha, size, ok := verifiedFile(cachePath, expected); ok { + if sha, size, ok := verifiedFile(cachePath, expected, opts.ExpectedSize); ok { if err := copyVerifiedFile(cachePath, outputPath); err != nil { return Result{}, err } @@ -87,7 +100,7 @@ func Download(ctx context.Context, opts Options) (Result, error) { return result, nil } } - if sha, size, ok := verifiedFile(outputPath, expected); ok { + if sha, size, ok := verifiedFile(outputPath, expected, opts.ExpectedSize); ok { result.SourceURL = "output" result.FromCache = true result.Bytes = size @@ -95,14 +108,31 @@ func Download(ctx context.Context, opts Options) (Result, error) { result.Transcript = append(result.Transcript, Event{Phase: "output-hit", Bytes: size}) return result, nil } + partPath := outputPath + ".part" + if opts.ExpectedSize > 0 { + if info, err := os.Stat(partPath); err == nil && info.Size() == opts.ExpectedSize { + sha, size, ok := verifiedFile(partPath, expected, opts.ExpectedSize) + if ok { + if err := promotePart(partPath, outputPath); err != nil { + return Result{}, err + } + if cachePath := strings.TrimSpace(opts.CachePath); cachePath != "" && !sameCleanPath(cachePath, outputPath) { + _ = copyVerifiedFile(outputPath, cachePath) + } + result.SourceURL = "partial" + result.Resumed = true + result.Bytes = size + result.SHA256 = sha + result.Transcript = append(result.Transcript, Event{Phase: "partial-hit", Bytes: size}) + return result, nil + } + _ = os.Remove(partPath) + } + } mirrors := normalizedMirrors(opts.Mirrors) if len(mirrors) == 0 { return Result{}, fmt.Errorf("at least one mirror is required") } - client := opts.Client - if client == nil { - client = http.DefaultClient - } maxAttempts := opts.MaxAttempts if maxAttempts <= 0 { maxAttempts = 3 @@ -115,7 +145,7 @@ func Download(ctx context.Context, opts Options) (Result, error) { return Result{}, err } } - resumed, err := downloadOnce(ctx, client, mirror.URL, outputPath) + resumed, err := downloadOnce(ctx, opts.Transport, mirror.URL, outputPath, opts.ExpectedSize) if err != nil { lastErr = err result.Transcript = append(result.Transcript, Event{Phase: "download-error", URL: mirror.URL, Message: err.Error()}) @@ -124,12 +154,19 @@ func Download(ctx context.Context, opts Options) (Result, error) { } continue } - sha, size, ok := verifiedFile(outputPath+".part", expected) - if !ok { + sha, size, verifyErr := fileSHA256(partPath) + if verifyErr != nil { + return Result{}, verifyErr + } + if opts.ExpectedSize > 0 && size != opts.ExpectedSize { + _ = os.Remove(partPath) + return Result{}, fmt.Errorf("downloaded size does not match expected size: got %d bytes, expected %d", size, opts.ExpectedSize) + } + if normalizeSHA256(sha) != expected { _ = os.Remove(outputPath + ".part") return Result{}, fmt.Errorf("checksum mismatch for %s", mirror.URL) } - if err := promotePart(outputPath+".part", outputPath); err != nil { + if err := promotePart(partPath, outputPath); err != nil { return Result{}, err } if cachePath := strings.TrimSpace(opts.CachePath); cachePath != "" && !sameCleanPath(cachePath, outputPath) { @@ -163,35 +200,48 @@ func normalizedMirrors(values []Mirror) []Mirror { return out } -func downloadOnce(ctx context.Context, client *http.Client, rawURL, outputPath string) (bool, error) { +func downloadOnce(ctx context.Context, transport Transport, rawURL, outputPath string, expectedSize int64) (_ bool, resultErr error) { partPath := outputPath + ".part" var offset int64 if info, err := os.Stat(partPath); err == nil { offset = info.Size() } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return false, err + if expectedSize > 0 && offset > expectedSize { + _ = os.Remove(partPath) + return false, fmt.Errorf("partial download exceeds expected size: got %d bytes, expected %d", offset, expectedSize) } - if offset > 0 { - req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset)) - } - resp, err := client.Do(req) + resp, err := transport.Fetch(ctx, TransportRequest{ + URL: rawURL, + Offset: offset, + MaxBytes: expectedSize, + }) if err != nil { + if resp.Body != nil { + err = errors.Join(err, wrapResponseBodyCloseError(resp.Body.Close())) + } return false, err } - defer resp.Body.Close() - appendMode := offset > 0 && resp.StatusCode == http.StatusPartialContent - if offset > 0 && resp.StatusCode == http.StatusOK { + if resp.Body == nil { + return false, fmt.Errorf("download response body is required") + } + defer func() { + resultErr = errors.Join(resultErr, wrapResponseBodyCloseError(resp.Body.Close())) + }() + appendMode := offset > 0 && resp.StatusCode == downloadStatusPartialContent + if offset > 0 && resp.StatusCode == downloadStatusOK { offset = 0 } - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { - err := fmt.Errorf("download failed: %s", resp.Status) + if resp.StatusCode != downloadStatusOK && resp.StatusCode != downloadStatusPartialContent { + err := fmt.Errorf("download failed: status %d", resp.StatusCode) if retryableStatus(resp.StatusCode) { return false, retryableError{err: err} } return false, err } + if expectedSize > 0 && resp.ContentLength >= 0 && offset+resp.ContentLength > expectedSize { + _ = os.Remove(partPath) + return false, fmt.Errorf("download exceeds expected size: got at least %d bytes, expected %d", offset+resp.ContentLength, expectedSize) + } flags := os.O_CREATE | os.O_WRONLY if appendMode { flags |= os.O_APPEND @@ -202,19 +252,37 @@ func downloadOnce(ctx context.Context, client *http.Client, rawURL, outputPath s if err != nil { return false, err } - if _, err := io.Copy(file, resp.Body); err != nil { + reader := io.Reader(resp.Body) + remaining := int64(0) + if expectedSize > 0 { + remaining = expectedSize - offset + reader = io.LimitReader(resp.Body, remaining+1) + } + written, err := io.Copy(file, reader) + if err != nil { _ = file.Close() return appendMode, err } + if expectedSize > 0 && written > remaining { + _ = file.Close() + _ = os.Remove(partPath) + return appendMode, fmt.Errorf("download exceeds expected size: expected %d bytes", expectedSize) + } if err := file.Close(); err != nil { return appendMode, err } return appendMode, nil } +func wrapResponseBodyCloseError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("close download response body: %w", err) +} + func promotePart(partPath, outputPath string) error { - _ = os.Remove(outputPath) - return os.Rename(partPath, outputPath) + return atomicReplace(partPath, outputPath) } func copyVerifiedFile(src, dst string) error { @@ -243,16 +311,19 @@ func copyVerifiedFile(src, dst string) error { _ = os.Remove(tmp) return err } - _ = os.Remove(dst) - return os.Rename(tmp, dst) + if err := atomicReplace(tmp, dst); err != nil { + _ = os.Remove(tmp) + return err + } + return nil } -func verifiedFile(path, expected string) (string, int64, bool) { +func verifiedFile(path, expected string, expectedSize int64) (string, int64, bool) { sha, size, err := fileSHA256(path) if err != nil { return "", 0, false } - if normalizeSHA256(sha) != expected { + if normalizeSHA256(sha) != expected || expectedSize > 0 && size != expectedSize { return sha, size, false } return sha, size, true @@ -318,8 +389,8 @@ func retryable(err error) bool { } func retryableStatus(status int) bool { - return status == http.StatusRequestTimeout || - status == http.StatusTooManyRequests || + return status == downloadStatusRequestTimeout || + status == downloadStatusTooManyRequests || status >= 500 } diff --git a/internal/assetdownload/downloader_test.go b/internal/assetdownload/downloader_test.go index 658fd32..4e3bfc9 100644 --- a/internal/assetdownload/downloader_test.go +++ b/internal/assetdownload/downloader_test.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package assetdownload import ( @@ -5,9 +7,11 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -20,6 +24,115 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +type transportFunc func(context.Context, TransportRequest) (TransportResponse, error) + +func (f transportFunc) Fetch(ctx context.Context, req TransportRequest) (TransportResponse, error) { + return f(ctx, req) +} + +type recordingTransport struct { + requests []TransportRequest + fetch transportFunc +} + +func (t *recordingTransport) Fetch(ctx context.Context, req TransportRequest) (TransportResponse, error) { + t.requests = append(t.requests, req) + return t.fetch(ctx, req) +} + +func TestDownloadTransportIsRequired(t *testing.T) { + dir := t.TempDir() + _, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: filepath.Join(dir, "helper.exe"), + ExpectedSHA256: sha256String([]byte("helper")), + }) + if err == nil || !strings.Contains(err.Error(), "transport is required") { + t.Fatalf("expected required transport error, got %v", err) + } +} + +func TestDownloadTransportStartsAtOffsetZero(t *testing.T) { + dir := t.TempDir() + payload := []byte("fresh helper") + outPath := filepath.Join(dir, "helper.exe") + transport := &recordingTransport{fetch: func(_ context.Context, req TransportRequest) (TransportResponse, error) { + return testTransportResponse(http.StatusOK, payload), nil + }} + + result, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String(payload), + ExpectedSize: int64(len(payload)), + Transport: transport, + MaxAttempts: 1, + }) + if err != nil { + t.Fatal(err) + } + if len(transport.requests) != 1 { + t.Fatalf("expected one transport request, got %#v", transport.requests) + } + request := transport.requests[0] + if request.URL != "https://mirror.example.invalid/helper.exe" || request.Offset != 0 || request.MaxBytes != int64(len(payload)) { + t.Fatalf("unexpected initial transport request: %#v", request) + } + if result.SourceURL != request.URL || result.Resumed { + t.Fatalf("unexpected initial download result: %#v", result) + } +} + +func TestDownloadTransportValidatesOptions(t *testing.T) { + transport := transportFunc(func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + t.Fatal("invalid options must fail before fetching") + return TransportResponse{}, nil + }) + validDigest := sha256String([]byte("helper")) + tests := []struct { + name string + options func(string) Options + message string + }{ + { + name: "digest", + options: func(outputPath string) Options { + return Options{OutputPath: outputPath, Transport: transport} + }, + message: "expected sha256 is required", + }, + { + name: "output path", + options: func(_ string) Options { + return Options{ExpectedSHA256: validDigest, Transport: transport} + }, + message: "output path is required", + }, + { + name: "expected size", + options: func(outputPath string) Options { + return Options{OutputPath: outputPath, ExpectedSHA256: validDigest, ExpectedSize: -1, Transport: transport} + }, + message: "expected size must not be negative", + }, + { + name: "mirrors", + options: func(outputPath string) Options { + return Options{Mirrors: []Mirror{{URL: " "}}, OutputPath: outputPath, ExpectedSHA256: validDigest, Transport: transport} + }, + message: "at least one mirror is required", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := Download(context.Background(), test.options(filepath.Join(t.TempDir(), "helper.exe"))) + if err == nil || !strings.Contains(err.Error(), test.message) { + t.Fatalf("expected %q, got %v", test.message, err) + } + }) + } +} + func TestDownloadReusesVerifiedCache(t *testing.T) { ctx := context.Background() dir := t.TempDir() @@ -35,10 +148,10 @@ func TestDownloadReusesVerifiedCache(t *testing.T) { OutputPath: outPath, CachePath: cachePath, ExpectedSHA256: sha256String(payload), - Client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - t.Fatalf("cache hit should not make HTTP request to %s", req.URL.String()) - return nil, nil - })}, + Transport: transportFunc(func(_ context.Context, req TransportRequest) (TransportResponse, error) { + t.Fatalf("cache hit should not make transport request to %s", req.URL) + return TransportResponse{}, nil + }), }) if err != nil { t.Fatal(err) @@ -55,6 +168,36 @@ func TestDownloadReusesVerifiedCache(t *testing.T) { } } +func TestDownloadTransportReusesVerifiedOutput(t *testing.T) { + dir := t.TempDir() + payload := []byte("verified output") + outPath := filepath.Join(dir, "helper.exe") + if err := os.WriteFile(outPath, payload, 0o600); err != nil { + t.Fatal(err) + } + transport := transportFunc(func(_ context.Context, req TransportRequest) (TransportResponse, error) { + t.Fatalf("verified output must not fetch %s", req.URL) + return TransportResponse{}, nil + }) + + result, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String(payload), + ExpectedSize: int64(len(payload)), + Transport: transport, + }) + if err != nil { + t.Fatal(err) + } + if !result.FromCache || result.SourceURL != "output" || result.Bytes != int64(len(payload)) || result.SHA256 != sha256String(payload) { + t.Fatalf("unexpected verified output result: %#v", result) + } + if len(result.Transcript) != 1 || result.Transcript[0].Phase != "output-hit" || result.Transcript[0].Bytes != int64(len(payload)) { + t.Fatalf("unexpected verified output transcript: %#v", result.Transcript) + } +} + func TestDownloadResumesPartialFileWithRange(t *testing.T) { ctx := context.Background() dir := t.TempDir() @@ -63,26 +206,24 @@ func TestDownloadResumesPartialFileWithRange(t *testing.T) { if err := os.WriteFile(outPath+".part", payload[:6], 0o600); err != nil { t.Fatal(err) } - var sawRange string - client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - sawRange = r.Header.Get("Range") - if sawRange != "bytes=6-" { - t.Fatalf("expected resume range bytes=6-, got %q", sawRange) - } - return testResponse(r, http.StatusPartialContent, payload[6:]), nil - })} + transport := &recordingTransport{fetch: func(_ context.Context, req TransportRequest) (TransportResponse, error) { + return testTransportResponse(http.StatusPartialContent, payload[req.Offset:]), nil + }} result, err := Download(ctx, Options{ Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, OutputPath: outPath, ExpectedSHA256: sha256String(payload), - Client: client, + Transport: transport, }) if err != nil { t.Fatal(err) } - if !result.Resumed || sawRange != "bytes=6-" { - t.Fatalf("expected resumed download with Range, got result=%#v range=%q", result, sawRange) + if len(transport.requests) != 1 || transport.requests[0].Offset != 6 { + t.Fatalf("expected resume from exact partial size, got %#v", transport.requests) + } + if !result.Resumed { + t.Fatalf("expected resumed download, got result=%#v", result) } got, err := os.ReadFile(outPath) if err != nil { @@ -93,22 +234,149 @@ func TestDownloadResumesPartialFileWithRange(t *testing.T) { } } +func TestDownloadHTTPTransportResumesAfterInterruptedResponse(t *testing.T) { + payload := bytes.Repeat([]byte("range-resume-fixture-"), 4096) + half := len(payload) / 2 + requests := 0 + var ranges []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + requests++ + ranges = append(ranges, request.Header.Get("Range")) + if requests == 1 { + connection, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _, _ = fmt.Fprintf(buffer, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nContent-Type: application/octet-stream\r\n\r\n", len(payload)) + _, _ = buffer.Write(payload[:half]) + _ = buffer.Flush() + _ = connection.Close() + return + } + if request.Header.Get("Range") != fmt.Sprintf("bytes=%d-", half) { + t.Errorf("Range = %q, want bytes=%d-", request.Header.Get("Range"), half) + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(payload)-half)) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(payload[half:]) + })) + defer server.Close() + + outPath := filepath.Join(t.TempDir(), "runtime.exe") + result, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: server.URL + "/runtime.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String(payload), + ExpectedSize: int64(len(payload)), + Transport: HTTPTransport{Client: server.Client()}, + MaxAttempts: 2, + }) + if err != nil { + t.Fatal(err) + } + if !result.Resumed || requests != 2 || len(ranges) != 2 || ranges[0] != "" || ranges[1] != fmt.Sprintf("bytes=%d-", half) { + t.Fatalf("unexpected Range recovery: result=%#v requests=%d ranges=%#v", result, requests, ranges) + } + content, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(content, payload) { + t.Fatal("resumed HTTP download bytes changed") + } +} + +func TestDownloadTransportRestartsWhenOffsetResponseIsOK(t *testing.T) { + dir := t.TempDir() + payload := []byte("replacement helper") + outPath := filepath.Join(dir, "helper.exe") + if err := os.WriteFile(outPath+".part", []byte("stale-"), 0o600); err != nil { + t.Fatal(err) + } + transport := &recordingTransport{fetch: func(_ context.Context, req TransportRequest) (TransportResponse, error) { + return testTransportResponse(http.StatusOK, payload), nil + }} + + result, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String(payload), + ExpectedSize: int64(len(payload)), + Transport: transport, + MaxAttempts: 1, + }) + if err != nil { + t.Fatal(err) + } + if len(transport.requests) != 1 || transport.requests[0].Offset != int64(len("stale-")) { + t.Fatalf("expected offset request from partial size, got %#v", transport.requests) + } + if result.Resumed { + t.Fatalf("status 200 response must restart rather than resume: %#v", result) + } + got, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("restart did not replace stale partial: %q", got) + } +} + +func TestDownloadPromotesCompleteVerifiedPartialWithoutRequest(t *testing.T) { + dir := t.TempDir() + payload := []byte("complete verified partial") + outPath := filepath.Join(dir, "runtime.exe") + if err := os.WriteFile(outPath+".part", payload, 0o600); err != nil { + t.Fatal(err) + } + transport := transportFunc(func(_ context.Context, req TransportRequest) (TransportResponse, error) { + t.Fatalf("complete verified partial must not request %s", req.URL) + return TransportResponse{}, nil + }) + + result, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/runtime.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String(payload), + ExpectedSize: int64(len(payload)), + Transport: transport, + }) + if err != nil { + t.Fatal(err) + } + if !result.Resumed || result.FromCache || result.SourceURL != "partial" { + t.Fatalf("unexpected complete partial result: %#v", result) + } + got, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("promoted partial changed content: %q", got) + } + if _, statErr := os.Stat(outPath + ".part"); !os.IsNotExist(statErr) { + t.Fatalf("verified partial was not atomically promoted, stat err=%v", statErr) + } +} + func TestDownloadFallsBackToSecondMirrorAfterEOF(t *testing.T) { ctx := context.Background() dir := t.TempDir() payload := []byte("second mirror payload") outPath := filepath.Join(dir, "helper.exe") attempts := map[string]int{} - client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - attempts[req.URL.Host]++ - if req.URL.Host == "first.example.invalid" { - return nil, io.ErrUnexpectedEOF + transport := transportFunc(func(_ context.Context, req TransportRequest) (TransportResponse, error) { + attempts[req.URL]++ + if req.URL == "https://first.example.invalid/helper.exe" { + return TransportResponse{}, io.ErrUnexpectedEOF } - if req.URL.Host != "second.example.invalid" { - t.Fatalf("unexpected host %s", req.URL.Host) + if req.URL != "https://second.example.invalid/helper.exe" { + t.Fatalf("unexpected URL %s", req.URL) } - return testResponse(req, http.StatusOK, payload), nil - })} + return testTransportResponse(http.StatusOK, payload), nil + }) result, err := Download(ctx, Options{ Mirrors: []Mirror{ @@ -117,32 +385,162 @@ func TestDownloadFallsBackToSecondMirrorAfterEOF(t *testing.T) { }, OutputPath: outPath, ExpectedSHA256: sha256String(payload), - Client: client, + Transport: transport, MaxAttempts: 1, }) if err != nil { t.Fatal(err) } if result.SourceURL != "https://second.example.invalid/helper.exe" || - attempts["first.example.invalid"] != 1 || - attempts["second.example.invalid"] != 1 { + attempts["https://first.example.invalid/helper.exe"] != 1 || + attempts["https://second.example.invalid/helper.exe"] != 1 { t.Fatalf("expected second mirror fallback, got result=%#v attempts=%#v", result, attempts) } } +func TestDownloadTransportRetriesRetryableFailure(t *testing.T) { + dir := t.TempDir() + payload := []byte("retried helper") + attempts := 0 + transport := &recordingTransport{fetch: func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + attempts++ + if attempts == 1 { + return TransportResponse{}, io.ErrUnexpectedEOF + } + return testTransportResponse(http.StatusOK, payload), nil + }} + + result, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: filepath.Join(dir, "helper.exe"), + ExpectedSHA256: sha256String(payload), + Transport: transport, + MaxAttempts: 2, + }) + if err != nil { + t.Fatal(err) + } + if attempts != 2 || len(result.Transcript) != 2 || result.Transcript[0].Phase != "download-error" || result.Transcript[1].Phase != "download-verified" { + t.Fatalf("retry or transcript behavior changed: attempts=%d result=%#v", attempts, result) + } +} + +func TestDownloadTransportPreservesCancellation(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + transport := &recordingTransport{fetch: func(ctx context.Context, _ TransportRequest) (TransportResponse, error) { + return TransportResponse{}, ctx.Err() + }} + + _, err := Download(ctx, Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: filepath.Join(dir, "helper.exe"), + ExpectedSHA256: sha256String([]byte("helper")), + Transport: transport, + MaxAttempts: 3, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } + if len(transport.requests) != 1 { + t.Fatalf("canceled download should not retry, got %#v", transport.requests) + } +} + +func TestDownloadTransportRejectsNilResponseBody(t *testing.T) { + dir := t.TempDir() + _, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: filepath.Join(dir, "helper.exe"), + ExpectedSHA256: sha256String([]byte("helper")), + Transport: transportFunc(func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + return TransportResponse{StatusCode: http.StatusOK}, nil + }), + MaxAttempts: 1, + }) + if err == nil || !strings.Contains(err.Error(), "response body is required") { + t.Fatalf("expected missing response body error, got %v", err) + } +} + +func TestDownloadTransportClosesResponseBodyWhenFetchReturnsError(t *testing.T) { + dir := t.TempDir() + fetchErr := errors.New("transport interrupted") + body := &closeTrackingReadCloser{Reader: strings.NewReader("unused")} + _, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: filepath.Join(dir, "helper.exe"), + ExpectedSHA256: sha256String([]byte("helper")), + Transport: transportFunc(func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + return TransportResponse{Body: body}, fetchErr + }), + MaxAttempts: 1, + }) + if !errors.Is(err, fetchErr) { + t.Fatalf("expected fetch error, got %v", err) + } + if !body.Closed { + t.Fatal("transport response body was not closed after fetch error") + } +} + +func TestDownloadTransportPropagatesResponseBodyCloseFailure(t *testing.T) { + payload := []byte("verified payload") + closeErr := errors.New("temporary response cleanup failed") + body := &errorCloseReadCloser{Reader: bytes.NewReader(payload), Err: closeErr} + _, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: filepath.Join(t.TempDir(), "helper.exe"), + ExpectedSHA256: sha256String(payload), + ExpectedSize: int64(len(payload)), + Transport: transportFunc(func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + return TransportResponse{StatusCode: http.StatusOK, ContentLength: int64(len(payload)), Body: body}, nil + }), + MaxAttempts: 1, + }) + if err == nil || !errors.Is(err, closeErr) { + t.Fatalf("response cleanup failure was not propagated: %v", err) + } +} + +func TestDownloadTransportRejectsStatusOutsideOKAndPartialContent(t *testing.T) { + for _, status := range []int{http.StatusEarlyHints, http.StatusCreated, http.StatusRequestedRangeNotSatisfiable} { + t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "helper.exe") + _, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String([]byte("helper")), + Transport: transportFunc(func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + return testTransportResponse(status, []byte("helper")), nil + }), + MaxAttempts: 1, + }) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("status %d", status)) { + t.Fatalf("expected HTTP %d rejection, got %v", status, err) + } + if _, statErr := os.Stat(outPath); !os.IsNotExist(statErr) { + t.Fatalf("rejected response must not be promoted, stat err=%v", statErr) + } + }) + } +} + func TestDownloadRejectsChecksumMismatch(t *testing.T) { ctx := context.Background() dir := t.TempDir() outPath := filepath.Join(dir, "helper.exe") - client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return testResponse(req, http.StatusOK, []byte("tampered helper")), nil - })} + transport := transportFunc(func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + return testTransportResponse(http.StatusOK, []byte("tampered helper")), nil + }) _, err := Download(ctx, Options{ Mirrors: []Mirror{{URL: "https://mirror.example.invalid/helper.exe"}}, OutputPath: outPath, ExpectedSHA256: sha256String([]byte("expected helper")), - Client: client, + Transport: transport, }) if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { t.Fatalf("expected checksum mismatch, got %v", err) @@ -152,19 +550,150 @@ func TestDownloadRejectsChecksumMismatch(t *testing.T) { } } +func TestDownloadRejectsResponseLargerThanExpectedSize(t *testing.T) { + tests := []struct { + name string + contentLength int64 + }{ + {name: "declared content length", contentLength: 9}, + {name: "chunked body", contentLength: -1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "runtime.exe") + body := &countingReadCloser{Reader: bytes.NewReader(bytes.Repeat([]byte("x"), 64))} + transport := transportFunc(func(_ context.Context, _ TransportRequest) (TransportResponse, error) { + return TransportResponse{ + StatusCode: http.StatusOK, + ContentLength: test.contentLength, + Body: body, + }, nil + }) + + _, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/runtime.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String([]byte("xxxxxxxx")), + ExpectedSize: 8, + Transport: transport, + MaxAttempts: 1, + }) + if err == nil || !strings.Contains(err.Error(), "exceeds expected size") { + t.Fatalf("expected signed size limit failure, got %v", err) + } + if test.contentLength > 0 && body.BytesRead != 0 { + t.Fatalf("declared oversized response should be rejected before reading, read %d bytes", body.BytesRead) + } + if test.contentLength < 0 && body.BytesRead > 9 { + t.Fatalf("chunked oversized response read beyond the one-byte limit probe: %d", body.BytesRead) + } + if _, statErr := os.Stat(outPath + ".part"); !os.IsNotExist(statErr) { + t.Fatalf("oversized partial file must be removed, stat err=%v", statErr) + } + }) + } +} + +func TestDownloadRejectsOversizedExistingPartialBeforeRequest(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "runtime.exe") + if err := os.WriteFile(outPath+".part", []byte("123456789"), 0o600); err != nil { + t.Fatal(err) + } + transport := transportFunc(func(_ context.Context, req TransportRequest) (TransportResponse, error) { + t.Fatalf("oversized existing partial must fail before requesting %s", req.URL) + return TransportResponse{}, nil + }) + + _, err := Download(context.Background(), Options{ + Mirrors: []Mirror{{URL: "https://mirror.example.invalid/runtime.exe"}}, + OutputPath: outPath, + ExpectedSHA256: sha256String([]byte("12345678")), + ExpectedSize: 8, + Transport: transport, + MaxAttempts: 1, + }) + if err == nil || !strings.Contains(err.Error(), "exceeds expected size") { + t.Fatalf("expected oversized partial failure, got %v", err) + } + if _, statErr := os.Stat(outPath + ".part"); !os.IsNotExist(statErr) { + t.Fatalf("oversized existing partial must be removed, stat err=%v", statErr) + } +} + +func TestDownloadHTTPTransportAdaptsRangeAndResponse(t *testing.T) { + ctx := context.WithValue(context.Background(), struct{}{}, "request-context") + body := io.NopCloser(strings.NewReader("payload")) + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodGet || req.URL.String() != "https://mirror.example.invalid/helper.exe" { + t.Fatalf("unexpected HTTP request: %s %s", req.Method, req.URL) + } + if req.Header.Get("Range") != "bytes=7-" { + t.Fatalf("unexpected Range header %q", req.Header.Get("Range")) + } + if req.Context().Value(struct{}{}) != "request-context" { + t.Fatal("transport did not preserve request context") + } + return &http.Response{ + StatusCode: http.StatusPartialContent, + Status: "206 Partial Content", + ContentLength: 7, + Body: body, + Request: req, + }, nil + })} + + response, err := (HTTPTransport{Client: client}).Fetch(ctx, TransportRequest{ + URL: "https://mirror.example.invalid/helper.exe", + Offset: 7, + MaxBytes: 14, + }) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusPartialContent || response.ContentLength != 7 || response.Body != body { + t.Fatalf("unexpected transport response: %#v", response) + } + if err := response.Body.Close(); err != nil { + t.Fatal(err) + } +} + +func TestDownloadTransportPromotionFailurePreservesPreviousOutput(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, "helper.exe") + previous := []byte("previous verified helper") + if err := os.WriteFile(outputPath, previous, 0o600); err != nil { + t.Fatal(err) + } + + err := promotePart(filepath.Join(dir, "missing.part"), outputPath) + if err == nil { + t.Fatal("expected promotion to fail for a missing part") + } + got, readErr := os.ReadFile(outputPath) + if readErr != nil { + t.Fatalf("previous output was removed before replacement failed: %v", readErr) + } + if !bytes.Equal(got, previous) { + t.Fatalf("previous output changed after replacement failure: %q", got) + } +} + func TestDefaultCachePathUsesUserCache(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "xdg-cache")) t.Setenv("LOCALAPPDATA", filepath.Join(dir, "local-app-data")) - path, ok := DefaultCachePath("rdev-windows-amd64.exe") + path, ok := DefaultCachePath("rdev-host-windows-amd64.exe") if !ok { t.Fatal("expected default cache path") } if !strings.Contains(path, "remote-dev-skillkit") || !strings.Contains(path, "helpers") || - filepath.Base(path) != "rdev-windows-amd64.exe" { + filepath.Base(path) != "rdev-host-windows-amd64.exe" { t.Fatalf("unexpected cache path %q", path) } if _, ok := DefaultCachePath("../rdev.exe"); ok { @@ -175,16 +704,46 @@ func TestDefaultCachePathUsesUserCache(t *testing.T) { } } -func testResponse(req *http.Request, status int, body []byte) *http.Response { - return &http.Response{ - StatusCode: status, - Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(body)), - Request: req, +func testTransportResponse(status int, body []byte) TransportResponse { + return TransportResponse{ + StatusCode: status, + ContentLength: int64(len(body)), + Body: io.NopCloser(bytes.NewReader(body)), } } +type countingReadCloser struct { + *bytes.Reader + BytesRead int +} + +func (r *countingReadCloser) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + r.BytesRead += n + return n, err +} + +func (r *countingReadCloser) Close() error { return nil } + +type closeTrackingReadCloser struct { + io.Reader + Closed bool +} + +func (r *closeTrackingReadCloser) Close() error { + r.Closed = true + return nil +} + +type errorCloseReadCloser struct { + io.Reader + Err error +} + +func (reader *errorCloseReadCloser) Close() error { + return reader.Err +} + func sha256String(data []byte) string { sum := sha256.Sum256(data) return "sha256:" + hex.EncodeToString(sum[:]) diff --git a/internal/assetdownload/http_transport.go b/internal/assetdownload/http_transport.go new file mode 100644 index 0000000..5f5b482 --- /dev/null +++ b/internal/assetdownload/http_transport.go @@ -0,0 +1,36 @@ +//go:build !rdev_bootstrap_focused + +package assetdownload + +import ( + "context" + "fmt" + "net/http" +) + +type HTTPTransport struct { + Client *http.Client +} + +func (t HTTPTransport) Fetch(ctx context.Context, request TransportRequest) (TransportResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, request.URL, nil) + if err != nil { + return TransportResponse{}, err + } + if request.Offset > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", request.Offset)) + } + client := t.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return TransportResponse{}, err + } + return TransportResponse{ + StatusCode: resp.StatusCode, + ContentLength: resp.ContentLength, + Body: resp.Body, + }, nil +} diff --git a/internal/assetdownload/transport.go b/internal/assetdownload/transport.go new file mode 100644 index 0000000..d9e557b --- /dev/null +++ b/internal/assetdownload/transport.go @@ -0,0 +1,22 @@ +package assetdownload + +import ( + "context" + "io" +) + +type Transport interface { + Fetch(context.Context, TransportRequest) (TransportResponse, error) +} + +type TransportRequest struct { + URL string + Offset int64 + MaxBytes int64 +} + +type TransportResponse struct { + StatusCode int + ContentLength int64 + Body io.ReadCloser +} diff --git a/internal/bootstrapcmd/bootstrapcmd.go b/internal/bootstrapcmd/bootstrapcmd.go index dca37de..c632cf6 100644 --- a/internal/bootstrapcmd/bootstrapcmd.go +++ b/internal/bootstrapcmd/bootstrapcmd.go @@ -1,29 +1,70 @@ package bootstrapcmd import ( - "compress/gzip" "context" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" + "net/url" "os" "os/exec" "path/filepath" - "runtime" "strings" "time" "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" + "github.com/EitanWong/remote-dev-skillkit/internal/model" + "github.com/EitanWong/remote-dev-skillkit/internal/release" + "github.com/EitanWong/remote-dev-skillkit/internal/trustref" ) +const LayeredRunReportSchemaVersion = "rdev.layered-run-report.v1" + +const maxLayeredManifestBytes = 1 << 20 + type App struct { - Stdout io.Writer - Stderr io.Writer - Client *http.Client + Stdout io.Writer + Stderr io.Writer + Stdin io.Reader + Client *http.Client + CommandContext func(context.Context, string, ...string) *exec.Cmd +} + +type LayeredRunOptions struct { + ManifestURL string + Root model.TrustBundle + ExpectedReleaseVersion string + Platform string + CacheDir string + Mode string + Args []string + Client *http.Client + Now time.Time +} + +type LayeredRunReport struct { + SchemaVersion string `json:"schema_version"` + AssetID string `json:"asset_id"` + FromCache bool `json:"from_cache"` + Resumed bool `json:"resumed"` + Bytes int64 `json:"bytes"` + Stages []LayeredRunStage `json:"stages"` +} + +type LayeredRunStage struct { + Name string `json:"name"` + DurationMS int64 `json:"duration_ms"` +} + +type layeredRuntime struct { + path string + digest [sha256.Size]byte + size int64 } func (a App) Run(ctx context.Context, args []string) error { @@ -31,280 +72,513 @@ func (a App) Run(ctx context.Context, args []string) error { return fmt.Errorf("missing rdev-bootstrap subcommand") } switch args[0] { - case "upgrade": - return a.upgrade(ctx, args[1:]) + case "layered-run": + return a.layeredRun(ctx, args[1:]) case "help", "-h", "--help": - _, _ = fmt.Fprintln(a.stdout(), "usage: rdev-bootstrap upgrade --gateway-url URL --ticket-code CODE --asset NAME --out PATH [--mirror URL] [--cache PATH] [--no-exec] [-- full-helper-args...]") + _, _ = fmt.Fprintln(a.stdout(), "usage: rdev-bootstrap layered-run --manifest-url URL --root-public-key KEY --expected-release-version VERSION --platform OS/ARCH --cache-dir PATH --mode temporary [-- core-args...]") return nil default: return fmt.Errorf("unknown rdev-bootstrap subcommand %q", args[0]) } } -func (a App) upgrade(ctx context.Context, args []string) error { - fs := flag.NewFlagSet("rdev-bootstrap upgrade", flag.ContinueOnError) +func (a App) layeredRun(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("rdev-bootstrap layered-run", flag.ContinueOnError) fs.SetOutput(a.stderr()) - gatewayURL := fs.String("gateway-url", "", "support-session gateway URL") - ticketCode := fs.String("ticket-code", "", "support-session ticket code") - asset := fs.String("asset", "", "full rdev helper asset name") - out := fs.String("out", "", "downloaded full helper output path") - cache := fs.String("cache", "", "optional verified helper cache path") - noExec := fs.Bool("no-exec", false, "download and verify without executing the full helper") - var mirrors stringListFlag - fs.Var(&mirrors, "mirror", "additional raw helper mirror URL; may be repeated") + manifestURL := fs.String("manifest-url", "", "signed layered asset manifest URL") + runnerManifest := fs.String("runner-manifest", "", "connection entry runner manifest path") + rootPublicKey := fs.String("root-public-key", "", "pinned release root, formatted key_id:base64url_public_key") + expectedReleaseVersion := fs.String("expected-release-version", "", "required signed layered asset release version") + platform := fs.String("platform", "", "layered runtime platform") + cacheDir := fs.String("cache-dir", "", "user-scoped verified runtime cache directory") + mode := fs.String("mode", "", "bootstrap mode; must be temporary") if err := fs.Parse(args); err != nil { return err } - opts := upgradeOptions{ - GatewayURL: strings.TrimRight(strings.TrimSpace(*gatewayURL), "/"), - TicketCode: strings.TrimSpace(*ticketCode), - Asset: strings.TrimSpace(*asset), - Out: strings.TrimSpace(*out), - Cache: strings.TrimSpace(*cache), - Mirrors: []string(mirrors), - NoExec: *noExec, - ExecArgs: fs.Args(), + coreArgs := append([]string(nil), fs.Args()...) + if strings.TrimSpace(*runnerManifest) != "" { + runner, err := readBootstrapRunnerManifest(*runnerManifest) + if err != nil { + return err + } + if strings.TrimSpace(*manifestURL) == "" { + *manifestURL = runner.LayeredAssetsManifestURL + } + if strings.TrimSpace(*platform) == "" { + *platform = runner.TargetOS + "/" + runner.TargetArch + } + if len(coreArgs) == 0 { + coreArgs = runner.coreArgs() + } + } + if strings.TrimSpace(*mode) != "temporary" { + return fmt.Errorf("layered-run requires --mode temporary") + } + if !supportedLayeredPlatform(strings.TrimSpace(*platform)) { + return fmt.Errorf("layered-run requires a supported --platform") + } + if strings.TrimSpace(*expectedReleaseVersion) == "" { + return fmt.Errorf("layered-run requires expected release version via --expected-release-version") + } + root, err := trustref.Parse(*rootPublicKey) + if err != nil { + return fmt.Errorf("root public key: %w", err) + } + report, runtime, err := runLayeredTemporary(ctx, LayeredRunOptions{ + ManifestURL: strings.TrimSpace(*manifestURL), + Root: root, + ExpectedReleaseVersion: strings.TrimSpace(*expectedReleaseVersion), + Platform: strings.TrimSpace(*platform), + CacheDir: strings.TrimSpace(*cacheDir), + Mode: strings.TrimSpace(*mode), + Args: append([]string(nil), coreArgs...), + Client: a.client(), + }) + if err != nil { + return err + } + executable, err := openVerifiedLayeredRuntime(runtime) + if err != nil { + return fmt.Errorf("lock verified layered runtime: %w", err) + } + defer executable.Close() + cmd := a.commandContext(ctx, runtime.path, coreArgs...) + if cmd == nil { + return fmt.Errorf("layered runtime command is nil") + } + if err := recheckVerifiedLayeredRuntime(executable, runtime); err != nil { + return fmt.Errorf("layered runtime changed before execution: %w", err) } - return a.runUpgrade(ctx, opts) + if err := json.NewEncoder(a.stdout()).Encode(report); err != nil { + return err + } + cmd.Stdout = a.stdout() + cmd.Stderr = a.stderr() + cmd.Stdin = a.stdin() + return cmd.Run() } -type upgradeOptions struct { - GatewayURL string - TicketCode string - Asset string - Out string - Cache string - Mirrors []string - NoExec bool - ExecArgs []string +type bootstrapRunnerManifest struct { + SchemaVersion string `json:"schema_version"` + TargetOS string `json:"target_os"` + TargetArch string `json:"target_arch"` + Mode string `json:"mode"` + HostName string `json:"host_name"` + ManifestURL string `json:"manifest_url"` + ManifestRootPublicKey string `json:"manifest_root_public_key"` + LayeredAssetsManifestURL string `json:"layered_assets_manifest_url"` + GatewayURL string `json:"gateway_url"` } -type stringListFlag []string +func readBootstrapRunnerManifest(path string) (bootstrapRunnerManifest, error) { + path = strings.TrimSpace(path) + if path == "" { + return bootstrapRunnerManifest{}, fmt.Errorf("runner manifest path is required") + } + file, err := os.Open(path) + if err != nil { + return bootstrapRunnerManifest{}, err + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, maxLayeredManifestBytes+1)) + if err != nil { + return bootstrapRunnerManifest{}, err + } + if len(content) > maxLayeredManifestBytes { + return bootstrapRunnerManifest{}, fmt.Errorf("runner manifest exceeds %d bytes", maxLayeredManifestBytes) + } + var manifest bootstrapRunnerManifest + if err := json.Unmarshal(content, &manifest); err != nil { + return bootstrapRunnerManifest{}, fmt.Errorf("decode runner manifest: %w", err) + } + if manifest.SchemaVersion != "rdev.connection-entry.runner.v1" || + !supportedLayeredPlatform(manifest.TargetOS+"/"+manifest.TargetArch) || + strings.TrimSpace(manifest.LayeredAssetsManifestURL) == "" || + strings.TrimSpace(manifest.ManifestURL) == "" || + strings.TrimSpace(manifest.ManifestRootPublicKey) == "" || + strings.TrimSpace(manifest.GatewayURL) == "" { + return bootstrapRunnerManifest{}, fmt.Errorf("runner manifest is incomplete") + } + return manifest, nil +} -func (f *stringListFlag) String() string { - return strings.Join(*f, ",") +func (manifest bootstrapRunnerManifest) coreArgs() []string { + mode := strings.TrimSpace(manifest.Mode) + if mode == "" || mode == "attended-temporary" { + mode = "temporary" + } + args := []string{ + "--mode", mode, + "--gateway", manifest.GatewayURL, + "--manifest-url", manifest.ManifestURL, + "--manifest-root-public-key", manifest.ManifestRootPublicKey, + "--transport", "auto", + "--once=false", + } + if strings.TrimSpace(manifest.HostName) != "" { + args = append(args, "--name", manifest.HostName) + } + return args } -func (f *stringListFlag) Set(value string) error { - value = strings.TrimSpace(value) - if value != "" { - *f = append(*f, value) - } - return nil +func RunLayeredTemporary(ctx context.Context, opts LayeredRunOptions) (LayeredRunReport, string, error) { + report, runtime, err := runLayeredTemporary(ctx, opts) + return report, runtime.path, err } -func (a App) runUpgrade(ctx context.Context, opts upgradeOptions) error { - if opts.GatewayURL == "" { - return fmt.Errorf("gateway-url is required") +func runLayeredTemporary(ctx context.Context, opts LayeredRunOptions) (LayeredRunReport, layeredRuntime, error) { + manifestURL, origin, client, now, err := prepareLayeredRequest(opts) + if err != nil { + return LayeredRunReport{}, layeredRuntime{}, err + } + report := LayeredRunReport{SchemaVersion: LayeredRunReportSchemaVersion} + + started := time.Now() + manifest, finalManifestURL, err := fetchLayeredManifest(ctx, client, manifestURL) + if err != nil { + return LayeredRunReport{}, layeredRuntime{}, err } - if opts.TicketCode == "" { - return fmt.Errorf("ticket-code is required") + report.Stages = append(report.Stages, layeredStage("manifest-fetch", started)) + if now.IsZero() { + now = time.Now() } - if opts.Asset == "" { - return fmt.Errorf("asset is required") + + started = time.Now() + if err := release.VerifyLayeredAssetManifest(manifest, opts.Root, now); err != nil { + return LayeredRunReport{}, layeredRuntime{}, err } - if opts.Out == "" { - return fmt.Errorf("out is required") + if manifest.Version != opts.ExpectedReleaseVersion { + return LayeredRunReport{}, layeredRuntime{}, fmt.Errorf("layered manifest release version %q does not match expected release version %q", manifest.Version, opts.ExpectedReleaseVersion) } - if strings.Contains(opts.Asset, "/") || strings.Contains(opts.Asset, `\`) { - return fmt.Errorf("asset must be a file name") + asset, err := release.SelectLayeredAsset(manifest, opts.Platform, "core-runtime", nil) + if err != nil { + return LayeredRunReport{}, layeredRuntime{}, err } - absOut, err := filepath.Abs(opts.Out) + assetURL, err := resolveLayeredAssetURL(finalManifestURL, asset.RelativePath, origin) if err != nil { - return err + return LayeredRunReport{}, layeredRuntime{}, err } - opts.Out = absOut - if err := os.MkdirAll(filepath.Dir(opts.Out), 0o700); err != nil { + report.Stages = append(report.Stages, layeredStage("signature-verification", started)) + + started = time.Now() + paths, err := prepareLayeredCachePaths(opts.CacheDir, asset) + if err != nil { + return LayeredRunReport{}, layeredRuntime{}, err + } + result, err := assetdownload.Download(ctx, assetdownload.Options{ + Mirrors: []assetdownload.Mirror{{URL: assetURL.String()}}, + ExpectedSHA256: asset.SHA256, + ExpectedSize: asset.SizeBytes, + OutputPath: paths.output, + CachePath: paths.content, + Transport: assetdownload.HTTPTransport{Client: client}, + }) + if err != nil { + return LayeredRunReport{}, layeredRuntime{}, err + } + if result.Bytes != asset.SizeBytes { + return LayeredRunReport{}, layeredRuntime{}, fmt.Errorf("downloaded runtime size does not match signed manifest") + } + report.Stages = append(report.Stages, layeredStage("runtime-download", started)) + + started = time.Now() + if err := secureLayeredResultFiles(paths, asset); err != nil { + return LayeredRunReport{}, layeredRuntime{}, err + } + runtime, err := layeredRuntimeForAsset(paths.output, asset) + if err != nil { + return LayeredRunReport{}, layeredRuntime{}, err + } + report.Stages = append(report.Stages, layeredStage("runtime-launch-preparation", started)) + report.AssetID = asset.ID + report.FromCache = result.FromCache + report.Resumed = result.Resumed + report.Bytes = result.Bytes + return report, runtime, nil +} + +type layeredCachePaths struct { + output string + content string +} + +func layeredRuntimeForAsset(path string, asset release.LayeredAsset) (layeredRuntime, error) { + digestBytes, err := hex.DecodeString(strings.TrimPrefix(asset.SHA256, "sha256:")) + if err != nil || len(digestBytes) != sha256.Size { + return layeredRuntime{}, fmt.Errorf("signed layered runtime digest is invalid") + } + var digest [sha256.Size]byte + copy(digest[:], digestBytes) + return layeredRuntime{path: path, digest: digest, size: asset.SizeBytes}, nil +} + +func recheckVerifiedLayeredRuntime(file *os.File, runtime layeredRuntime) error { + if file == nil { + return fmt.Errorf("protected layered runtime handle is required") + } + info, err := file.Stat() + if err != nil { return err } - _ = a.postPreconnect(ctx, opts, "downloading-helper", "downloading verified helper") - downloadResult, err := a.downloadVerifiedHelper(ctx, opts) + pathInfo, err := os.Lstat(runtime.path) if err != nil { - _ = os.Remove(opts.Out) return err } - if err := os.Chmod(opts.Out, 0o700); err != nil { + if !info.Mode().IsRegular() || pathInfo.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, pathInfo) || info.Size() != runtime.size { + return fmt.Errorf("protected layered runtime path or size changed") + } + if _, err := file.Seek(0, io.SeekStart); err != nil { return err } - if opts.NoExec { - return json.NewEncoder(a.stdout()).Encode(map[string]any{ - "schema_version": "rdev-bootstrap-upgrade-result.v1", - "ok": true, - "verified": true, - "executed": false, - "helper": opts.Out, - "asset": opts.Asset, - "download": downloadResult, - }) - } - _ = a.postPreconnect(ctx, opts, "starting-full-helper", "starting verified full helper") - cmd := exec.CommandContext(ctx, opts.Out, opts.ExecArgs...) - cmd.Stdout = a.stdout() - cmd.Stderr = a.stderr() - cmd.Stdin = os.Stdin - if err := cmd.Run(); err != nil { + digest := sha256.New() + written, err := io.Copy(digest, io.LimitReader(file, runtime.size+1)) + if err != nil { return err } - return nil -} - -func (a App) postPreconnect(ctx context.Context, opts upgradeOptions, phase, message string) error { - body := map[string]any{ - "ticket_code": opts.TicketCode, - "phase": phase, - "os": runtime.GOOS, - "arch": runtime.GOARCH, - "asset": opts.Asset, - "source": "rdev-bootstrap-native", - "message": message, + if written != runtime.size || subtle.ConstantTimeCompare(digest.Sum(nil), runtime.digest[:]) != 1 { + return fmt.Errorf("protected layered runtime digest or size changed") } - content, err := json.Marshal(body) + postInfo, err := file.Stat() if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, opts.GatewayURL+"/v1/support-session/preconnect", strings.NewReader(string(content))) + postPathInfo, err := os.Lstat(runtime.path) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") - resp, err := a.client().Do(req) + if !postInfo.Mode().IsRegular() || postPathInfo.Mode()&os.ModeSymlink != 0 || !os.SameFile(postInfo, postPathInfo) || postInfo.Size() != runtime.size { + return fmt.Errorf("protected layered runtime changed during final verification") + } + _, err = file.Seek(0, io.SeekStart) + return err +} + +func prepareLayeredRequest(opts LayeredRunOptions) (*url.URL, *url.URL, *http.Client, time.Time, error) { + if opts.Mode != "temporary" { + return nil, nil, nil, time.Time{}, fmt.Errorf("layered run requires mode temporary") + } + if !supportedLayeredPlatform(opts.Platform) { + return nil, nil, nil, time.Time{}, fmt.Errorf("layered run requires a supported platform") + } + if strings.TrimSpace(opts.ExpectedReleaseVersion) == "" { + return nil, nil, nil, time.Time{}, fmt.Errorf("expected release version is required") + } + if strings.TrimSpace(opts.CacheDir) == "" { + return nil, nil, nil, time.Time{}, fmt.Errorf("cache directory is required") + } + manifestURL, err := parseLayeredHTTPSURL(opts.ManifestURL) if err != nil { - return err + return nil, nil, nil, time.Time{}, err } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("preconnect failed: %s", resp.Status) + return manifestURL, manifestURL, cloneLayeredHTTPClient(opts.Client, manifestURL), opts.Now, nil +} + +func supportedLayeredPlatform(platform string) bool { + switch strings.TrimSpace(platform) { + case "windows/amd64", "windows/arm64", "darwin/amd64", "darwin/arm64", "linux/amd64", "linux/arm64": + return true + default: + return false } - return nil } -func (a App) downloadVerifiedHelper(ctx context.Context, opts upgradeOptions) (assetdownload.Result, error) { - assetsURL := opts.GatewayURL + "/assets/" + opts.Asset - expected, err := a.downloadText(ctx, assetsURL+".sha256") +func parseLayeredHTTPSURL(rawURL string) (*url.URL, error) { + rawURL = strings.TrimSpace(rawURL) + parsed, err := url.Parse(rawURL) if err != nil { - return assetdownload.Result{}, err + return nil, fmt.Errorf("invalid layered URL") } - cachePath := strings.TrimSpace(opts.Cache) - if cachePath == "" { - if path, ok := assetdownload.DefaultCachePath(opts.Asset); ok { - cachePath = path - } + if !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || parsed.User != nil || strings.Contains(rawURL, "#") || parsed.RawQuery != "" || parsed.ForceQuery { + return nil, fmt.Errorf("layered URL must be an HTTPS URL without credentials, query, or fragment") } - mirrors := make([]assetdownload.Mirror, 0, len(opts.Mirrors)+1) - for _, mirror := range opts.Mirrors { - mirror = strings.TrimSpace(mirror) - if mirror != "" { - mirrors = append(mirrors, assetdownload.Mirror{URL: mirror, Kind: "operator-mirror"}) + return parsed, nil +} + +func cloneLayeredHTTPClient(base *http.Client, origin *url.URL) *http.Client { + if base == nil { + base = http.DefaultClient + } + cloned := *base + originalPolicy := base.CheckRedirect + cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if !sameLayeredOrigin(req.URL, origin) { + return fmt.Errorf("layered redirect must remain same-origin HTTPS") } + if originalPolicy != nil { + if err := originalPolicy(req, via); err != nil { + return err + } + } + if !sameLayeredOrigin(req.URL, origin) { + return fmt.Errorf("layered redirect policy changed the request origin") + } + return nil } - mirrors = append(mirrors, assetdownload.Mirror{URL: assetsURL, Kind: "gateway-asset"}) - result, rawErr := assetdownload.Download(ctx, assetdownload.Options{ - Mirrors: mirrors, - OutputPath: opts.Out, - CachePath: cachePath, - ExpectedSHA256: strings.TrimSpace(expected), - Client: a.client(), - }) - if rawErr == nil { - return result, nil + return &cloned +} + +func sameLayeredOrigin(candidate, origin *url.URL) bool { + return candidate != nil && origin != nil && + strings.EqualFold(candidate.Scheme, "https") && + strings.EqualFold(origin.Scheme, "https") && + strings.EqualFold(candidate.Host, origin.Host) && + candidate.User == nil && candidate.RawQuery == "" && !candidate.ForceQuery && candidate.Fragment == "" +} + +func fetchLayeredManifest(ctx context.Context, client *http.Client, manifestURL *url.URL) (release.LayeredAssetManifest, *url.URL, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL.String(), nil) + if err != nil { + return release.LayeredAssetManifest{}, nil, err + } + resp, err := client.Do(req) + if err != nil { + return release.LayeredAssetManifest{}, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return release.LayeredAssetManifest{}, nil, fmt.Errorf("layered manifest request failed: %s", resp.Status) + } + content, err := io.ReadAll(io.LimitReader(resp.Body, maxLayeredManifestBytes+1)) + if err != nil { + return release.LayeredAssetManifest{}, nil, err + } + if len(content) > maxLayeredManifestBytes { + return release.LayeredAssetManifest{}, nil, fmt.Errorf("layered manifest exceeds %d bytes", maxLayeredManifestBytes) } - if strings.Contains(strings.ToLower(rawErr.Error()), "checksum mismatch") { - return assetdownload.Result{}, rawErr + manifest, err := release.DecodeLayeredAssetManifest(content) + if err != nil { + return release.LayeredAssetManifest{}, nil, fmt.Errorf("decode layered manifest: %w", err) } - if err := a.downloadGzip(ctx, assetsURL+".gz", opts.Out); err != nil { - return assetdownload.Result{}, rawErr + finalURL := manifestURL + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL } - actual, err := fileSHA256(opts.Out) + return manifest, finalURL, nil +} + +func resolveLayeredAssetURL(manifestURL *url.URL, relativePath string, origin *url.URL) (*url.URL, error) { + reference, err := url.Parse(relativePath) if err != nil { - return assetdownload.Result{}, err + return nil, err } - if !strings.EqualFold(strings.TrimSpace(expected), actual) { - return assetdownload.Result{}, fmt.Errorf("rdev helper SHA-256 mismatch: expected %s got %s", strings.TrimSpace(expected), actual) + resolved := manifestURL.ResolveReference(reference) + if !sameLayeredOrigin(resolved, origin) { + return nil, fmt.Errorf("layered asset URL must remain same-origin HTTPS") } - info, err := os.Stat(opts.Out) + return resolved, nil +} + +func prepareLayeredCachePaths(cacheDir string, asset release.LayeredAsset) (layeredCachePaths, error) { + cacheDir, err := filepath.Abs(strings.TrimSpace(cacheDir)) if err != nil { - return assetdownload.Result{}, err + return layeredCachePaths{}, err + } + digest := strings.TrimPrefix(asset.SHA256, "sha256:") + paths := layeredCachePaths{ + output: filepath.Join(cacheDir, "runtime", digest, filepath.Base(asset.RelativePath)), + content: filepath.Join(cacheDir, "content", digest), + } + for _, dir := range []string{cacheDir, filepath.Join(cacheDir, "runtime"), filepath.Dir(paths.output), filepath.Join(cacheDir, "content")} { + if err := ensureLayeredDirectory(dir); err != nil { + return layeredCachePaths{}, err + } } - result = assetdownload.Result{ - OutputPath: opts.Out, - SourceURL: assetsURL + ".gz", - Bytes: info.Size(), - SHA256: "sha256:" + actual, - Transcript: []assetdownload.Event{{Phase: "download-verified", URL: assetsURL + ".gz", Bytes: info.Size()}}, + for _, path := range []string{paths.output, paths.output + ".tmp", paths.content, paths.content + ".tmp"} { + if err := secureLayeredFileIfExists(path, asset.SizeBytes); err != nil { + return layeredCachePaths{}, err + } + } + if err := secureLayeredPartialFileIfExists(paths.output+".part", asset.SizeBytes); err != nil { + return layeredCachePaths{}, err } - return result, nil + return paths, nil } -func (a App) downloadGzip(ctx context.Context, rawURL, out string) error { - resp, err := a.getWithRetry(ctx, rawURL) +func ensureLayeredDirectory(path string) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + info, err = os.Lstat(path) + } if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download %s failed: %s", rawURL, resp.Status) + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("layered cache path must be a directory without symlinks") + } + return os.Chmod(path, 0o700) +} + +func secureLayeredFileIfExists(path string, maxBytes int64) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil } - reader, err := gzip.NewReader(resp.Body) if err != nil { return err } - defer reader.Close() - return writeFileAtomically(out, reader) + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("layered cache file must be regular and not a symlink") + } + if info.Size() > maxBytes { + return fmt.Errorf("layered cache file exceeds signed runtime size") + } + return os.Chmod(path, 0o600) } -func (a App) downloadFile(ctx context.Context, rawURL, out string) error { - resp, err := a.getWithRetry(ctx, rawURL) +func secureLayeredPartialFileIfExists(path string, maxBytes int64) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download %s failed: %s", rawURL, resp.Status) + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("layered cache partial must be regular and not a symlink") + } + if info.Size() > maxBytes { + return os.Remove(path) } - return writeFileAtomically(out, resp.Body) + return os.Chmod(path, 0o600) } -func (a App) downloadText(ctx context.Context, rawURL string) (string, error) { - resp, err := a.getWithRetry(ctx, rawURL) +func secureLayeredResultFiles(paths layeredCachePaths, asset release.LayeredAsset) error { + info, err := os.Lstat(paths.output) if err != nil { - return "", err + return err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("download %s failed: %s", rawURL, resp.Status) + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("layered result must be a regular file without symlinks") + } + sha, err := fileSHA256(paths.output) + finalInfo, finalStatErr := os.Lstat(paths.output) + if err != nil || finalStatErr != nil || finalInfo.Mode()&os.ModeSymlink != 0 || !finalInfo.Mode().IsRegular() || "sha256:"+sha != asset.SHA256 || finalInfo.Size() != asset.SizeBytes { + return fmt.Errorf("layered result does not match signed digest and size") + } + if err := os.Chmod(paths.output, 0o600); err != nil { + return err + } + contentInfo, err := os.Lstat(paths.content) + if os.IsNotExist(err) { + return nil } - content, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return err } - return string(content), nil + if contentInfo.Mode()&os.ModeSymlink != 0 || !contentInfo.Mode().IsRegular() { + return fmt.Errorf("layered content cache must be a regular file without symlinks") + } + return os.Chmod(paths.content, 0o600) } -func (a App) getWithRetry(ctx context.Context, rawURL string) (*http.Response, error) { - var lastErr error - for attempt := 1; attempt <= 3; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return nil, err - } - resp, err := a.client().Do(req) - if err == nil && resp.StatusCode < 500 { - return resp, nil - } - if resp != nil { - _ = resp.Body.Close() - lastErr = fmt.Errorf("GET %s returned %s", rawURL, resp.Status) - } - if err != nil { - lastErr = err - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(time.Duration(attempt) * 200 * time.Millisecond): - } - } - return nil, lastErr +func layeredStage(name string, started time.Time) LayeredRunStage { + return LayeredRunStage{Name: name, DurationMS: time.Since(started).Milliseconds()} } func (a App) client() *http.Client { @@ -328,22 +602,18 @@ func (a App) stderr() io.Writer { return os.Stderr } -func writeFileAtomically(path string, reader io.Reader) error { - tmp := path + ".tmp" - file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o700) - if err != nil { - return err +func (a App) stdin() io.Reader { + if a.Stdin != nil { + return a.Stdin } - if _, err := io.Copy(file, reader); err != nil { - _ = file.Close() - _ = os.Remove(tmp) - return err - } - if err := file.Close(); err != nil { - _ = os.Remove(tmp) - return err + return os.Stdin +} + +func (a App) commandContext(ctx context.Context, path string, args ...string) *exec.Cmd { + if a.CommandContext != nil { + return a.CommandContext(ctx, path, args...) } - return os.Rename(tmp, path) + return exec.CommandContext(ctx, path, args...) } func fileSHA256(path string) (string, error) { diff --git a/internal/bootstrapcmd/bootstrapcmd_test.go b/internal/bootstrapcmd/bootstrapcmd_test.go index ee9399f..7927ee9 100644 --- a/internal/bootstrapcmd/bootstrapcmd_test.go +++ b/internal/bootstrapcmd/bootstrapcmd_test.go @@ -2,331 +2,18 @@ package bootstrapcmd import ( "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "encoding/json" "fmt" "io" "net/http" - "os" - "path/filepath" - "runtime" "strings" "testing" - - "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" ) -func TestUpgradePreconnectsDownloadsAndVerifiesHelper(t *testing.T) { - helper := []byte("fake verified rdev-host helper\n") - sum := sha256.Sum256(helper) - expectedSHA := hex.EncodeToString(sum[:]) - gzipped := gzipBytes(t, helper) - var preconnect map[string]any - gatewayURL := "https://gateway.test" - - client := testHTTPClient(func(r *http.Request) (*http.Response, error) { - switch r.URL.Path { - case "/v1/support-session/preconnect": - if err := json.NewDecoder(r.Body).Decode(&preconnect); err != nil { - t.Fatalf("decode preconnect: %v", err) - } - return testResponse(r, http.StatusAccepted, nil), nil - case "/assets/rdev-host-test": - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - case "/assets/rdev-host-test.gz": - return testResponse(r, http.StatusOK, gzipped), nil - case "/assets/rdev-host-test.sha256": - return testResponse(r, http.StatusOK, []byte(expectedSHA+"\n")), nil - default: - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - } - }) - - out := filepath.Join(t.TempDir(), "rdev-host-test") - var stdout bytes.Buffer - app := App{Stdout: &stdout, Stderr: &bytes.Buffer{}, Client: client} - err := app.Run(t.Context(), []string{ - "upgrade", - "--gateway-url", gatewayURL, - "--ticket-code", "ABCD-1234", - "--asset", "rdev-host-test", - "--out", out, - "--no-exec", - }) - if err != nil { - t.Fatal(err) - } - written, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(written, helper) { - t.Fatalf("downloaded helper mismatch: got %q want %q", written, helper) - } - if preconnect["ticket_code"] != "ABCD-1234" || - preconnect["phase"] != "downloading-helper" || - preconnect["asset"] != "rdev-host-test" || - preconnect["source"] != "rdev-bootstrap-native" { - t.Fatalf("unexpected preconnect body: %#v", preconnect) - } - if !strings.Contains(stdout.String(), `"verified":true`) || - !strings.Contains(stdout.String(), `"executed":false`) { - t.Fatalf("expected no-exec JSON status, got %s", stdout.String()) - } -} - -func TestUpgradeRejectsSHA256MismatchAndRemovesHelper(t *testing.T) { - helper := []byte("fake verified rdev-host helper\n") - gzipped := gzipBytes(t, helper) - gatewayURL := "https://gateway.test" - client := testHTTPClient(func(r *http.Request) (*http.Response, error) { - switch r.URL.Path { - case "/v1/support-session/preconnect": - return testResponse(r, http.StatusAccepted, nil), nil - case "/assets/rdev-host-test": - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - case "/assets/rdev-host-test.gz": - return testResponse(r, http.StatusOK, gzipped), nil - case "/assets/rdev-host-test.sha256": - return testResponse(r, http.StatusOK, []byte(strings.Repeat("0", 64)+"\n")), nil - default: - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - } - }) - - out := filepath.Join(t.TempDir(), "rdev-host-test") - app := App{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, Client: client} - err := app.Run(t.Context(), []string{ - "upgrade", - "--gateway-url", gatewayURL, - "--ticket-code", "ABCD-1234", - "--asset", "rdev-host-test", - "--out", out, - "--no-exec", - }) - if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") { - t.Fatalf("expected SHA-256 mismatch, got %v", err) - } - if _, statErr := os.Stat(out); !os.IsNotExist(statErr) { - t.Fatalf("expected mismatched helper to be removed, stat err=%v", statErr) - } -} - -func TestUpgradeUsesMirrorFallbackAndCache(t *testing.T) { - helper := []byte("raw helper from second mirror\n") - sum := sha256.Sum256(helper) - expectedSHA := hex.EncodeToString(sum[:]) - var preconnects int - var firstMirrorHits int - var secondMirrorHits int - - gatewayURL := "https://gateway.test" - firstMirrorURL := "https://first-mirror.test/rdev-host-test" - secondMirrorURL := "https://second-mirror.test/rdev-host-test" - client := testHTTPClient(func(r *http.Request) (*http.Response, error) { - switch r.URL.Host { - case "gateway.test": - switch r.URL.Path { - case "/v1/support-session/preconnect": - preconnects++ - return testResponse(r, http.StatusAccepted, nil), nil - case "/assets/rdev-host-test.sha256": - return testResponse(r, http.StatusOK, []byte(expectedSHA+"\n")), nil - default: - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - } - case "first-mirror.test": - firstMirrorHits++ - return testResponse(r, http.StatusBadGateway, []byte("weak mirror")), nil - case "second-mirror.test": - secondMirrorHits++ - return testResponse(r, http.StatusOK, helper), nil - default: - t.Fatalf("unexpected host %s", r.URL.Host) - return nil, nil - } - }) - - dir := t.TempDir() - cachePath := filepath.Join(dir, "cache", "rdev-host-test") - out := filepath.Join(dir, "rdev-host-test") - var stdout bytes.Buffer - app := App{Stdout: &stdout, Stderr: &bytes.Buffer{}, Client: client} - err := app.Run(t.Context(), []string{ - "upgrade", - "--gateway-url", gatewayURL, - "--ticket-code", "ABCD-1234", - "--asset", "rdev-host-test", - "--mirror", firstMirrorURL, - "--mirror", secondMirrorURL, - "--cache", cachePath, - "--out", out, - "--no-exec", - }) - if err != nil { - t.Fatal(err) - } - if firstMirrorHits != 3 || secondMirrorHits != 1 { - t.Fatalf("expected first mirror retries then second mirror success, first=%d second=%d", firstMirrorHits, secondMirrorHits) - } - if cached, err := os.ReadFile(cachePath); err != nil || !bytes.Equal(cached, helper) { - t.Fatalf("expected verified cache copy, content=%q err=%v", string(cached), err) - } - if !strings.Contains(stdout.String(), `"source_url":"`+secondMirrorURL+`"`) || - !strings.Contains(stdout.String(), `"from_cache":false`) { - t.Fatalf("expected no-exec JSON to include mirror download result, got %s", stdout.String()) - } - - stdout.Reset() - if err := os.Remove(out); err != nil { - t.Fatal(err) - } - err = app.Run(t.Context(), []string{ - "upgrade", - "--gateway-url", gatewayURL, - "--ticket-code", "ABCD-1234", - "--asset", "rdev-host-test", - "--mirror", firstMirrorURL, - "--mirror", secondMirrorURL, - "--cache", cachePath, - "--out", out, - "--no-exec", - }) - if err != nil { - t.Fatal(err) - } - if firstMirrorHits != 3 || secondMirrorHits != 1 { - t.Fatalf("expected cache hit to avoid mirror requests, first=%d second=%d", firstMirrorHits, secondMirrorHits) - } - if !strings.Contains(stdout.String(), `"source_url":"cache"`) || - !strings.Contains(stdout.String(), `"from_cache":true`) || - preconnects != 2 { - t.Fatalf("expected cache hit result and preconnect per run, preconnects=%d stdout=%s", preconnects, stdout.String()) - } -} - -func TestUpgradeUsesDefaultCacheWhenCacheFlagOmitted(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "xdg-cache")) - t.Setenv("LOCALAPPDATA", filepath.Join(dir, "local-app-data")) - - helper := []byte("helper cached by default path\n") - sum := sha256.Sum256(helper) - expectedSHA := hex.EncodeToString(sum[:]) - var rawAssetHits int - var preconnects int - - gatewayURL := "https://gateway.test" - client := testHTTPClient(func(r *http.Request) (*http.Response, error) { - switch r.URL.Path { - case "/v1/support-session/preconnect": - preconnects++ - return testResponse(r, http.StatusAccepted, nil), nil - case "/assets/rdev-host-test.sha256": - return testResponse(r, http.StatusOK, []byte(expectedSHA+"\n")), nil - case "/assets/rdev-host-test": - rawAssetHits++ - return testResponse(r, http.StatusOK, helper), nil - default: - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - } - }) - - cachePath, ok := assetdownload.DefaultCachePath("rdev-host-test") - if !ok { - t.Fatal("expected default cache path") - } - out := filepath.Join(dir, "first-helper") - var stdout bytes.Buffer - app := App{Stdout: &stdout, Stderr: &bytes.Buffer{}, Client: client} - err := app.Run(t.Context(), []string{ - "upgrade", - "--gateway-url", gatewayURL, - "--ticket-code", "ABCD-1234", - "--asset", "rdev-host-test", - "--out", out, - "--no-exec", - }) - if err != nil { - t.Fatal(err) - } - if rawAssetHits != 1 { - t.Fatalf("expected first run to download raw asset once, got %d", rawAssetHits) - } - if cached, err := os.ReadFile(cachePath); err != nil || !bytes.Equal(cached, helper) { - t.Fatalf("expected default cache file, content=%q err=%v", string(cached), err) - } - - stdout.Reset() - out = filepath.Join(dir, "second-helper") - err = app.Run(t.Context(), []string{ - "upgrade", - "--gateway-url", gatewayURL, - "--ticket-code", "ABCD-1234", - "--asset", "rdev-host-test", - "--out", out, - "--no-exec", - }) - if err != nil { - t.Fatal(err) - } - if rawAssetHits != 1 || preconnects != 2 { - t.Fatalf("expected second run to reuse cache after preconnect, raw hits=%d preconnects=%d", rawAssetHits, preconnects) - } - if !strings.Contains(stdout.String(), `"source_url":"cache"`) || - !strings.Contains(stdout.String(), `"from_cache":true`) { - t.Fatalf("expected default cache hit in JSON, got %s", stdout.String()) - } -} - -func TestUpgradeExecutesDownloadedRelativeHelperPath(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses a POSIX shell fixture") - } - helper := []byte("#!/bin/sh\nprintf executed > \"$1\"\n") - sum := sha256.Sum256(helper) - expectedSHA := hex.EncodeToString(sum[:]) - gzipped := gzipBytes(t, helper) - gatewayURL := "https://gateway.test" - client := testHTTPClient(func(r *http.Request) (*http.Response, error) { - switch r.URL.Path { - case "/v1/support-session/preconnect": - return testResponse(r, http.StatusAccepted, nil), nil - case "/assets/rdev-host-test": - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - case "/assets/rdev-host-test.gz": - return testResponse(r, http.StatusOK, gzipped), nil - case "/assets/rdev-host-test.sha256": - return testResponse(r, http.StatusOK, []byte(expectedSHA+"\n")), nil - default: - return testResponse(r, http.StatusNotFound, []byte("not found")), nil - } - }) - - dir := t.TempDir() - t.Chdir(dir) - marker := filepath.Join(dir, "marker.txt") - app := App{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, Client: client} - err := app.Run(t.Context(), []string{ - "upgrade", - "--gateway-url", gatewayURL, - "--ticket-code", "ABCD-1234", - "--asset", "rdev-host-test", - "--out", "downloaded-helper", - "--", marker, - }) - if err != nil { - t.Fatal(err) - } - content, err := os.ReadFile(marker) - if err != nil { - t.Fatal(err) - } - if string(content) != "executed" { - t.Fatalf("expected downloaded helper to execute, got %q", content) +func TestBootstrapRejectsLegacyUpgradeCommand(t *testing.T) { + app := App{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, Stdin: strings.NewReader("")} + err := app.Run(t.Context(), []string{"upgrade"}) + if err == nil || !strings.Contains(err.Error(), "unknown rdev-bootstrap subcommand") { + t.Fatalf("legacy upgrade command must be unavailable, got %v", err) } } @@ -349,16 +36,3 @@ func testResponse(req *http.Request, status int, body []byte) *http.Response { Request: req, } } - -func gzipBytes(t *testing.T, content []byte) []byte { - t.Helper() - var buf bytes.Buffer - zw := gzip.NewWriter(&buf) - if _, err := zw.Write(content); err != nil { - t.Fatal(err) - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - return buf.Bytes() -} diff --git a/internal/bootstrapcmd/layered_runtime_open_other.go b/internal/bootstrapcmd/layered_runtime_open_other.go new file mode 100644 index 0000000..0b4357d --- /dev/null +++ b/internal/bootstrapcmd/layered_runtime_open_other.go @@ -0,0 +1,61 @@ +//go:build !windows + +package bootstrapcmd + +import ( + "crypto/sha256" + "crypto/subtle" + "fmt" + "io" + "math" + "os" +) + +func openVerifiedLayeredRuntime(runtime layeredRuntime) (_ *os.File, returnErr error) { + if runtime.size < 1 || runtime.size == math.MaxInt64 { + return nil, fmt.Errorf("layered runtime size must be between 1 and %d", math.MaxInt64-1) + } + file, err := os.Open(runtime.path) + if err != nil { + return nil, err + } + defer func() { + if returnErr != nil { + _ = file.Close() + } + }() + info, err := file.Stat() + if err != nil { + return nil, err + } + pathInfo, err := os.Lstat(runtime.path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || pathInfo.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, pathInfo) || info.Size() != runtime.size { + return nil, fmt.Errorf("layered runtime path or size is invalid") + } + digest := sha256.New() + written, err := io.Copy(digest, io.LimitReader(file, runtime.size+1)) + if err != nil { + return nil, err + } + if written != runtime.size || subtle.ConstantTimeCompare(digest.Sum(nil), runtime.digest[:]) != 1 { + return nil, fmt.Errorf("layered runtime digest or size mismatch") + } + postInfo, err := file.Stat() + if err != nil { + return nil, err + } + postPathInfo, err := os.Lstat(runtime.path) + if err != nil { + return nil, err + } + if !postInfo.Mode().IsRegular() || postPathInfo.Mode()&os.ModeSymlink != 0 || !os.SameFile(postInfo, postPathInfo) || postInfo.Size() != runtime.size { + return nil, fmt.Errorf("layered runtime changed while opening verified handle") + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return nil, err + } + return file, nil +} diff --git a/internal/bootstrapcmd/layered_runtime_open_windows.go b/internal/bootstrapcmd/layered_runtime_open_windows.go new file mode 100644 index 0000000..f7dc0f2 --- /dev/null +++ b/internal/bootstrapcmd/layered_runtime_open_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package bootstrapcmd + +import ( + "fmt" + "os" + + "github.com/EitanWong/remote-dev-skillkit/internal/tunnel" +) + +func openVerifiedLayeredRuntime(runtime layeredRuntime) (*os.File, error) { + if err := tunnel.ValidateProtectedParentChain(runtime.path); err != nil { + return nil, err + } + file, err := tunnel.OpenVerifiedProtectedExecutableSHA256(runtime.path, runtime.size, runtime.digest) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if info.Size() != runtime.size { + _ = file.Close() + return nil, fmt.Errorf("protected layered runtime size mismatch") + } + return file, nil +} diff --git a/internal/bootstrapcmd/layered_test.go b/internal/bootstrapcmd/layered_test.go new file mode 100644 index 0000000..69d533f --- /dev/null +++ b/internal/bootstrapcmd/layered_test.go @@ -0,0 +1,782 @@ +package bootstrapcmd + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/model" + "github.com/EitanWong/remote-dev-skillkit/internal/release" + "github.com/EitanWong/remote-dev-skillkit/internal/signing" + "github.com/EitanWong/remote-dev-skillkit/internal/trustref" +) + +func TestRunLayeredTemporaryUsesVerifiedDigestCache(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("verified Windows core runtime\n")) + manifestHits := 0 + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/manifest-start": + http.Redirect(w, r, "/release/layered-assets.json", http.StatusFound) + case "/release/layered-assets.json": + manifestHits++ + _, _ = w.Write(fixture.manifestJSON(t)) + case "/release/assets/rdev-host-windows-amd64.exe": + assetHits++ + _, _ = w.Write(fixture.payload) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cacheDir := filepath.Join(t.TempDir(), "layered-cache") + opts := fixture.options(server.URL+"/manifest-start", cacheDir, server.Client()) + opts.Now = time.Time{} + opts.Args = []string{"host", "serve", "--ticket-code", "must-not-be-reported"} + first, executablePath, err := RunLayeredTemporary(t.Context(), opts) + if err != nil { + t.Fatal(err) + } + if first.SchemaVersion != LayeredRunReportSchemaVersion || first.AssetID != "rdev-host-windows-amd64" || + first.FromCache || first.Resumed || first.Bytes != int64(len(fixture.payload)) { + t.Fatalf("unexpected cold layered report: %#v", first) + } + assertLayeredStages(t, first.Stages) + assertFileContent(t, executablePath, fixture.payload) + digest := strings.TrimPrefix(fixture.manifest.Assets[0].SHA256, "sha256:") + cachePath := filepath.Join(cacheDir, "content", digest) + assertFileContent(t, cachePath, fixture.payload) + assertLayeredPermissions(t, cacheDir, executablePath, cachePath) + encoded, err := json.Marshal(first) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{server.URL, cacheDir, executablePath, "must-not-be-reported", fixture.manifest.Signature} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("layered report leaked %q: %s", forbidden, encoded) + } + } + + if err := os.Remove(executablePath); err != nil { + t.Fatal(err) + } + second, secondPath, err := RunLayeredTemporary(t.Context(), opts) + if err != nil { + t.Fatal(err) + } + if !second.FromCache || second.Resumed || second.Bytes != int64(len(fixture.payload)) || secondPath != executablePath { + t.Fatalf("unexpected warm layered report/path: %#v %q", second, secondPath) + } + if manifestHits != 2 || assetHits != 1 { + t.Fatalf("warm cache should avoid runtime refetch, manifest=%d asset=%d", manifestHits, assetHits) + } + assertFileContent(t, secondPath, fixture.payload) +} + +func TestRunLayeredTemporaryResumesInterruptedTransferWithRange(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("0123456789abcdef-verified-runtime")) + cacheDir := filepath.Join(t.TempDir(), "layered-cache") + digest := strings.TrimPrefix(fixture.manifest.Assets[0].SHA256, "sha256:") + outputPath := filepath.Join(cacheDir, "runtime", digest, "rdev-host-windows-amd64.exe") + if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { + t.Fatal(err) + } + const partialBytes = 9 + if err := os.WriteFile(outputPath+".part", fixture.payload[:partialBytes], 0o600); err != nil { + t.Fatal(err) + } + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/layered-assets.json": + _, _ = w.Write(fixture.manifestJSON(t)) + case "/assets/rdev-host-windows-amd64.exe": + assetHits++ + if got := r.Header.Get("Range"); got != "bytes=9-" { + t.Errorf("Range = %q, want bytes=9-", got) + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes 9-%d/%d", len(fixture.payload)-1, len(fixture.payload))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(fixture.payload[partialBytes:]) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + report, gotPath, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", cacheDir, server.Client())) + if err != nil { + t.Fatal(err) + } + if !report.Resumed || report.FromCache || assetHits != 1 || gotPath != outputPath { + t.Fatalf("unexpected resumed report/path: %#v %q hits=%d", report, gotPath, assetHits) + } + assertFileContent(t, gotPath, fixture.payload) +} + +func TestRunLayeredTemporaryRejectsBadSignatureBeforeAssetRequest(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime must not download")) + fixture.manifest.Signature = strings.Repeat("A", len(fixture.manifest.Signature)) + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/layered-assets.json" { + _, _ = w.Write(fixture.manifestJSON(t)) + return + } + assetHits++ + _, _ = w.Write(fixture.payload) + })) + defer server.Close() + + report, executablePath, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", t.TempDir(), server.Client())) + if err == nil { + t.Fatalf("expected invalid signature rejection, got report=%#v path=%q", report, executablePath) + } + if assetHits != 0 || executablePath != "" { + t.Fatalf("bad signature reached asset/download preparation, hits=%d path=%q", assetHits, executablePath) + } +} + +func TestRunLayeredTemporaryRejectsManifestThatExpiresDuringFetch(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime must not download after manifest expiry")) + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/layered-assets.json": + manifest := fixture.manifest + manifest.GeneratedAt = time.Now().UTC().Add(-time.Minute) + manifest.ExpiresAt = time.Now().UTC().Add(100 * time.Millisecond) + signed, err := release.SignLayeredAssetManifest(manifest, fixture.key) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + time.Sleep(150 * time.Millisecond) + _ = json.NewEncoder(w).Encode(signed) + case "/assets/rdev-host-windows-amd64.exe": + assetHits++ + _, _ = w.Write(fixture.payload) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + opts := fixture.options(server.URL+"/layered-assets.json", t.TempDir(), server.Client()) + opts.Now = time.Time{} + report, executablePath, err := RunLayeredTemporary(t.Context(), opts) + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expected post-fetch expiry rejection, report=%#v path=%q err=%v", report, executablePath, err) + } + if assetHits != 0 || executablePath != "" { + t.Fatalf("expired manifest reached asset download, hits=%d path=%q", assetHits, executablePath) + } +} + +func TestRunLayeredTemporaryRejectsUnexpectedReleaseVersionBeforeAssetRequest(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime must not download for wrong release")) + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/layered-assets.json" { + _, _ = w.Write(fixture.manifestJSON(t)) + return + } + assetHits++ + _, _ = w.Write(fixture.payload) + })) + defer server.Close() + + opts := fixture.options(server.URL+"/layered-assets.json", t.TempDir(), server.Client()) + opts.ExpectedReleaseVersion = "v0.1.0" + report, executablePath, err := RunLayeredTemporary(t.Context(), opts) + if err == nil || !strings.Contains(err.Error(), "release version") { + t.Fatalf("expected release version mismatch rejection, report=%#v path=%q err=%v", report, executablePath, err) + } + if assetHits != 0 || executablePath != "" { + t.Fatalf("release version mismatch reached asset download, hits=%d path=%q", assetHits, executablePath) + } +} + +func TestRunLayeredTemporaryRejectsSignedSizeMismatch(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime with signed size contract")) + fixture.manifest.Assets[0].SizeBytes++ + manifest, err := release.SignLayeredAssetManifest(fixture.manifest, fixture.key) + if err != nil { + t.Fatal(err) + } + fixture.manifest = manifest + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/layered-assets.json" { + _, _ = w.Write(fixture.manifestJSON(t)) + return + } + assetHits++ + _, _ = w.Write(fixture.payload) + })) + defer server.Close() + report, executablePath, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", t.TempDir(), server.Client())) + if err == nil || !strings.Contains(err.Error(), "size") || executablePath != "" || assetHits != 1 { + t.Fatalf("expected signed size mismatch rejection, report=%#v path=%q err=%v hits=%d", report, executablePath, err, assetHits) + } +} + +func TestRunLayeredTemporaryRejectsHTTPAndInvalidManifestResponses(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime")) + t.Run("http scheme before request", func(t *testing.T) { + hits := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits++ })) + defer server.Close() + _, _, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", t.TempDir(), server.Client())) + if err == nil || hits != 0 { + t.Fatalf("expected HTTP manifest rejection before request, err=%v hits=%d", err, hits) + } + }) + + for _, tt := range []struct { + name string + status int + body []byte + }{ + {name: "non success status", status: http.StatusServiceUnavailable, body: []byte("unavailable")}, + {name: "invalid JSON", status: http.StatusOK, body: []byte(`{"schema_version":`)}, + {name: "trailing JSON", status: http.StatusOK, body: append(fixture.manifestJSON(t), []byte("\n{}")...)}, + {name: "oversized manifest", status: http.StatusOK, body: bytes.Repeat([]byte(" "), maxLayeredManifestBytes+1)}, + } { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write(tt.body) + })) + defer server.Close() + _, _, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", t.TempDir(), server.Client())) + if err == nil { + t.Fatal("expected manifest response rejection") + } + }) + } +} + +func TestRunLayeredTemporaryRejectsManifestJSONAmbiguityBeforeAssetRequest(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime must not download for ambiguous JSON")) + content := fixture.manifestJSON(t) + tests := map[string][]byte{ + "unknown field": append(append([]byte(nil), content[:len(content)-1]...), []byte(`,"unsigned_field":true}`)...), + "duplicate key": []byte(strings.Replace(string(content), `{"schema_version":`, `{"version":"v0.2.0","schema_version":`, 1)), + } + for name, ambiguous := range tests { + t.Run(name, func(t *testing.T) { + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/layered-assets.json" { + _, _ = w.Write(ambiguous) + return + } + assetHits++ + _, _ = w.Write(fixture.payload) + })) + defer server.Close() + + _, executablePath, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", t.TempDir(), server.Client())) + if err == nil { + t.Fatal("expected ambiguous layered manifest rejection") + } + if assetHits != 0 || executablePath != "" { + t.Fatalf("ambiguous manifest reached asset download, hits=%d path=%q", assetHits, executablePath) + } + }) + } +} + +func TestRunLayeredTemporaryRejectsManifestQueryBeforeRequest(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime")) + hits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + _, _ = w.Write(fixture.manifestJSON(t)) + })) + defer server.Close() + for _, suffix := range []string{"?token=must-not-leak", "?"} { + _, _, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json"+suffix, t.TempDir(), server.Client())) + if err == nil || hits != 0 { + t.Fatalf("expected manifest query rejection before request, suffix=%q err=%v hits=%d", suffix, err, hits) + } + } +} + +func TestRunLayeredTemporaryRejectsOversizedManagedFilesBeforeAssetRequest(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("signed runtime size")) + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/layered-assets.json" { + _, _ = w.Write(fixture.manifestJSON(t)) + return + } + assetHits++ + _, _ = w.Write(fixture.payload) + })) + defer server.Close() + + for _, fileName := range []string{"output", "content", "output.tmp", "content.tmp"} { + t.Run(fileName, func(t *testing.T) { + assetHits = 0 + cacheDir := t.TempDir() + paths := fixture.cachePaths(cacheDir) + managedPaths := map[string]string{ + "output": paths.output, + "content": paths.content, + "output.part": paths.output + ".part", + "output.tmp": paths.output + ".tmp", + "content.tmp": paths.content + ".tmp", + } + path := managedPaths[fileName] + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, bytes.Repeat([]byte("x"), len(fixture.payload)+1), 0o600); err != nil { + t.Fatal(err) + } + _, _, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", cacheDir, server.Client())) + if err == nil || assetHits != 0 { + t.Fatalf("expected oversized %s rejection before asset request, err=%v hits=%d", fileName, err, assetHits) + } + }) + } + + t.Run("oversized output partial is discarded and downloaded again", func(t *testing.T) { + assetHits = 0 + cacheDir := t.TempDir() + paths := fixture.cachePaths(cacheDir) + if err := os.MkdirAll(filepath.Dir(paths.output), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.output+".part", bytes.Repeat([]byte("x"), len(fixture.payload)+1), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", cacheDir, server.Client())); err != nil { + t.Fatal(err) + } + if assetHits != 1 { + t.Fatalf("expected one clean asset download after discarding oversized partial, got %d", assetHits) + } + if _, statErr := os.Stat(paths.output + ".part"); !os.IsNotExist(statErr) { + t.Fatalf("oversized partial was not removed, stat err=%v", statErr) + } + }) +} + +func TestRunLayeredTemporaryAcceptsVerifiedOutputWithoutContentCache(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("verified output hit")) + cacheDir := t.TempDir() + paths := fixture.cachePaths(cacheDir) + if err := os.MkdirAll(filepath.Dir(paths.output), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.output, fixture.payload, 0o600); err != nil { + t.Fatal(err) + } + assetHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/layered-assets.json" { + _, _ = w.Write(fixture.manifestJSON(t)) + return + } + assetHits++ + _, _ = w.Write(fixture.payload) + })) + defer server.Close() + report, executablePath, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/layered-assets.json", cacheDir, server.Client())) + if err != nil || !report.FromCache || executablePath != paths.output || assetHits != 0 { + t.Fatalf("expected verified output hit without content cache, report=%#v path=%q err=%v hits=%d", report, executablePath, err, assetHits) + } +} + +func TestSecureLayeredResultFilesRechecksSignedDigestAndSize(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("verified output")) + paths := fixture.cachePaths(t.TempDir()) + if err := os.MkdirAll(filepath.Dir(paths.output), 0o700); err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + name string + content []byte + asset release.LayeredAsset + }{ + {name: "digest", content: []byte("tampered output"), asset: fixture.manifest.Assets[0]}, + {name: "size", content: fixture.payload, asset: func() release.LayeredAsset { asset := fixture.manifest.Assets[0]; asset.SizeBytes++; return asset }()}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(paths.output, tt.content, 0o600); err != nil { + t.Fatal(err) + } + if err := secureLayeredResultFiles(paths, tt.asset); err == nil { + t.Fatalf("expected final %s mismatch rejection", tt.name) + } + }) + } +} + +func TestRunLayeredTemporaryRejectsCrossOriginRedirects(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime")) + t.Run("manifest", func(t *testing.T) { + targetHits := 0 + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetHits++ + _, _ = w.Write(fixture.manifestJSON(t)) + })) + defer target.Close() + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/layered-assets.json", http.StatusFound) + })) + defer origin.Close() + _, _, err := RunLayeredTemporary(t.Context(), fixture.options(origin.URL+"/layered-assets.json", t.TempDir(), origin.Client())) + if err == nil || targetHits != 0 { + t.Fatalf("expected cross-origin manifest redirect rejection, err=%v target_hits=%d", err, targetHits) + } + }) + + t.Run("asset", func(t *testing.T) { + targetHits := 0 + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetHits++ + _, _ = w.Write(fixture.payload) + })) + defer target.Close() + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/layered-assets.json": + _, _ = w.Write(fixture.manifestJSON(t)) + case "/assets/rdev-host-windows-amd64.exe": + http.Redirect(w, r, target.URL+"/runtime.exe", http.StatusFound) + default: + http.NotFound(w, r) + } + })) + defer origin.Close() + _, _, err := RunLayeredTemporary(t.Context(), fixture.options(origin.URL+"/layered-assets.json", t.TempDir(), origin.Client())) + if err == nil || targetHits != 0 { + t.Fatalf("expected cross-origin asset redirect rejection, err=%v target_hits=%d", err, targetHits) + } + }) +} + +func TestRunLayeredTemporaryPreservesCallerRedirectPolicy(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime")) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/start" { + http.Redirect(w, r, "/layered-assets.json", http.StatusFound) + return + } + _, _ = w.Write(fixture.manifestJSON(t)) + })) + defer server.Close() + sentinel := errors.New("caller redirect policy") + policyCalls := 0 + client := server.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { + policyCalls++ + return sentinel + } + _, _, err := RunLayeredTemporary(t.Context(), fixture.options(server.URL+"/start", t.TempDir(), client)) + if !errors.Is(err, sentinel) || policyCalls != 1 { + t.Fatalf("caller redirect policy was not preserved: err=%v calls=%d", err, policyCalls) + } +} + +func TestLayeredRunCLIExecutesVerifiedRuntimeInForeground(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("downloaded Windows runtime")) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/layered-assets.json": + _, _ = w.Write(fixture.manifestJSON(t)) + case "/assets/rdev-host-windows-amd64.exe": + _, _ = w.Write(fixture.payload) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + var stdout bytes.Buffer + var stderr bytes.Buffer + var invokedPath string + var invokedArgs []string + cacheDir := filepath.Join(t.TempDir(), "cache") + app := App{ + Stdout: &stdout, + Stderr: &stderr, + Stdin: strings.NewReader("inherited-input"), + Client: server.Client(), + CommandContext: func(ctx context.Context, path string, args ...string) *exec.Cmd { + invokedPath = path + invokedArgs = append([]string(nil), args...) + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestLayeredRunCLIHelperProcess$") + cmd.Env = append(os.Environ(), "RDEV_LAYERED_RUN_HELPER_PROCESS=1") + return cmd + }, + } + err := app.Run(t.Context(), []string{ + "layered-run", + "--manifest-url", server.URL + "/layered-assets.json", + "--root-public-key", trustref.Encode(fixture.key.ID, fixture.key.PublicKey), + "--expected-release-version", fixture.manifest.Version, + "--platform", "windows/amd64", + "--cache-dir", cacheDir, + "--mode", "temporary", + "--", "host", "serve", "--ticket-code", "ticket-secret", + }) + if err != nil { + t.Fatal(err) + } + if filepath.Base(invokedPath) != "rdev-host-windows-amd64.exe" || strings.Join(invokedArgs, "\x00") != "host\x00serve\x00--ticket-code\x00ticket-secret" { + t.Fatalf("unexpected foreground invocation: %q %#v", invokedPath, invokedArgs) + } + if !strings.Contains(stdout.String(), `"schema_version":"rdev.layered-run-report.v1"`) || + !strings.Contains(stdout.String(), "helper-stdin=inherited-input") || + !strings.Contains(stderr.String(), "helper-stderr") { + t.Fatalf("foreground stdio/report contract failed, stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + for _, forbidden := range []string{server.URL, cacheDir, invokedPath, "ticket-secret"} { + if strings.Contains(stdout.String(), forbidden) { + t.Fatalf("layered CLI report leaked %q: %s", forbidden, stdout.String()) + } + } +} + +func TestLayeredRunCLIRejectsInjectedPreExecRuntimeSwap(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows denies the injected rename while the executable handle is locked") + } + fixture := newLayeredTestFixture(t, []byte("runtime verified before injected replacement")) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/layered-assets.json": + _, _ = w.Write(fixture.manifestJSON(t)) + case "/assets/rdev-host-windows-amd64.exe": + _, _ = w.Write(fixture.payload) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + markerPath := filepath.Join(t.TempDir(), "runtime-executed") + commandContextCalls := 0 + app := App{ + Stdout: io.Discard, + Stderr: io.Discard, + Stdin: strings.NewReader(""), + Client: server.Client(), + CommandContext: func(ctx context.Context, path string, args ...string) *exec.Cmd { + commandContextCalls++ + replacementPath := path + ".injected-swap" + replacement := bytes.Repeat([]byte("x"), len(fixture.payload)) + if err := os.WriteFile(replacementPath, replacement, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacementPath, path); err != nil { + t.Fatal(err) + } + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestLayeredRunCLIHelperProcess$") + cmd.Env = append(os.Environ(), + "RDEV_LAYERED_RUN_HELPER_PROCESS=1", + "RDEV_LAYERED_RUN_HELPER_MARKER="+markerPath, + ) + return cmd + }, + } + err := app.Run(t.Context(), []string{ + "layered-run", + "--manifest-url", server.URL + "/layered-assets.json", + "--root-public-key", trustref.Encode(fixture.key.ID, fixture.key.PublicKey), + "--expected-release-version", fixture.manifest.Version, + "--platform", "windows/amd64", + "--cache-dir", filepath.Join(t.TempDir(), "cache"), + "--mode", "temporary", + "--", "host", "serve", + }) + if err == nil || !strings.Contains(err.Error(), "changed before execution") { + t.Fatalf("pre-exec runtime swap error = %v, want fail-closed rejection", err) + } + if commandContextCalls != 1 { + t.Fatalf("CommandContext calls = %d, want 1 injected swap attempt", commandContextCalls) + } + if _, statErr := os.Stat(markerPath); !os.IsNotExist(statErr) { + t.Fatalf("swapped runtime command executed, marker stat error = %v", statErr) + } +} + +func TestLayeredRunCLIRequiresTemporaryWindowsAMD64Contract(t *testing.T) { + fixture := newLayeredTestFixture(t, []byte("runtime")) + root := trustref.Encode(fixture.key.ID, fixture.key.PublicKey) + tests := []struct { + name string + args []string + want string + }{ + {name: "missing mode", args: []string{"--manifest-url", "https://gateway.test/layered-assets.json", "--root-public-key", root, "--platform", "windows/amd64", "--cache-dir", t.TempDir()}, want: "mode temporary"}, + {name: "managed mode", args: []string{"--manifest-url", "https://gateway.test/layered-assets.json", "--root-public-key", root, "--expected-release-version", fixture.manifest.Version, "--platform", "windows/amd64", "--cache-dir", t.TempDir(), "--mode", "managed"}, want: "mode temporary"}, + {name: "missing expected release version", args: []string{"--manifest-url", "https://gateway.test/layered-assets.json", "--root-public-key", root, "--platform", "windows/amd64", "--cache-dir", t.TempDir(), "--mode", "temporary"}, want: "expected release version"}, + {name: "wrong platform", args: []string{"--manifest-url", "https://gateway.test/layered-assets.json", "--root-public-key", root, "--expected-release-version", fixture.manifest.Version, "--platform", "windows-amd64", "--cache-dir", t.TempDir(), "--mode", "temporary"}, want: "supported --platform"}, + {name: "invalid root", args: []string{"--manifest-url", "https://gateway.test/layered-assets.json", "--root-public-key", "invalid", "--expected-release-version", fixture.manifest.Version, "--platform", "windows/amd64", "--cache-dir", t.TempDir(), "--mode", "temporary"}, want: "root public key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := App{Stdout: io.Discard, Stderr: io.Discard, Client: testHTTPClient(func(r *http.Request) (*http.Response, error) { + t.Fatalf("invalid layered CLI contract made request to %s", r.URL) + return nil, nil + })} + err := app.Run(t.Context(), append([]string{"layered-run"}, tt.args...)) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + }) + } +} + +func TestLayeredRunCLIHelperProcess(t *testing.T) { + if os.Getenv("RDEV_LAYERED_RUN_HELPER_PROCESS") != "1" { + return + } + if markerPath := os.Getenv("RDEV_LAYERED_RUN_HELPER_MARKER"); markerPath != "" { + if err := os.WriteFile(markerPath, []byte("executed\n"), 0o600); err != nil { + t.Fatal(err) + } + } + content, err := io.ReadAll(os.Stdin) + if err != nil { + t.Fatal(err) + } + _, _ = fmt.Fprintf(os.Stdout, "helper-stdin=%s", content) + _, _ = fmt.Fprint(os.Stderr, "helper-stderr") +} + +type layeredTestFixture struct { + payload []byte + manifest release.LayeredAssetManifest + key signing.Key + now time.Time +} + +func newLayeredTestFixture(t *testing.T, payload []byte) layeredTestFixture { + t.Helper() + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Add(-time.Minute) + sum := sha256.Sum256(payload) + manifest, err := release.SignLayeredAssetManifest(release.LayeredAssetManifest{ + SchemaVersion: release.LayeredAssetManifestSchemaVersion, + Version: "v0.2.0", + GeneratedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + Assets: []release.LayeredAsset{{ + ID: "rdev-host-windows-amd64", + Platform: "windows/amd64", + Kind: "core-runtime", + RelativePath: "assets/rdev-host-windows-amd64.exe", + SHA256: "sha256:" + hex.EncodeToString(sum[:]), + SizeBytes: int64(len(payload)), + }}, + }, key) + if err != nil { + t.Fatal(err) + } + return layeredTestFixture{payload: append([]byte(nil), payload...), manifest: manifest, key: key, now: now} +} + +func (f layeredTestFixture) manifestJSON(t *testing.T) []byte { + t.Helper() + content, err := json.Marshal(f.manifest) + if err != nil { + t.Fatal(err) + } + return content +} + +func (f layeredTestFixture) options(manifestURL, cacheDir string, client *http.Client) LayeredRunOptions { + return LayeredRunOptions{ + ManifestURL: manifestURL, + Root: model.NewTrustBundle(f.key.ID, f.key.PublicKey), + ExpectedReleaseVersion: f.manifest.Version, + Platform: "windows/amd64", + CacheDir: cacheDir, + Mode: "temporary", + Client: client, + Now: f.now, + } +} + +func (f layeredTestFixture) cachePaths(cacheDir string) layeredCachePaths { + digest := strings.TrimPrefix(f.manifest.Assets[0].SHA256, "sha256:") + return layeredCachePaths{ + output: filepath.Join(cacheDir, "runtime", digest, filepath.Base(f.manifest.Assets[0].RelativePath)), + content: filepath.Join(cacheDir, "content", digest), + } +} + +func assertLayeredStages(t *testing.T, stages []LayeredRunStage) { + t.Helper() + want := []string{"manifest-fetch", "signature-verification", "runtime-download", "runtime-launch-preparation"} + if len(stages) != len(want) { + t.Fatalf("stages = %#v, want %v", stages, want) + } + for index, stage := range stages { + if stage.Name != want[index] || stage.DurationMS < 0 { + t.Fatalf("stage[%d] = %#v, want name %q with nonnegative duration", index, stage, want[index]) + } + } +} + +func assertFileContent(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("file %s content = %q, want %q", filepath.Base(path), got, want) + } +} + +func assertLayeredPermissions(t *testing.T, cacheDir, executablePath, cachePath string) { + t.Helper() + if runtime.GOOS == "windows" { + return + } + for _, dir := range []string{cacheDir, filepath.Join(cacheDir, "runtime"), filepath.Dir(executablePath), filepath.Join(cacheDir, "content")} { + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o700 { + t.Fatalf("directory %s mode = %o, want 0700", filepath.Base(dir), info.Mode().Perm()) + } + } + for _, path := range []string{executablePath, cachePath} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("file %s mode = %o, want 0600", filepath.Base(path), info.Mode().Perm()) + } + } +} diff --git a/internal/bootstrapcmd/windowsentry/app.go b/internal/bootstrapcmd/windowsentry/app.go new file mode 100644 index 0000000..e3dfbda --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/app.go @@ -0,0 +1,619 @@ +package windowsentry + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" + "github.com/EitanWong/remote-dev-skillkit/internal/release" +) + +const ( + RunReportSchemaVersion = "rdev.windows-entry-run-report.v1" + maxManifestBytes = int64(1 << 20) +) + +type App struct { + Stdout io.Writer + Stderr io.Writer + Stdin io.Reader + Transport assetdownload.Transport + Now time.Time + Clock func() time.Time + CommandContext func(context.Context, string, ...string) *exec.Cmd + download func(context.Context, assetdownload.Options) (assetdownload.Result, error) +} + +type RunReport struct { + SchemaVersion string `json:"schema_version"` + AssetID string `json:"asset_id"` + FromCache bool `json:"from_cache"` + Resumed bool `json:"resumed"` + Bytes int64 `json:"bytes"` +} + +func (a App) Run(ctx context.Context, args []string) (resultErr error) { + if len(args) == 0 || args[0] != "layered-run" { + return newPreCoreError("invalid_input") + } + if len(args) > 1 && (args[1] == "attempt-check" || args[1] == "private-path-check") { + return a.runCheck(args[1] == "attempt-check", args[2:]) + } + opts, err := parseRunArgs(args[1:]) + if err != nil { + return newPreCoreError("invalid_input") + } + if opts.mode != "temporary" { + return newPreCoreError("invalid_input") + } + if opts.platform != "windows/amd64" && opts.platform != "windows/arm64" { + return newPreCoreError("invalid_input") + } + if strings.TrimSpace(opts.expectedVersion) == "" { + return newPreCoreError("invalid_input") + } + if strings.TrimSpace(opts.cacheDir) == "" { + return newPreCoreError("invalid_input") + } + if strings.TrimSpace(opts.attemptDir) == "" || !opts.launcher.valid() { + return newPreCoreError("invalid_input") + } + if !validCoreTransport(opts.args) { + return newPreCoreError("invalid_input") + } + attempt, err := acquireAttempt(opts.attemptDir, opts.launcher, a.now()) + if err != nil { + if errors.Is(err, errAttemptClosed) { + return newPreCoreError("attempt_closed") + } + if errors.Is(err, errAttemptBusy) { + return newPreCoreError("attempt_busy") + } + return newPreCoreError("attempt_invalid") + } + defer func() { + if closeErr := attempt.close(); closeErr != nil && resultErr == nil { + resultErr = errors.New("layered attempt lock release failed") + } + }() + + parsedManifestURL, err := strictHTTPSURL(opts.manifestURL) + if err != nil { + return newPreCoreError("invalid_input") + } + root, err := release.ParseLayeredTrustRoot(opts.rootPublicKey) + if err != nil { + return newPreCoreError("invalid_input") + } + transport := a.Transport + if transport == nil { + transport, err = defaultTransport() + if err != nil { + return newPreCoreError("transport_unavailable") + } + } + manifest, err := fetchManifest(ctx, transport, parsedManifestURL.String()) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return newPreCoreError("manifest_fetch") + } + if err := release.VerifyLayeredAssetManifestRoot(manifest, root, a.now()); err != nil { + return newPreCoreError("manifest_verify") + } + if manifest.Version != strings.TrimSpace(opts.expectedVersion) { + return newPreCoreError("manifest_verify") + } + asset, err := release.SelectLayeredAsset(manifest, opts.platform, "core-runtime", nil) + if err != nil { + return newPreCoreError("manifest_verify") + } + assetURL, err := resolveAssetURL(parsedManifestURL, asset.RelativePath) + if err != nil { + return newPreCoreError("manifest_verify") + } + outputPath, contentPath, err := cachePaths(opts.cacheDir, asset) + if err != nil { + return newPreCoreError("runtime_prepare") + } + + download := a.download + if download == nil { + download = assetdownload.Download + } + result, err := download(ctx, assetdownload.Options{ + Mirrors: []assetdownload.Mirror{{URL: assetURL.String()}}, + OutputPath: outputPath, + CachePath: contentPath, + ExpectedSHA256: asset.SHA256, + ExpectedSize: asset.SizeBytes, + Transport: transport, + }) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return newPreCoreError("runtime_prepare") + } + if result.Bytes != asset.SizeBytes { + return newPreCoreError("runtime_prepare") + } + if err := os.Chmod(result.OutputPath, 0o600); err != nil { + return newPreCoreError("runtime_prepare") + } + if err := validatePrivateCacheFile(result.OutputPath, asset.SizeBytes); err != nil { + return newPreCoreError("runtime_prepare") + } + if err := validateOptionalPrivateCacheFile(contentPath, asset.SizeBytes); err != nil { + return newPreCoreError("runtime_prepare") + } + runtimeFile, err := openVerifiedRuntime(result.OutputPath, asset.SizeBytes, asset.SHA256) + if err != nil { + return newPreCoreError("runtime_prepare") + } + defer runtimeFile.Close() + + report := RunReport{ + SchemaVersion: RunReportSchemaVersion, + AssetID: asset.ID, + FromCache: result.FromCache, + Resumed: result.Resumed, + Bytes: result.Bytes, + } + cmd := a.commandContext(ctx, result.OutputPath, opts.args...) + if cmd == nil { + return newPreCoreError("runtime_prepare") + } + cmd.Stdin = a.stdin() + cmd.Stdout = a.stdout() + cmd.Stderr = a.stderr() + lifecycle, err := newCoreLifecycle(cmd) + if err != nil { + return newPreCoreError("runtime_prepare") + } + defer lifecycle.close() + if err := recheckVerifiedRuntime(runtimeFile, result.OutputPath, asset.SizeBytes, asset.SHA256); err != nil { + return newPreCoreError("runtime_prepare") + } + if err := writeRunReport(a.stdout(), report); err != nil { + return newPreCoreError("report_output") + } + if err := attempt.transition(attemptStageCoreStarted, a.now()); err != nil { + return newPreCoreError("attempt_state") + } + cleaned, runErr := lifecycle.run(ctx, cmd) + if !cleaned { + return errors.New("layered core containment failed") + } + if err := attempt.transition(attemptStageCoreExited, a.now()); err != nil { + return errors.New("layered core exit state failed") + } + if ctx.Err() != nil { + return ctx.Err() + } + if runErr != nil { + return errors.New("layered core lifecycle failed") + } + return nil +} + +func (a App) runCheck(attemptCommand bool, args []string) error { + if !attemptCommand { + if len(args) != 4 || args[0] != "--path" || args[2] != "--kind" { + return errInvalidAttemptState + } + directory := args[3] == "directory" + if args[1] == "" || args[3] != "directory" && args[3] != "file" { + return errInvalidAttemptState + } + return validatePrivateLauncherPath(args[1], directory) + } + create := len(args) == 5 && args[4] == "--create" + if len(args) != 4 && !create || args[0] != "--attempt-dir" || args[2] != "--launcher" { + return errInvalidAttemptState + } + directory := args[1] + launcher := attemptLauncher(args[3]) + if directory == "" || !launcher.valid() { + return errInvalidAttemptState + } + if create { + if err := preparePrivateAttemptDirectory(directory); err != nil { + return err + } + } else if _, err := validatePrivateAttemptDirectory(directory); err != nil { + return err + } + attempt, err := acquireAttempt(directory, launcher, a.now()) + if err != nil { + return err + } + return attempt.close() +} + +func writeRunReport(destination io.Writer, report RunReport) error { + encoded := make([]byte, 0, 160) + encoded = append(encoded, `{"schema_version":`...) + encoded = appendReportString(encoded, report.SchemaVersion) + encoded = append(encoded, `,"asset_id":`...) + encoded = appendReportString(encoded, report.AssetID) + encoded = append(encoded, `,"from_cache":`...) + encoded = strconv.AppendBool(encoded, report.FromCache) + encoded = append(encoded, `,"resumed":`...) + encoded = strconv.AppendBool(encoded, report.Resumed) + encoded = append(encoded, `,"bytes":`...) + encoded = strconv.AppendInt(encoded, report.Bytes, 10) + encoded = append(encoded, '}', '\n') + written, err := destination.Write(encoded) + if err == nil && written != len(encoded) { + return io.ErrShortWrite + } + return err +} + +func appendReportString(destination []byte, value string) []byte { + const hex = "0123456789abcdef" + destination = append(destination, '"') + for _, character := range value { + switch character { + case '\\', '"': + destination = append(destination, '\\', byte(character)) + case '\b': + destination = append(destination, `\b`...) + case '\f': + destination = append(destination, `\f`...) + case '\n': + destination = append(destination, `\n`...) + case '\r': + destination = append(destination, `\r`...) + case '\t': + destination = append(destination, `\t`...) + default: + if character < 0x20 { + destination = append(destination, `\u00`...) + destination = append(destination, hex[(character>>4)&15], hex[character&15]) + } else { + destination = utf8.AppendRune(destination, character) + } + } + } + return append(destination, '"') +} + +func fetchManifest(ctx context.Context, transport assetdownload.Transport, rawURL string) (_ release.LayeredAssetManifest, resultErr error) { + response, err := transport.Fetch(ctx, assetdownload.TransportRequest{URL: rawURL, MaxBytes: maxManifestBytes}) + if err != nil { + if response.Body != nil { + err = errors.Join(err, wrapManifestBodyCloseError(response.Body.Close())) + } + return release.LayeredAssetManifest{}, err + } + if response.Body == nil { + return release.LayeredAssetManifest{}, fmt.Errorf("manifest response body is required") + } + defer func() { + resultErr = errors.Join(resultErr, wrapManifestBodyCloseError(response.Body.Close())) + }() + if response.StatusCode != 200 { + return release.LayeredAssetManifest{}, fmt.Errorf("layered manifest request failed: status %d", response.StatusCode) + } + if response.ContentLength > maxManifestBytes { + return release.LayeredAssetManifest{}, fmt.Errorf("layered manifest exceeds %d bytes", maxManifestBytes) + } + content, err := io.ReadAll(io.LimitReader(response.Body, maxManifestBytes+1)) + if err != nil { + return release.LayeredAssetManifest{}, err + } + if int64(len(content)) > maxManifestBytes { + return release.LayeredAssetManifest{}, fmt.Errorf("layered manifest exceeds %d bytes", maxManifestBytes) + } + return release.DecodeLayeredAssetManifest(content) +} + +func wrapManifestBodyCloseError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("close layered manifest response body: %w", err) +} + +type httpsURL struct { + raw string + origin string + directory string +} + +func (parsed httpsURL) String() string { + return parsed.raw +} + +func strictHTTPSURL(rawURL string) (httpsURL, error) { + rawURL = strings.TrimSpace(rawURL) + if len(rawURL) <= len("https://") || !strings.EqualFold(rawURL[:len("https://")], "https://") || strings.ContainsAny(rawURL, "?#\\") { + return httpsURL{}, fmt.Errorf("layered URL must be HTTPS without credentials, query, or fragment") + } + remainder := rawURL[len("https://"):] + slash := strings.IndexByte(remainder, '/') + authority := remainder + pathValue := "" + if slash >= 0 { + authority = remainder[:slash] + pathValue = remainder[slash:] + } + if !validHTTPSAuthority(authority) || strings.ContainsAny(authority, "@%") || containsURLControl(authority) || containsURLControl(pathValue) { + return httpsURL{}, fmt.Errorf("layered URL must be HTTPS without credentials, query, or fragment") + } + origin := rawURL[:len("https://")] + authority + directory := "/" + if pathValue != "" { + directory = pathValue[:strings.LastIndexByte(pathValue, '/')+1] + } + return httpsURL{raw: rawURL, origin: origin, directory: directory}, nil +} + +func validHTTPSAuthority(authority string) bool { + if authority == "" { + return false + } + if authority[0] == '[' { + closing := strings.IndexByte(authority, ']') + if closing <= 1 || !validIPv6Literal(authority[1:closing]) { + return false + } + remainder := authority[closing+1:] + return remainder == "" || strings.HasPrefix(remainder, ":") && validHTTPSPort(remainder[1:]) + } + if strings.ContainsAny(authority, "[]") || strings.Count(authority, ":") > 1 { + return false + } + host := authority + port := "" + if colon := strings.LastIndexByte(authority, ':'); colon >= 0 { + host, port = authority[:colon], authority[colon+1:] + if !validHTTPSPort(port) { + return false + } + } + if host == "" || strings.Contains(host, "..") { + return false + } + for index := 0; index < len(host); index++ { + character := host[index] + if !isASCIIAlphaNumeric(character) && character != '.' && character != '-' { + return false + } + } + return true +} + +func validIPv6Literal(host string) bool { + if !strings.Contains(host, ":") { + return false + } + for index := 0; index < len(host); index++ { + character := host[index] + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F') || character == ':' || character == '.') { + return false + } + } + return true +} + +func validHTTPSPort(port string) bool { + if port == "" || len(port) > 5 { + return false + } + value := 0 + for index := 0; index < len(port); index++ { + if port[index] < '0' || port[index] > '9' { + return false + } + value = value*10 + int(port[index]-'0') + } + return value > 0 && value <= 65535 +} + +func isASCIIAlphaNumeric(character byte) bool { + return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' +} + +func resolveAssetURL(manifestURL httpsURL, relativePath string) (httpsURL, error) { + if relativePath == "" || path.Clean(relativePath) != relativePath || strings.HasPrefix(relativePath, "/") || strings.ContainsAny(relativePath, "\\?#:%") { + return httpsURL{}, fmt.Errorf("invalid layered asset URL") + } + return httpsURL{raw: manifestURL.origin + manifestURL.directory + relativePath, origin: manifestURL.origin}, nil +} + +func containsURLControl(value string) bool { + for index := 0; index < len(value); index++ { + if value[index] <= ' ' || value[index] >= 0x7f { + return true + } + } + return false +} + +type runOptions struct { + manifestURL string + rootPublicKey string + expectedVersion string + platform string + cacheDir string + attemptDir string + launcher attemptLauncher + mode string + args []string +} + +func parseRunArgs(args []string) (runOptions, error) { + var opts runOptions + seen := make(map[string]bool, 6) + for index := 0; index < len(args); index++ { + name := args[index] + if name == "--" { + opts.args = append([]string(nil), args[index+1:]...) + return opts, nil + } + if !strings.HasPrefix(name, "--") || index+1 >= len(args) || seen[name] { + return runOptions{}, fmt.Errorf("invalid or duplicate layered-run argument %q", name) + } + seen[name] = true + index++ + value := args[index] + switch name { + case "--manifest-url": + opts.manifestURL = value + case "--root-public-key": + opts.rootPublicKey = value + case "--expected-release-version": + opts.expectedVersion = value + case "--platform": + opts.platform = value + case "--cache-dir": + opts.cacheDir = value + case "--attempt-dir": + opts.attemptDir = value + case "--launcher": + opts.launcher = attemptLauncher(value) + case "--mode": + opts.mode = value + default: + return runOptions{}, fmt.Errorf("unknown layered-run argument %q", name) + } + } + return opts, nil +} + +func validCoreTransport(args []string) bool { + found := false + for index := 0; index < len(args); index++ { + if args[index] == "--transport" { + if found || index+1 >= len(args) || args[index+1] != "auto" { + return false + } + found = true + index++ + continue + } + if strings.HasPrefix(args[index], "--transport=") { + return false + } + } + return found +} + +func validateRuntime(path string, expectedSize int64) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() != expectedSize { + return fmt.Errorf("downloaded runtime is not a regular file with the signed size") + } + return nil +} + +func openVerifiedRuntime(path string, expectedSize int64, expectedSHA256 string) (*os.File, error) { + if err := validateRuntime(path, expectedSize); err != nil { + return nil, err + } + file, err := openPrivateRuntime(path) + if err != nil { + return nil, err + } + valid := false + defer func() { + if !valid { + _ = file.Close() + } + }() + if err := recheckVerifiedRuntime(file, path, expectedSize, expectedSHA256); err != nil { + return nil, err + } + valid = true + return file, nil +} + +func recheckVerifiedRuntime(file *os.File, path string, expectedSize int64, expectedSHA256 string) error { + if file == nil { + return fmt.Errorf("downloaded runtime verification handle is required") + } + if err := validatePrivateCacheFile(path, expectedSize); err != nil { + return err + } + info, err := file.Stat() + pathInfo, pathErr := os.Lstat(path) + if err != nil || pathErr != nil || !info.Mode().IsRegular() || pathInfo.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, pathInfo) || info.Size() != expectedSize { + return fmt.Errorf("downloaded runtime identity changed before verification") + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + digest := sha256.New() + written, err := io.Copy(digest, io.LimitReader(file, expectedSize+1)) + if err != nil { + return err + } + actualSHA256 := "sha256:" + hex.EncodeToString(digest.Sum(nil)) + if written != expectedSize || actualSHA256 != expectedSHA256 { + return fmt.Errorf("downloaded runtime does not match the signed digest and size") + } + postInfo, err := file.Stat() + postPathInfo, pathErr := os.Lstat(path) + if err != nil || pathErr != nil || !os.SameFile(postInfo, postPathInfo) || postInfo.Size() != expectedSize { + return fmt.Errorf("downloaded runtime changed during final verification") + } + return nil +} + +func (a App) stdout() io.Writer { + if a.Stdout != nil { + return a.Stdout + } + return os.Stdout +} + +func (a App) stderr() io.Writer { + if a.Stderr != nil { + return a.Stderr + } + return os.Stderr +} + +func (a App) stdin() io.Reader { + if a.Stdin != nil { + return a.Stdin + } + return os.Stdin +} + +func (a App) now() time.Time { + if a.Clock != nil { + if now := a.Clock(); !now.IsZero() { + return now + } + } + if !a.Now.IsZero() { + return a.Now + } + return time.Now() +} + +func (a App) commandContext(ctx context.Context, path string, args ...string) *exec.Cmd { + if a.CommandContext != nil { + return a.CommandContext(ctx, path, args...) + } + return exec.CommandContext(ctx, path, args...) +} diff --git a/internal/bootstrapcmd/windowsentry/app_test.go b/internal/bootstrapcmd/windowsentry/app_test.go new file mode 100644 index 0000000..4e21b2c --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/app_test.go @@ -0,0 +1,741 @@ +//go:build !rdev_bootstrap_focused + +package windowsentry + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" + "github.com/EitanWong/remote-dev-skillkit/internal/release" + "github.com/EitanWong/remote-dev-skillkit/internal/signing" +) + +func TestWindowsEntryRejectsEveryNonLayeredSubcommand(t *testing.T) { + for _, args := range [][]string{ + nil, + {"upgrade"}, + {"help"}, + {"doctor"}, + {"--help"}, + } { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + app := App{Transport: &recordingTransport{}} + if err := app.Run(t.Context(), args); err == nil { + t.Fatalf("App.Run(%q) accepted a command other than layered-run", args) + } + }) + } +} + +func TestWindowsEntryAttemptCheckInitializesAndReusesPreCoreState(t *testing.T) { + directory := privateAttemptDirForTest(t) + if err := os.Remove(directory); err != nil { + t.Fatal(err) + } + app := App{Now: time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)} + args := []string{"layered-run", "attempt-check", "--attempt-dir", directory, "--launcher", "powershell", "--create"} + if err := app.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + state, err := readAttemptState(filepath.Join(directory, attemptStateFilename)) + if err != nil { + t.Fatal(err) + } + if state.AttemptID != filepath.Base(directory) || state.Stage != attemptStagePreCore || state.Launcher != launcherPowerShell { + t.Fatalf("unexpected initialized attempt state: %#v", state) + } + if _, err := os.Stat(filepath.Join(directory, attemptLockFilename)); !os.IsNotExist(err) { + t.Fatalf("attempt-check left its lock behind: %v", err) + } + if err := app.Run(t.Context(), []string{"layered-run", "attempt-check", "--attempt-dir", directory, "--launcher", "cmd"}); err != nil { + t.Fatalf("canonical pre_core state was not reusable: %v", err) + } + if err := app.Run(t.Context(), args); err == nil { + t.Fatal("attempt-check reused an existing directory with --create") + } +} + +func TestWindowsEntryAttemptCheckRejectsInvalidGrammarAndClosedState(t *testing.T) { + directory := privateAttemptDirForTest(t) + app := App{Now: time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)} + for _, args := range [][]string{ + {"layered-run", "attempt-check"}, + {"layered-run", "attempt-check", "--attempt-dir", directory}, + {"layered-run", "attempt-check", "--attempt-dir", directory, "--launcher", "cmd", "extra"}, + {"layered-run", "attempt-check", "--attempt-dir", directory, "--attempt-dir", directory, "--launcher", "cmd"}, + {"layered-run", "attempt-check", "--attempt-dir", directory, "--launcher", "invalid"}, + {"layered-run", "attempt-check", "--unknown", "value", "--attempt-dir", directory, "--launcher", "cmd"}, + {"layered-run", "attempt-check", "--path", directory, "--kind", "directory"}, + } { + if err := app.Run(t.Context(), args); err == nil { + t.Fatalf("attempt-check accepted invalid arguments %q", args) + } + } + if err := writeAttemptState(directory, attemptState{ + SchemaVersion: AttemptStateSchemaVersion, + AttemptID: filepath.Base(directory), + Stage: attemptStageCoreExited, + Launcher: launcherPowerShell, + UpdatedAt: "2026-07-17T08:00:00Z", + }); err != nil { + t.Fatal(err) + } + if err := app.Run(t.Context(), []string{"layered-run", "attempt-check", "--attempt-dir", directory, "--launcher", "cmd"}); err == nil { + t.Fatal("attempt-check accepted core_exited state") + } +} + +func TestWindowsEntryAttemptCheckWithoutCreateRejectsMissingDirectory(t *testing.T) { + directory := privateAttemptDirForTest(t) + if err := os.Remove(directory); err != nil { + t.Fatal(err) + } + app := App{Now: time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)} + if err := app.Run(t.Context(), []string{"layered-run", "attempt-check", "--attempt-dir", directory, "--launcher", "cmd"}); err == nil { + t.Fatal("attempt-check without --create accepted a missing directory") + } + if _, err := os.Stat(directory); !os.IsNotExist(err) { + t.Fatalf("attempt-check without --create mutated the missing directory: %v", err) + } +} + +func TestWindowsEntryPrivatePathCheckAcceptsOnlyExactGrammarAndPrivateObjects(t *testing.T) { + directory := privateAttemptDirForTest(t) + filePath := privateLauncherFileForTest(t, directory, "launcher.cmd") + app := App{} + for _, args := range [][]string{ + {"layered-run", "private-path-check", "--path", directory, "--kind", "directory"}, + {"layered-run", "private-path-check", "--path", filePath, "--kind", "file"}, + } { + if err := app.Run(t.Context(), args); err != nil { + t.Fatalf("private path check rejected %q: %v", args, err) + } + } + for _, args := range [][]string{ + {"layered-run", "private-path-check"}, + {"layered-run", "private-path-check", "--path", directory, "--kind", "other"}, + {"layered-run", "private-path-check", "--path", directory, "--kind", "shape-directory"}, + {"layered-run", "private-path-check", "--kind", "directory", "--path", directory}, + {"layered-run", "private-path-check", "--path", directory, "--kind", "directory", "extra"}, + {"layered-run", "private-path-check", "--attempt-dir", directory, "--launcher", "cmd"}, + } { + if err := app.Run(t.Context(), args); err == nil { + t.Fatalf("private path check accepted invalid arguments %q", args) + } + } +} + +func TestWindowsEntryVerifiesManifestBeforeCoreAndDownloadsOnlyCore(t *testing.T) { + fixture := newWindowsEntryFixture(t) + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + fixture.helperURL: {status: 200, content: []byte("optional helper must not be requested")}, + }} + downloadCalls := 0 + var stdout bytes.Buffer + app := App{ + Stdout: &stdout, + Stderr: io.Discard, + Transport: transport, + Now: fixture.now, + CommandContext: successfulTestCommand, + download: func(ctx context.Context, opts assetdownload.Options) (assetdownload.Result, error) { + downloadCalls++ + if opts.Transport != transport { + t.Fatal("focused app did not inject its command transport into assetdownload.Download") + } + if len(opts.Mirrors) != 1 || opts.Mirrors[0].URL != fixture.coreURL { + t.Fatalf("download selected a non-core asset: %#v", opts.Mirrors) + } + return assetdownload.Download(ctx, opts) + }, + } + if err := app.Run(t.Context(), fixture.args(t, windowsEntryTestCacheDir(t))); err != nil { + t.Fatal(err) + } + if downloadCalls != 1 { + t.Fatalf("assetdownload.Download calls = %d, want exactly 1", downloadCalls) + } + wantRequests := []string{fixture.manifestURL, fixture.coreURL} + if fmt.Sprint(transport.requestURLs()) != fmt.Sprint(wantRequests) { + t.Fatalf("transport requests = %q, want manifest then core only %q", transport.requestURLs(), wantRequests) + } + if strings.Contains(stdout.String(), fixture.helperURL) { + t.Fatalf("focused report exposed or requested optional helper URL: %s", stdout.String()) + } + var report RunReport + if err := json.NewDecoder(&stdout).Decode(&report); err != nil { + t.Fatalf("decode focused report: %v\n%s", err, stdout.String()) + } + if report.AssetID != "windows-core" || report.Bytes != int64(len(fixture.core)) { + t.Fatalf("unexpected focused report: %#v", report) + } +} + +func TestWindowsEntryRejectsBadSignatureBeforeAnyCoreRequest(t *testing.T) { + fixture := newWindowsEntryFixture(t) + var manifest release.LayeredAssetManifest + if err := json.Unmarshal(fixture.manifestJSON, &manifest); err != nil { + t.Fatal(err) + } + manifest.Signature = strings.Repeat("A", len(manifest.Signature)) + badManifest, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: badManifest}, + fixture.coreURL: {status: 200, content: fixture.core}, + }} + downloadCalls := 0 + app := App{ + Transport: transport, + Now: fixture.now, + download: func(context.Context, assetdownload.Options) (assetdownload.Result, error) { + downloadCalls++ + return assetdownload.Result{}, nil + }, + } + if err := app.Run(t.Context(), fixture.args(t, windowsEntryTestCacheDir(t))); err == nil { + t.Fatal("invalid signed manifest was accepted") + } + if downloadCalls != 0 { + t.Fatalf("assetdownload.Download ran %d times before manifest verification", downloadCalls) + } + if got := transport.requestURLs(); len(got) != 1 || got[0] != fixture.manifestURL { + t.Fatalf("bad signature caused post-manifest request(s): %q", got) + } +} + +func TestWindowsEntryRejectsCoreChangedAfterDownload(t *testing.T) { + fixture := newWindowsEntryFixture(t) + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }} + commandCalled := false + app := App{ + Transport: transport, + Now: fixture.now, + download: func(ctx context.Context, opts assetdownload.Options) (assetdownload.Result, error) { + result, err := assetdownload.Download(ctx, opts) + if err != nil { + return result, err + } + if err := os.WriteFile(result.OutputPath, bytes.Repeat([]byte("x"), len(fixture.core)), 0o600); err != nil { + return assetdownload.Result{}, err + } + return result, nil + }, + CommandContext: func(ctx context.Context, path string, args ...string) *exec.Cmd { + commandCalled = true + return successfulTestCommand(ctx, path, args...) + }, + } + if err := app.Run(t.Context(), fixture.args(t, windowsEntryTestCacheDir(t))); err == nil { + t.Fatal("same-size core replacement was accepted") + } + if commandCalled { + t.Fatal("core command was created after final digest verification failed") + } +} + +func TestWindowsEntryRejectsCoreReplacedAfterCommandCreation(t *testing.T) { + fixture := newWindowsEntryFixture(t) + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }} + commandCreated := false + replacementBlocked := false + var stdout bytes.Buffer + app := App{ + Stdout: &stdout, + Transport: transport, + Now: fixture.now, + CommandContext: func(ctx context.Context, runtimePath string, args ...string) *exec.Cmd { + commandCreated = true + replacement := runtimePath + ".replacement" + if err := os.WriteFile(replacement, bytes.Repeat([]byte("x"), len(fixture.core)), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, runtimePath); err != nil { + if runtime.GOOS == "windows" { + replacementBlocked = true + _ = os.Remove(replacement) + return successfulTestCommand(ctx, runtimePath, args...) + } + t.Fatal(err) + } + return successfulTestCommand(ctx, runtimePath, args...) + }, + } + err := app.Run(t.Context(), fixture.args(t, windowsEntryTestCacheDir(t))) + if replacementBlocked { + if err != nil { + t.Fatalf("locked core failed after replacement was blocked: %v", err) + } + return + } + if err == nil { + t.Fatal("core replacement after command creation was accepted") + } + if stdout.Len() != 0 { + t.Fatalf("failed final runtime verification emitted a success report: %s", stdout.String()) + } + if !commandCreated { + t.Fatal("test did not reach the final pre-start verification boundary") + } +} + +func TestWindowsEntryRejectsUnsafeCacheBeforeCoreDownload(t *testing.T) { + fixture := newWindowsEntryFixture(t) + root := canonicalWindowsEntryTestTempDir(t) + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "cache-link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("create cache symlink: %v", err) + } + + for _, cacheDir := range []string{ + `\\server\share\rdev-cache`, + `//server/share/rdev-cache`, + filepath.Join(link, "nested"), + } { + t.Run(cacheDir, func(t *testing.T) { + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + }} + downloadCalls := 0 + app := App{ + Transport: transport, + Now: fixture.now, + download: func(context.Context, assetdownload.Options) (assetdownload.Result, error) { + downloadCalls++ + return assetdownload.Result{}, fmt.Errorf("unexpected download") + }, + } + if err := app.Run(t.Context(), fixture.args(t, cacheDir)); err == nil { + t.Fatalf("unsafe cache path %q was accepted", cacheDir) + } + if downloadCalls != 0 { + t.Fatalf("unsafe cache path %q reached core download", cacheDir) + } + }) + } +} + +func TestWindowsEntryRejectsUnsafeManifestURLsBeforeTransport(t *testing.T) { + fixture := newWindowsEntryFixture(t) + unsafeURLs := []string{ + "http://downloads.example.test/layered.json", + "https://user:secret@downloads.example.test/layered.json", + "https://downloads.example.test/layered.json?channel=test", + "https://downloads.example.test/layered.json#latest", + "//downloads.example.test/layered.json", + "https://:443/layered.json", + "https://downloads.example.test:bad/layered.json", + "https://downloads.example.test:65536/layered.json", + "https://[::1/layered.json", + } + for _, rawURL := range unsafeURLs { + t.Run(rawURL, func(t *testing.T) { + transport := &recordingTransport{} + args := fixture.args(t, windowsEntryTestCacheDir(t)) + args[2] = rawURL + app := App{Transport: transport, Now: fixture.now} + if err := app.Run(t.Context(), args); err == nil { + t.Fatalf("unsafe manifest URL %q was accepted", rawURL) + } + if len(transport.requests) != 0 { + t.Fatalf("unsafe manifest URL reached transport: %#v", transport.requests) + } + }) + } +} + +func TestWindowsEntryRejectsInvalidRequiredFlagsBeforeTransport(t *testing.T) { + fixture := newWindowsEntryFixture(t) + for _, testCase := range []struct { + name string + flag string + value string + }{ + {name: "mode", flag: "--mode", value: "persistent"}, + {name: "platform", flag: "--platform", value: "linux/amd64"}, + {name: "version", flag: "--expected-release-version", value: ""}, + {name: "cache", flag: "--cache-dir", value: ""}, + {name: "attempt", flag: "--attempt-dir", value: ""}, + {name: "launcher", flag: "--launcher", value: "pwsh"}, + {name: "root", flag: "--root-public-key", value: "invalid"}, + } { + t.Run(testCase.name, func(t *testing.T) { + transport := &recordingTransport{} + args := replaceWindowsEntryFlag(t, fixture.args(t, windowsEntryTestCacheDir(t)), testCase.flag, testCase.value) + if err := (App{Transport: transport, Now: fixture.now}).Run(t.Context(), args); err == nil { + t.Fatalf("invalid %s was accepted", testCase.flag) + } + if len(transport.requests) != 0 { + t.Fatalf("invalid %s reached transport", testCase.flag) + } + }) + } +} + +func TestWindowsEntryRejectsAmbiguousLayeredArguments(t *testing.T) { + fixture := newWindowsEntryFixture(t) + duplicateMode := fixture.args(t, windowsEntryTestCacheDir(t)) + separator := slices.Index(duplicateMode, "--") + duplicateMode = append(append(append([]string(nil), duplicateMode[:separator]...), "--mode", "temporary"), duplicateMode[separator:]...) + for _, args := range [][]string{ + duplicateMode, + {"layered-run", "positional-before-flags"}, + } { + transport := &recordingTransport{} + if err := (App{Transport: transport, Now: fixture.now}).Run(t.Context(), args); err == nil { + t.Fatalf("ambiguous focused arguments were accepted: %q", args) + } + if len(transport.requests) != 0 { + t.Fatalf("ambiguous focused arguments reached transport: %q", args) + } + } +} + +func TestWindowsEntryManifestFetchIsBounded(t *testing.T) { + validURL := "https://downloads.example.test/layered.json" + for _, testCase := range []struct { + name string + transport assetdownload.Transport + }{ + { + name: "transport error", + transport: transportFunc(func(context.Context, assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + return assetdownload.TransportResponse{}, fmt.Errorf("fetch failed") + }), + }, + { + name: "missing body", + transport: transportFunc(func(context.Context, assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + return assetdownload.TransportResponse{StatusCode: 200}, nil + }), + }, + { + name: "bad status", + transport: transportFunc(func(context.Context, assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + return assetdownload.TransportResponse{StatusCode: 503, Body: io.NopCloser(strings.NewReader("retry"))}, nil + }), + }, + { + name: "declared oversize", + transport: transportFunc(func(context.Context, assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + return assetdownload.TransportResponse{StatusCode: 200, ContentLength: maxManifestBytes + 1, Body: io.NopCloser(strings.NewReader("{}"))}, nil + }), + }, + { + name: "streamed oversize", + transport: transportFunc(func(context.Context, assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + return assetdownload.TransportResponse{StatusCode: 200, ContentLength: -1, Body: io.NopCloser(io.LimitReader(zeroReader{}, maxManifestBytes+1))}, nil + }), + }, + } { + t.Run(testCase.name, func(t *testing.T) { + if _, err := fetchManifest(t.Context(), testCase.transport, validURL); err == nil { + t.Fatal("invalid manifest response was accepted") + } + }) + } +} + +func TestWindowsEntryManifestFetchPropagatesBodyCloseFailure(t *testing.T) { + closeErr := fmt.Errorf("manifest temporary cleanup failed") + transport := transportFunc(func(context.Context, assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + return assetdownload.TransportResponse{ + StatusCode: 200, + ContentLength: 2, + Body: &errorCloseBody{Reader: strings.NewReader("{}"), Err: closeErr}, + }, nil + }) + if _, err := fetchManifest(t.Context(), transport, "https://downloads.example.test/layered.json"); err == nil || !errors.Is(err, closeErr) { + t.Fatalf("manifest response cleanup failure was not propagated: %v", err) + } +} + +func TestWindowsEntryAssetURLAndRuntimeValidation(t *testing.T) { + manifestURL, err := strictHTTPSURL("https://downloads.example.test/releases/layered.json") + if err != nil { + t.Fatal(err) + } + resolved, err := resolveAssetURL(manifestURL, "rdev-core.exe") + if err != nil || resolved.String() != "https://downloads.example.test/releases/rdev-core.exe" { + t.Fatalf("resolve same-origin core: %s, %v", resolved, err) + } + if _, err := resolveAssetURL(manifestURL, "https://other.example.test/rdev-core.exe"); err == nil { + t.Fatal("cross-origin core URL was accepted") + } + for _, rawURL := range []string{ + "https://downloads.example.test:443/releases/layered.json", + "https://[::1]:443/releases/layered.json", + } { + if _, err := strictHTTPSURL(rawURL); err != nil { + t.Fatalf("safe HTTPS authority %q rejected: %v", rawURL, err) + } + } + + runtimePath := filepath.Join(t.TempDir(), "rdev-core.exe") + if err := os.WriteFile(runtimePath, []byte("core"), 0o600); err != nil { + t.Fatal(err) + } + if err := validateRuntime(runtimePath, 4); err != nil { + t.Fatal(err) + } + if err := validateRuntime(runtimePath, 5); err == nil { + t.Fatal("runtime with the wrong signed size was accepted") + } + linkPath := runtimePath + ".link" + if err := os.Symlink(runtimePath, linkPath); err != nil { + t.Fatal(err) + } + if err := validateRuntime(linkPath, 4); err == nil { + t.Fatal("runtime symlink was accepted") + } +} + +func TestWindowsEntryRejectsUnsafeWindowsCacheBasenames(t *testing.T) { + for _, name := range []string{"", "CON", "con.exe", "PRN.txt", "COM1.exe", "LPT9", "core. ", "core.", "core<1>.exe", "core name.exe", "c\u00f6re.exe"} { + if validWindowsCacheBasename(name) { + t.Errorf("unsafe Windows cache basename %q was accepted", name) + } + } + for _, name := range []string{"rdev-host.exe", "rdev-core-windows-amd64.exe", "core_v2.1.exe"} { + if !validWindowsCacheBasename(name) { + t.Errorf("safe Windows cache basename %q was rejected", name) + } + } +} + +func TestWindowsEntryHostDefaults(t *testing.T) { + if runtime.GOOS != "windows" { + if _, err := defaultTransport(); err == nil { + t.Fatal("non-Windows package unexpectedly created the Windows transport") + } + } + app := App{} + if app.stdout() != os.Stdout || app.stderr() != os.Stderr || app.stdin() != os.Stdin { + t.Fatal("focused app did not preserve process standard streams") + } + if app.commandContext(t.Context(), os.Args[0]) == nil { + t.Fatal("focused app did not create the default foreground command") + } +} + +func TestWindowsEntryHelperProcess(t *testing.T) { + if os.Getenv("RDEV_WINDOWS_ENTRY_HELPER") != "1" { + return + } + os.Exit(0) +} + +type windowsEntryFixture struct { + now time.Time + root string + manifestURL string + coreURL string + helperURL string + core []byte + manifestJSON []byte +} + +func newWindowsEntryFixture(t *testing.T) windowsEntryFixture { + t.Helper() + now := time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC) + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + core := []byte("representative verified Windows core runtime\n") + helper := []byte("optional helper\n") + coreDigest := sha256.Sum256(core) + helperDigest := sha256.Sum256(helper) + manifest, err := release.SignLayeredAssetManifest(release.LayeredAssetManifest{ + SchemaVersion: release.LayeredAssetManifestSchemaVersion, + Version: "v2.0.0-test", + GeneratedAt: now.Add(-time.Hour), + ExpiresAt: now.Add(time.Hour), + Assets: []release.LayeredAsset{ + { + ID: "windows-helper", + Platform: "windows/amd64", + Kind: "optional-helper", + RelativePath: "optional-helper.exe", + SHA256: fmt.Sprintf("sha256:%x", helperDigest), + SizeBytes: int64(len(helper)), + }, + { + ID: "windows-core", + Platform: "windows/amd64", + Kind: "core-runtime", + RelativePath: "rdev-core.exe", + SHA256: fmt.Sprintf("sha256:%x", coreDigest), + SizeBytes: int64(len(core)), + }, + }, + }, key) + if err != nil { + t.Fatal(err) + } + manifestJSON, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + return windowsEntryFixture{ + now: now, + root: key.ID + ":" + base64.RawURLEncoding.EncodeToString(key.PublicKey), + manifestURL: "https://downloads.example.test/releases/layered.json", + coreURL: "https://downloads.example.test/releases/rdev-core.exe", + helperURL: "https://downloads.example.test/releases/optional-helper.exe", + core: core, + manifestJSON: manifestJSON, + } +} + +func (fixture windowsEntryFixture) args(t *testing.T, cacheDir string) []string { + t.Helper() + return windowsEntryAttemptArgs(fixture, cacheDir, privateAttemptDirForTest(t), launcherPowerShell) +} + +func (fixture windowsEntryFixture) baseArgs(cacheDir string) []string { + return []string{ + "layered-run", + "--manifest-url", fixture.manifestURL, + "--root-public-key", fixture.root, + "--expected-release-version", "v2.0.0-test", + "--platform", "windows/amd64", + "--cache-dir", cacheDir, + "--mode", "temporary", + "--", "serve", "--mode", "temporary", "--transport", "auto", + } +} + +type transportFixture struct { + status int + content []byte +} + +type transportFunc func(context.Context, assetdownload.TransportRequest) (assetdownload.TransportResponse, error) + +func (fn transportFunc) Fetch(ctx context.Context, request assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + return fn(ctx, request) +} + +type zeroReader struct{} + +func (zeroReader) Read(content []byte) (int, error) { + for index := range content { + content[index] = 0 + } + return len(content), nil +} + +type errorCloseBody struct { + io.Reader + Err error +} + +func (body *errorCloseBody) Close() error { + return body.Err +} + +type recordingTransport struct { + responses map[string]transportFixture + requests []assetdownload.TransportRequest +} + +func (transport *recordingTransport) Fetch(_ context.Context, request assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + transport.requests = append(append([]assetdownload.TransportRequest(nil), transport.requests...), request) + fixture, ok := transport.responses[request.URL] + if !ok { + return assetdownload.TransportResponse{}, fmt.Errorf("unexpected request %q", request.URL) + } + content := fixture.content + if request.Offset > 0 { + if request.Offset > int64(len(content)) { + return assetdownload.TransportResponse{}, fmt.Errorf("offset exceeds fixture") + } + content = content[request.Offset:] + } + status := fixture.status + if request.Offset > 0 && status == 200 { + status = 206 + } + return assetdownload.TransportResponse{ + StatusCode: status, + ContentLength: int64(len(content)), + Body: io.NopCloser(bytes.NewReader(content)), + }, nil +} + +func (transport *recordingTransport) requestURLs() []string { + urls := make([]string, len(transport.requests)) + for index, request := range transport.requests { + urls[index] = request.URL + } + return urls +} + +func successfulTestCommand(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestWindowsEntryHelperProcess$") + cmd.Env = append(os.Environ(), "RDEV_WINDOWS_ENTRY_HELPER=1") + return cmd +} + +func replaceWindowsEntryFlag(t *testing.T, args []string, name, value string) []string { + t.Helper() + cloned := append([]string(nil), args...) + for index := 0; index+1 < len(cloned); index++ { + if cloned[index] == name { + cloned[index+1] = value + return cloned + } + } + t.Fatalf("flag %s not found", name) + return nil +} + +func canonicalWindowsEntryTestTempDir(t *testing.T) string { + t.Helper() + directory, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return directory +} + +func assertPrivateFileForWindowsEntryTest(t *testing.T, path string) { + t.Helper() + info, err := os.Lstat(filepath.Clean(path)) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("expected private regular file at %s", path) + } +} diff --git a/internal/bootstrapcmd/windowsentry/attempt.go b/internal/bootstrapcmd/windowsentry/attempt.go new file mode 100644 index 0000000..13168e9 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/attempt.go @@ -0,0 +1,363 @@ +package windowsentry + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/release" +) + +const ( + AttemptStateSchemaVersion = "rdev.windows-layered-attempt.v1" + attemptStateFilename = "state.json" + attemptLockFilename = "attempt.lock" + attemptStateTempFilename = "state.json.tmp" + maxAttemptStateBytes = 1024 +) + +type attemptStage string + +const ( + attemptStagePreCore attemptStage = "pre_core" + attemptStageCoreStarted attemptStage = "core_started" + attemptStageCoreExited attemptStage = "core_exited" +) + +type attemptLauncher string + +const ( + launcherPowerShell attemptLauncher = "powershell" + launcherPowerShellBypass attemptLauncher = "powershell-bypass" + launcherCMD attemptLauncher = "cmd" +) + +type attemptState struct { + SchemaVersion string + AttemptID string + Stage attemptStage + Launcher attemptLauncher + UpdatedAt string +} + +func newPreCoreError(class string) error { + return errors.New("layered pre-core failure:" + class) +} + +var ( + errAttemptBusy = errors.New("attempt lock is active") + errAttemptClosed = errors.New("attempt already started") + errInvalidAttemptState = errors.New("invalid attempt state") +) + +type attemptGuard struct { + directory string + directoryInfo os.FileInfo + statePath string + lockPath string + lock *os.File + launcher attemptLauncher + state attemptState +} + +func acquireAttempt(directory string, launcher attemptLauncher, now time.Time) (*attemptGuard, error) { + if !launcher.valid() { + return nil, fmt.Errorf("invalid attempt launcher") + } + clean, err := validateAttemptPathInput(directory) + if err != nil { + return nil, err + } + directoryInfo, err := validatePrivateAttemptDirectory(clean) + if err != nil { + return nil, err + } + lockPath := filepath.Join(clean, attemptLockFilename) + lock, err := createPrivateAttemptFile(clean, attemptLockFilename) + if err != nil { + if os.IsExist(err) { + if _, validationErr := validatePrivateAttemptFile(lockPath, 0); validationErr != nil { + return nil, validationErr + } + return nil, errAttemptBusy + } + return nil, err + } + guard := &attemptGuard{ + directory: clean, + directoryInfo: directoryInfo, + statePath: filepath.Join(clean, attemptStateFilename), + lockPath: lockPath, + lock: lock, + launcher: launcher, + } + valid := false + defer func() { + if !valid { + _ = guard.close() + } + }() + if err := lock.Sync(); err != nil { + return nil, err + } + if err := guard.revalidate(); err != nil { + return nil, err + } + state, err := readAttemptState(guard.statePath) + if os.IsNotExist(err) { + state = attemptState{ + SchemaVersion: AttemptStateSchemaVersion, + AttemptID: filepath.Base(clean), + Stage: attemptStagePreCore, + Launcher: launcher, + UpdatedAt: attemptTimestamp(now), + } + if err := writeAttemptState(clean, state); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + if state.AttemptID != filepath.Base(clean) { + return nil, fmt.Errorf("attempt identity does not match its directory") + } + if state.Stage != attemptStagePreCore { + return nil, errAttemptClosed + } + guard.state = state + valid = true + return guard, nil +} + +func (guard *attemptGuard) transition(next attemptStage, now time.Time) error { + if guard == nil || guard.lock == nil { + return fmt.Errorf("attempt lock is required") + } + if !validAttemptTransition(guard.state.Stage, next) { + return fmt.Errorf("invalid attempt stage transition") + } + if err := guard.revalidate(); err != nil { + return err + } + current, err := readAttemptState(guard.statePath) + if err != nil { + return err + } + if current.AttemptID != guard.state.AttemptID || current.Stage != guard.state.Stage { + return fmt.Errorf("attempt state changed while locked") + } + nextState := attemptState{ + SchemaVersion: AttemptStateSchemaVersion, + AttemptID: current.AttemptID, + Stage: next, + Launcher: guard.launcher, + UpdatedAt: attemptTimestamp(now), + } + if err := writeAttemptState(guard.directory, nextState); err != nil { + return err + } + guard.state = nextState + return nil +} + +func (guard *attemptGuard) revalidate() error { + if guard == nil || guard.lock == nil { + return fmt.Errorf("attempt lock is required") + } + directoryInfo, err := validatePrivateAttemptDirectory(guard.directory) + if err != nil || !os.SameFile(guard.directoryInfo, directoryInfo) { + return fmt.Errorf("attempt directory identity changed") + } + lockInfo, err := guard.lock.Stat() + if err != nil { + return err + } + pathInfo, err := validatePrivateAttemptFile(guard.lockPath, 0) + if err != nil || !os.SameFile(lockInfo, pathInfo) { + return fmt.Errorf("attempt lock identity changed") + } + return nil +} + +func (guard *attemptGuard) close() error { + if guard == nil || guard.lock == nil { + return nil + } + lock := guard.lock + guard.lock = nil + info, statErr := lock.Stat() + pathInfo, pathErr := os.Lstat(guard.lockPath) + closeErr := lock.Close() + if statErr == nil && pathErr == nil && os.SameFile(info, pathInfo) { + return errors.Join(closeErr, os.Remove(guard.lockPath)) + } + return errors.Join(statErr, pathErr, closeErr) +} + +func (launcher attemptLauncher) valid() bool { + return launcher == launcherPowerShell || launcher == launcherPowerShellBypass || launcher == launcherCMD +} + +func (stage attemptStage) valid() bool { + return stage == attemptStagePreCore || stage == attemptStageCoreStarted || stage == attemptStageCoreExited +} + +func validAttemptTransition(from, to attemptStage) bool { + return from == attemptStagePreCore && to == attemptStageCoreStarted || from == attemptStageCoreStarted && to == attemptStageCoreExited +} + +func attemptTimestamp(now time.Time) string { + if now.IsZero() { + now = time.Now() + } + return now.UTC().Format(time.RFC3339Nano) +} + +func validateAttemptPathInput(raw string) (string, error) { + if raw == "" || strings.TrimSpace(raw) != raw || strings.HasPrefix(raw, `\\`) || strings.HasPrefix(raw, "//") { + return "", fmt.Errorf("attempt must use a local path") + } + clean := filepath.Clean(raw) + if clean != raw || !filepath.IsAbs(clean) { + return "", fmt.Errorf("attempt must use an absolute canonical path") + } + if !validWindowsCacheBasename(filepath.Base(clean)) { + return "", fmt.Errorf("attempt has an unsafe identifier") + } + return clean, nil +} + +func readAttemptState(path string) (attemptState, error) { + file, err := openPrivateAttemptFile(path, maxAttemptStateBytes) + if err != nil { + return attemptState{}, err + } + content, readErr := io.ReadAll(io.LimitReader(file, maxAttemptStateBytes+1)) + closeErr := file.Close() + if readErr != nil || closeErr != nil { + return attemptState{}, errors.Join(readErr, closeErr) + } + if len(content) > maxAttemptStateBytes { + return attemptState{}, fmt.Errorf("attempt state exceeds its byte bound") + } + return decodeAttemptState(content) +} + +func writeAttemptState(directory string, state attemptState) (resultErr error) { + if err := state.validate(); err != nil { + return err + } + content := encodeAttemptState(state) + temporaryPath := filepath.Join(directory, attemptStateTempFilename) + file, err := createPrivateAttemptFile(directory, attemptStateTempFilename) + if err != nil { + return err + } + closed := false + defer func() { + if !closed { + resultErr = errors.Join(resultErr, file.Close()) + } + _ = os.Remove(temporaryPath) + }() + written, err := file.Write(content) + if err != nil { + return err + } + if written != len(content) { + return io.ErrShortWrite + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + closed = true + if _, err := validatePrivateAttemptFile(temporaryPath, int64(len(content))); err != nil { + return err + } + statePath := filepath.Join(directory, attemptStateFilename) + if err := replacePrivateAttemptFile(temporaryPath, statePath); err != nil { + return err + } + _, err = validatePrivateAttemptFile(statePath, int64(len(content))) + return err +} + +func encodeAttemptState(state attemptState) []byte { + content := make([]byte, 0, 192) + content = append(content, `{"schema_version":`...) + content = appendReportString(content, state.SchemaVersion) + content = append(content, `,"attempt_id":`...) + content = appendReportString(content, state.AttemptID) + content = append(content, `,"stage":`...) + content = appendReportString(content, string(state.Stage)) + content = append(content, `,"launcher":`...) + content = appendReportString(content, string(state.Launcher)) + content = append(content, `,"updated_at":`...) + content = appendReportString(content, state.UpdatedAt) + return append(content, '}', '\n') +} + +func decodeAttemptState(content []byte) (attemptState, error) { + start, end := 0, len(content) + for start < end && isJSONSpace(content[start]) { + start++ + } + for start < end && isJSONSpace(content[end-1]) { + end-- + } + text := string(content[start:end]) + const prefix = `{"schema_version":"` + if !strings.HasPrefix(text, prefix) { + return attemptState{}, errInvalidAttemptState + } + schema, rest, ok := strings.Cut(text[len(prefix):], `","attempt_id":"`) + if !ok { + return attemptState{}, errInvalidAttemptState + } + attemptID, rest, ok := strings.Cut(rest, `","stage":"`) + if !ok { + return attemptState{}, errInvalidAttemptState + } + stage, rest, ok := strings.Cut(rest, `","launcher":"`) + if !ok { + return attemptState{}, errInvalidAttemptState + } + launcher, rest, ok := strings.Cut(rest, `","updated_at":"`) + if !ok { + return attemptState{}, errInvalidAttemptState + } + updatedAt, trailing, ok := strings.Cut(rest, `"}`) + if !ok || trailing != "" { + return attemptState{}, errInvalidAttemptState + } + state := attemptState{ + SchemaVersion: schema, + AttemptID: attemptID, + Stage: attemptStage(stage), + Launcher: attemptLauncher(launcher), + UpdatedAt: updatedAt, + } + return state, state.validate() +} + +func isJSONSpace(character byte) bool { + return character == ' ' || character == '\t' || character == '\r' || character == '\n' +} + +func (state attemptState) validate() error { + if state.SchemaVersion != AttemptStateSchemaVersion || !state.Stage.valid() || !state.Launcher.valid() || + !validWindowsCacheBasename(state.AttemptID) { + return errInvalidAttemptState + } + if !release.IsCanonicalUTCTimestamp(state.UpdatedAt) { + return errInvalidAttemptState + } + return nil +} diff --git a/internal/bootstrapcmd/windowsentry/attempt_security_other.go b/internal/bootstrapcmd/windowsentry/attempt_security_other.go new file mode 100644 index 0000000..f827300 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/attempt_security_other.go @@ -0,0 +1,118 @@ +//go:build !windows + +package windowsentry + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func validatePrivateAttemptDirectory(path string) (os.FileInfo, error) { + for current := filepath.Clean(path); ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, fmt.Errorf("attempt path has an unsafe ancestor") + } + if current == path && info.Mode().Perm() != 0o700 { + return nil, fmt.Errorf("attempt directory permissions are not private") + } + parent := filepath.Dir(current) + if parent == current { + return os.Lstat(path) + } + } +} + +func preparePrivateAttemptDirectory(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + if err := os.Mkdir(path, 0o700); err != nil { + return err + } + return os.Chmod(path, 0o700) +} + +func validatePrivateLauncherPath(path string, directory bool) error { + if path == "" || strings.TrimSpace(path) != path || filepath.Clean(path) != path || !filepath.IsAbs(path) { + return errInvalidAttemptState + } + info, err := os.Lstat(path) + if err != nil { + return err + } + mode := os.FileMode(0o600) + if directory { + mode = 0o700 + } + if info.Mode()&os.ModeSymlink != 0 || info.IsDir() != directory || info.Mode().Perm() != mode { + return errInvalidAttemptState + } + return nil +} + +func createPrivateAttemptFile(directory, name string) (*os.File, error) { + if filepath.Base(name) != name || name == "" { + return nil, fmt.Errorf("invalid attempt filename") + } + file, err := os.OpenFile(filepath.Join(directory, name), os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return nil, err + } + if err := file.Chmod(0o600); err != nil { + file.Close() + return nil, err + } + return file, nil +} + +func openPrivateAttemptFile(path string, maxBytes int64) (*os.File, error) { + pathInfo, err := validatePrivateAttemptFile(path, -1) + if err != nil { + return nil, err + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil || !os.SameFile(info, pathInfo) || maxBytes >= 0 && info.Size() > maxBytes { + file.Close() + return nil, fmt.Errorf("attempt file changed or exceeded its byte bound") + } + return file, nil +} + +func validatePrivateAttemptFile(path string, expectedSize int64) (os.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 || expectedSize >= 0 && info.Size() != expectedSize { + return nil, fmt.Errorf("attempt file is not a private regular file") + } + return info, nil +} + +func replacePrivateAttemptFile(source, destination string) error { + if err := os.Rename(source, destination); err != nil { + return err + } + directory, err := os.Open(filepath.Dir(destination)) + if err != nil { + return err + } + return errorsJoinClose(directory.Sync(), directory.Close()) +} + +func errorsJoinClose(first, second error) error { + if first != nil { + return first + } + return second +} diff --git a/internal/bootstrapcmd/windowsentry/attempt_security_windows.go b/internal/bootstrapcmd/windowsentry/attempt_security_windows.go new file mode 100644 index 0000000..814069a --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/attempt_security_windows.go @@ -0,0 +1,142 @@ +//go:build windows + +package windowsentry + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" +) + +func validatePrivateAttemptDirectory(path string) (os.FileInfo, error) { + if _, err := validateWindowsAttemptPath(path); err != nil { + return nil, err + } + if err := rejectWindowsReparseAncestors(path); err != nil { + return nil, err + } + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return nil, err + } + if err := validateWindowsPrivatePath(path, true, true, trustees); err != nil { + return nil, err + } + return os.Lstat(path) +} + +func preparePrivateAttemptDirectory(path string) error { + root, err := validateWindowsAttemptPath(path) + if err != nil { + return err + } + descriptor, trustees, err := windowsPrivateSecurityDescriptor() + if err != nil { + return err + } + defer descriptor.close() + if err := ensureWindowsPrivateDirectory(root, descriptor, trustees); err != nil { + return err + } + if err := createWindowsPrivateDirectory(path, descriptor); err != nil { + return err + } + return validateWindowsPrivatePath(path, true, true, trustees) +} + +func validatePrivateLauncherPath(path string, directory bool) error { + if err := validateWindowsLocalPath(path); err != nil { + return err + } + if err := rejectWindowsReparseAncestors(path); err != nil { + return err + } + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return err + } + flags := uint32(syscall.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= syscall.FILE_FLAG_BACKUP_SEMANTICS + } + handle, err := syscall.CreateFile( + pointer, + winReadControl|winFileReadAttributes, + syscall.FILE_SHARE_READ, + nil, + syscall.OPEN_EXISTING, + flags, + 0, + ) + if err != nil { + return err + } + defer syscall.CloseHandle(handle) + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return err + } + expectedFlags := 0 + if directory { + expectedFlags = 3 + } + return validateWindowsPrivateHandle(handle, directory, true, expectedFlags, trustees) +} + +func validateWindowsAttemptPath(path string) (string, error) { + if err := validateWindowsLocalPath(path); err != nil { + return "", err + } + localAppData, err := winKnownLocalAppData() + if err != nil { + return "", err + } + root := filepath.Join(filepath.Clean(localAppData), "RemoteDevSkillkit", "attempts") + if !strings.EqualFold(filepath.Dir(path), root) || !validWindowsCacheBasename(filepath.Base(path)) { + return "", fmt.Errorf("attempt must be a direct child of the Windows LocalAppData attempts root") + } + if err := rejectWindowsReparseAncestors(path); err != nil { + return "", err + } + return root, nil +} + +func createPrivateAttemptFile(directory, name string) (*os.File, error) { + return createPrivateTemporaryFile(directory, name) +} + +func openPrivateAttemptFile(path string, maxBytes int64) (*os.File, error) { + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return nil, err + } + file, err := openWindowsPrivateFile(path, false, trustees) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil || maxBytes >= 0 && info.Size() > maxBytes { + file.Close() + return nil, fmt.Errorf("attempt file exceeded its byte bound") + } + return file, nil +} + +func validatePrivateAttemptFile(path string, expectedSize int64) (os.FileInfo, error) { + file, err := openPrivateAttemptFile(path, -1) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil || expectedSize >= 0 && info.Size() != expectedSize { + return nil, fmt.Errorf("attempt file is not a private regular file") + } + return info, nil +} + +func replacePrivateAttemptFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/internal/bootstrapcmd/windowsentry/attempt_test.go b/internal/bootstrapcmd/windowsentry/attempt_test.go new file mode 100644 index 0000000..1d67005 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/attempt_test.go @@ -0,0 +1,703 @@ +//go:build !rdev_bootstrap_focused + +package windowsentry + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" +) + +func TestAttemptStrictJSON(t *testing.T) { + valid := `{"schema_version":"rdev.windows-layered-attempt.v1","attempt_id":"opaque-id","stage":"pre_core","launcher":"powershell","updated_at":"2026-07-17T08:00:00Z"}` + state, err := decodeAttemptState([]byte(valid)) + if err != nil { + t.Fatal(err) + } + if state.AttemptID != "opaque-id" || state.Stage != attemptStagePreCore || state.Launcher != launcherPowerShell { + t.Fatalf("unexpected decoded attempt state: %#v", state) + } + for _, content := range []string{ + strings.Replace(valid, `"stage":"pre_core"`, `"stage":"pre_core","selected_route":"direct"`, 1), + strings.Replace(valid, `"stage":"pre_core"`, `"stage":"pre_core","stage":"core_started"`, 1), + valid + `{}`, + strings.Replace(valid, `,"launcher":"powershell"`, "", 1), + strings.Replace(valid, "rdev.windows-layered-attempt.v1", "rdev.windows-layered-attempt.v0", 1), + strings.Replace(valid, "pre_core", "invalid", 1), + strings.Replace(valid, "powershell", "pwsh", 1), + strings.Replace(valid, "2026-07-17T08:00:00Z", "not-a-time", 1), + "\v" + valid, + valid + "\f", + "\u00a0" + valid, + } { + if _, err := decodeAttemptState([]byte(content)); err == nil { + t.Fatalf("strict attempt decoder accepted %s", content) + } + } +} + +func TestAttemptTransitionsAreForwardOnly(t *testing.T) { + allowed := map[[2]attemptStage]bool{ + {attemptStagePreCore, attemptStageCoreStarted}: true, + {attemptStageCoreStarted, attemptStageCoreExited}: true, + } + for _, from := range []attemptStage{attemptStagePreCore, attemptStageCoreStarted, attemptStageCoreExited} { + for _, to := range []attemptStage{attemptStagePreCore, attemptStageCoreStarted, attemptStageCoreExited} { + if got := validAttemptTransition(from, to); got != allowed[[2]attemptStage{from, to}] { + t.Errorf("transition %s -> %s = %t", from, to, got) + } + } + } +} + +func TestAttemptStateIsPrivateAndAtomicallyReplaced(t *testing.T) { + directory := privateAttemptDirForTest(t) + guard, err := acquireAttempt(directory, launcherPowerShell, time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + defer guard.close() + + statePath := filepath.Join(directory, attemptStateFilename) + beforeFile, err := openPrivateAttemptFile(statePath, maxAttemptStateBytes) + if err != nil { + t.Fatal(err) + } + before, err := beforeFile.Stat() + if err != nil { + t.Fatal(err) + } + if err := beforeFile.Close(); err != nil { + t.Fatal(err) + } + assertPrivateAttemptPathForTest(t, directory, true) + assertPrivateAttemptPathForTest(t, statePath, false) + assertPrivateAttemptPathForTest(t, filepath.Join(directory, attemptLockFilename), false) + if err := guard.transition(attemptStageCoreStarted, time.Date(2026, 7, 17, 8, 0, 1, 0, time.UTC)); err != nil { + t.Fatal(err) + } + after, err := os.Lstat(statePath) + if err != nil { + t.Fatal(err) + } + if os.SameFile(before, after) { + t.Fatal("attempt state was updated in place instead of atomically replaced") + } + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.Contains(entry.Name(), ".tmp") { + t.Fatalf("attempt transition left temporary state file %q", entry.Name()) + } + } +} + +func TestAttemptRejectsUnsafePathsAndManagedFiles(t *testing.T) { + for _, directory := range []string{`\\server\share\attempt`, `//server/share/attempt`, "relative-attempt"} { + if _, err := acquireAttempt(directory, launcherPowerShell, time.Now()); err == nil { + t.Fatalf("unsafe attempt path %q was accepted", directory) + } + } + + root := canonicalWindowsEntryTestTempDir(t) + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("create attempt symlink fixture: %v", err) + } + if _, err := acquireAttempt(link, launcherPowerShell, time.Now()); err == nil { + t.Fatal("attempt directory symlink was accepted") + } + + directory := filepath.Join(root, "state-symlink-attempt") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + targetState := filepath.Join(root, "target-state") + if err := os.WriteFile(targetState, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(targetState, filepath.Join(directory, attemptStateFilename)); err != nil { + t.Skipf("create state symlink fixture: %v", err) + } + if _, err := acquireAttempt(directory, launcherPowerShell, time.Now()); err == nil { + t.Fatal("attempt state symlink was accepted") + } +} + +func TestLayeredRunStartsCoreOnceWithFreshAttempt(t *testing.T) { + fixture := newWindowsEntryFixture(t) + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }} + attemptDir := privateAttemptDirForTest(t) + args := windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShell) + + var stdout bytes.Buffer + var launchedArgs []string + runnerCount := 0 + app := App{ + Stdout: &stdout, + Stderr: io.Discard, + Transport: transport, + Now: fixture.now, + CommandContext: func(ctx context.Context, path string, args ...string) *exec.Cmd { + runnerCount++ + launchedArgs = append([]string(nil), args...) + return successfulTestCommand(ctx, path, args...) + }, + } + if err := app.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + if runnerCount != 1 { + t.Fatalf("core runner count = %d, want 1", runnerCount) + } + if !slices.Contains(launchedArgs, "--transport") || !slices.Contains(launchedArgs, "auto") { + t.Fatalf("core args do not preserve --transport auto: %q", launchedArgs) + } + + content, err := os.ReadFile(filepath.Join(attemptDir, "state.json")) + if err != nil { + t.Fatal(err) + } + var state map[string]any + if err := json.Unmarshal(content, &state); err != nil { + t.Fatal(err) + } + if state["schema_version"] != "rdev.windows-layered-attempt.v1" || state["stage"] != "core_exited" || state["launcher"] != "powershell" { + t.Fatalf("unexpected final attempt state: %s", content) + } + if _, found := state["selected_route"]; found { + t.Fatalf("attempt state persisted route selection: %s", content) + } +} + +func TestLayeredRunStartsCoreOnceAcrossLaunchers(t *testing.T) { + fixture := newWindowsEntryFixture(t) + attemptDir := privateAttemptDirForTest(t) + start := make(chan struct{}) + results := make(chan error, 2) + var runnerCount atomic.Int32 + for _, launcher := range []attemptLauncher{launcherPowerShell, launcherCMD} { + launcher := launcher + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }} + app := App{ + Stdout: io.Discard, + Stderr: io.Discard, + Transport: transport, + Now: fixture.now, + CommandContext: func(ctx context.Context, path string, args ...string) *exec.Cmd { + runnerCount.Add(1) + return successfulTestCommand(ctx, path, args...) + }, + } + go func() { + <-start + results <- app.Run(t.Context(), windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcher)) + }() + } + close(start) + err1, err2 := <-results, <-results + if err1 != nil && err2 != nil { + t.Fatalf("both launchers failed: %v; %v", err1, err2) + } + if runnerCount.Load() != 1 { + t.Fatalf("concurrent core runner count = %d, want 1", runnerCount.Load()) + } +} + +func TestLayeredRunStartsCoreOnceRejectsSecondStart(t *testing.T) { + fixture := newWindowsEntryFixture(t) + attemptDir := privateAttemptDirForTest(t) + var runnerCount atomic.Int32 + newApp := func() App { + return App{ + Stdout: io.Discard, + Stderr: io.Discard, + Transport: &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }}, + Now: fixture.now, + CommandContext: func(ctx context.Context, path string, args ...string) *exec.Cmd { + runnerCount.Add(1) + return successfulTestCommand(ctx, path, args...) + }, + } + } + if err := newApp().Run(t.Context(), windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShell)); err != nil { + t.Fatal(err) + } + err := newApp().Run(t.Context(), windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShellBypass)) + if err == nil { + t.Fatal("second core start was accepted") + } + if runnerCount.Load() != 1 { + t.Fatalf("core runner count after second launch = %d, want 1", runnerCount.Load()) + } +} + +func TestLayeredRunCancellationReapsProcessTreeBeforeCoreExited(t *testing.T) { + fixture := newWindowsEntryFixture(t) + attemptDir := privateAttemptDirForTest(t) + readyPath := filepath.Join(t.TempDir(), "child-ready") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var command *exec.Cmd + app := App{ + Stdout: io.Discard, + Stderr: io.Discard, + Transport: &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }}, + Now: fixture.now, + CommandContext: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + command = exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAttemptBlockingHelperProcess$") + command.Env = append(os.Environ(), + "RDEV_ATTEMPT_BLOCKING_HELPER=1", + "RDEV_ATTEMPT_READY="+readyPath, + "RDEV_ATTEMPT_SPAWN_GRANDCHILD=1", + ) + return command + }, + } + done := make(chan error, 1) + go func() { + done <- app.Run(ctx, windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShell)) + }() + waitForAttemptTest(t, func() bool { + content, err := os.ReadFile(filepath.Join(attemptDir, attemptStateFilename)) + if err != nil { + return false + } + state, err := decodeAttemptState(content) + return err == nil && state.Stage == attemptStageCoreStarted && attemptPIDFileReady(readyPath) + }) + if command == nil { + t.Fatal("child command was not created") + } + pids := attemptPIDsForTest(t, readyPath) + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("canceled layered run error = %v, want context canceled", err) + } + if command.ProcessState == nil { + t.Fatal("layered run returned before canceled child was reaped") + } + for _, pid := range pids { + if waitForAttemptProcessExit(pid, time.Second) { + continue + } + _ = killAttemptProcessForTest(pid) + t.Fatalf("attempt process %d remained alive after core_exited", pid) + } + content, err := os.ReadFile(filepath.Join(attemptDir, attemptStateFilename)) + if err != nil { + t.Fatal(err) + } + state, err := decodeAttemptState(content) + if err != nil { + t.Fatal(err) + } + if state.Stage != attemptStageCoreExited { + t.Fatalf("attempt stage after reaping = %s, want core_exited", state.Stage) + } +} + +func TestLayeredRunPreCoreErrorIsClassifiedAndRedacted(t *testing.T) { + fixture := newWindowsEntryFixture(t) + attemptDir := privateAttemptDirForTest(t) + cacheDir := windowsEntryTestCacheDir(t) + manifestURL := "https://downloads.example.test/releases/layered.json?token=TOP_SECRET" + args := windowsEntryAttemptArgs(fixture, cacheDir, attemptDir, launcherCMD) + args = replaceWindowsEntryFlag(t, args, "--manifest-url", manifestURL) + var stdout, stderr bytes.Buffer + err := (App{Stdout: &stdout, Stderr: &stderr, Transport: &recordingTransport{}}).Run(t.Context(), args) + if err == nil || !strings.HasPrefix(err.Error(), "layered pre-core failure:") { + t.Fatalf("pre-core error was not stably classified: %T %v", err, err) + } + combined := err.Error() + stdout.String() + stderr.String() + for _, private := range []string{manifestURL, fixture.root, attemptDir, cacheDir, "TOP_SECRET"} { + if strings.Contains(combined, private) { + t.Fatalf("pre-core output exposed private value %q: %s", private, combined) + } + } + for _, forbidden := range []string{"ticket", "gateway", "token", "credential"} { + if strings.Contains(strings.ToLower(combined), forbidden) { + t.Fatalf("pre-core output exposed forbidden category %q: %s", forbidden, combined) + } + } +} + +func TestLayeredRunPreCoreCancellationPreservesContext(t *testing.T) { + fixture := newWindowsEntryFixture(t) + for _, testCase := range []struct { + name string + transport assetdownload.Transport + download func(context.Context, assetdownload.Options) (assetdownload.Result, error) + }{ + { + name: "manifest fetch", + transport: transportFunc(func(ctx context.Context, _ assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + <-ctx.Done() + return assetdownload.TransportResponse{}, ctx.Err() + }), + }, + { + name: "runtime download", + transport: &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + }}, + download: func(ctx context.Context, _ assetdownload.Options) (assetdownload.Result, error) { + <-ctx.Done() + return assetdownload.Result{}, ctx.Err() + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + attemptDir := privateAttemptDirForTest(t) + err := (App{ + Stdout: io.Discard, + Stderr: io.Discard, + Transport: testCase.transport, + Now: fixture.now, + download: testCase.download, + }).Run(ctx, windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShell)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("pre-core cancellation error = %v, want context canceled", err) + } + content, readErr := os.ReadFile(filepath.Join(attemptDir, attemptStateFilename)) + if readErr != nil { + t.Fatal(readErr) + } + state, decodeErr := decodeAttemptState(content) + if decodeErr != nil || state.Stage != attemptStagePreCore { + t.Fatalf("canceled pre-core state = %#v, %v", state, decodeErr) + } + }) + } +} + +func TestLayeredRunVerifiesManifestAtPostFetchTime(t *testing.T) { + fixture := newWindowsEntryFixture(t) + var nowNanos atomic.Int64 + nowNanos.Store(fixture.now.UnixNano()) + downloadCalls := 0 + transport := transportFunc(func(_ context.Context, request assetdownload.TransportRequest) (assetdownload.TransportResponse, error) { + if request.URL != fixture.manifestURL { + t.Fatalf("request after expired manifest: %s", request.URL) + } + nowNanos.Store(fixture.now.Add(2 * time.Hour).UnixNano()) + return assetdownload.TransportResponse{ + StatusCode: 200, + ContentLength: int64(len(fixture.manifestJSON)), + Body: io.NopCloser(bytes.NewReader(fixture.manifestJSON)), + }, nil + }) + attemptDir := privateAttemptDirForTest(t) + err := (App{ + Stdout: io.Discard, + Stderr: io.Discard, + Transport: transport, + Clock: func() time.Time { + return time.Unix(0, nowNanos.Load()).UTC() + }, + download: func(context.Context, assetdownload.Options) (assetdownload.Result, error) { + downloadCalls++ + return assetdownload.Result{}, nil + }, + }).Run(t.Context(), windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShell)) + if err == nil || !strings.Contains(err.Error(), "manifest_verify") { + t.Fatalf("post-fetch expired manifest error = %v", err) + } + if downloadCalls != 0 { + t.Fatalf("expired manifest reached runtime download %d times", downloadCalls) + } + content, readErr := os.ReadFile(filepath.Join(attemptDir, attemptStateFilename)) + if readErr != nil { + t.Fatal(readErr) + } + state, decodeErr := decodeAttemptState(content) + if decodeErr != nil || state.Stage != attemptStagePreCore || state.UpdatedAt != fixture.now.Format(time.RFC3339Nano) { + t.Fatalf("expired manifest attempt state = %#v, %v", state, decodeErr) + } +} + +func TestLayeredRunTransitionsUseFreshClock(t *testing.T) { + fixture := newWindowsEntryFixture(t) + times := []time.Time{ + fixture.now, + fixture.now.Add(time.Second), + fixture.now.Add(2 * time.Second), + fixture.now.Add(3 * time.Second), + } + var clockIndex atomic.Int32 + attemptDir := privateAttemptDirForTest(t) + startedStatePath := filepath.Join(t.TempDir(), "core-started.json") + app := App{ + Stdout: io.Discard, + Stderr: io.Discard, + Transport: &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }}, + Clock: func() time.Time { + index := int(clockIndex.Add(1)) - 1 + if index >= len(times) { + return times[len(times)-1] + } + return times[index] + }, + CommandContext: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAttemptStateCaptureHelperProcess$") + cmd.Env = append(os.Environ(), + "RDEV_ATTEMPT_STATE_CAPTURE_HELPER=1", + "RDEV_ATTEMPT_STATE_PATH="+filepath.Join(attemptDir, attemptStateFilename), + "RDEV_ATTEMPT_STATE_CAPTURE="+startedStatePath, + ) + return cmd + }, + } + if err := app.Run(t.Context(), windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShell)); err != nil { + t.Fatal(err) + } + startedContent, err := os.ReadFile(startedStatePath) + if err != nil { + t.Fatal(err) + } + started, err := decodeAttemptState(startedContent) + if err != nil || started.Stage != attemptStageCoreStarted || started.UpdatedAt != times[2].Format(time.RFC3339Nano) { + t.Fatalf("captured started state = %#v, %v", started, err) + } + exitedContent, err := os.ReadFile(filepath.Join(attemptDir, attemptStateFilename)) + if err != nil { + t.Fatal(err) + } + exited, err := decodeAttemptState(exitedContent) + if err != nil || exited.Stage != attemptStageCoreExited || exited.UpdatedAt != times[3].Format(time.RFC3339Nano) { + t.Fatalf("final exited state = %#v, %v", exited, err) + } + if clockIndex.Load() != 4 { + t.Fatalf("clock calls = %d, want 4 lifecycle boundaries", clockIndex.Load()) + } +} + +func TestLayeredRunLifecycleFailuresExitOnlyAfterCleanup(t *testing.T) { + fixture := newWindowsEntryFixture(t) + for _, testCase := range []struct { + name string + command func(context.Context, string, ...string) *exec.Cmd + }{ + { + name: "start", + command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, filepath.Join(t.TempDir(), "missing-core.exe")) + }, + }, + { + name: "wait", + command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAttemptFailingHelperProcess$") + cmd.Env = append(os.Environ(), "RDEV_ATTEMPT_FAILING_HELPER=1") + return cmd + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + attemptDir := privateAttemptDirForTest(t) + err := (App{ + Stdout: io.Discard, + Stderr: io.Discard, + Transport: &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + fixture.coreURL: {status: 200, content: fixture.core}, + }}, + Now: fixture.now, + CommandContext: testCase.command, + }).Run(t.Context(), windowsEntryAttemptArgs(fixture, windowsEntryTestCacheDir(t), attemptDir, launcherPowerShell)) + if err == nil || strings.Contains(err.Error(), attemptDir) { + t.Fatalf("lifecycle %s error = %v", testCase.name, err) + } + content, readErr := os.ReadFile(filepath.Join(attemptDir, attemptStateFilename)) + if readErr != nil { + t.Fatal(readErr) + } + state, decodeErr := decodeAttemptState(content) + if decodeErr != nil || state.Stage != attemptStageCoreExited { + t.Fatalf("lifecycle %s state = %#v, %v", testCase.name, state, decodeErr) + } + }) + } +} + +func TestAttemptProcessLockAcrossProcesses(t *testing.T) { + if os.Getenv("RDEV_ATTEMPT_PROCESS_HELPER") == "1" { + guard, err := acquireAttempt(os.Getenv("RDEV_ATTEMPT_DIR"), launcherPowerShell, time.Now()) + if err != nil { + t.Fatal(err) + } + defer guard.close() + if err := os.WriteFile(os.Getenv("RDEV_ATTEMPT_READY"), []byte("ready"), 0o600); err != nil { + t.Fatal(err) + } + for !fileExistsForAttemptTest(os.Getenv("RDEV_ATTEMPT_RELEASE")) { + time.Sleep(5 * time.Millisecond) + } + return + } + directory := privateAttemptDirForTest(t) + readyPath := filepath.Join(t.TempDir(), "lock-ready") + releasePath := filepath.Join(t.TempDir(), "lock-release") + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestAttemptProcessLockAcrossProcesses$") + cmd.Env = append(os.Environ(), + "RDEV_ATTEMPT_PROCESS_HELPER=1", + "RDEV_ATTEMPT_DIR="+directory, + "RDEV_ATTEMPT_READY="+readyPath, + "RDEV_ATTEMPT_RELEASE="+releasePath, + ) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waitForAttemptTest(t, func() bool { return fileExistsForAttemptTest(readyPath) }) + if guard, err := acquireAttempt(directory, launcherCMD, time.Now()); err == nil { + guard.close() + t.Fatal("second process acquired an active attempt lock") + } + if err := os.WriteFile(releasePath, []byte("release"), 0o600); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + t.Fatal(err) + } +} + +func TestAttemptBlockingHelperProcess(t *testing.T) { + if os.Getenv("RDEV_ATTEMPT_BLOCKING_HELPER") != "1" { + return + } + pids := []string{strconv.Itoa(os.Getpid())} + if os.Getenv("RDEV_ATTEMPT_SPAWN_GRANDCHILD") == "1" { + grandchild := exec.Command(os.Args[0], "-test.run=^TestAttemptGrandchildHelperProcess$") + grandchild.Env = append(os.Environ(), "RDEV_ATTEMPT_GRANDCHILD_HELPER=1") + if err := grandchild.Start(); err != nil { + t.Fatal(err) + } + pids = append(pids, strconv.Itoa(grandchild.Process.Pid)) + } + if err := os.WriteFile(os.Getenv("RDEV_ATTEMPT_READY"), []byte(strings.Join(pids, " ")), 0o600); err != nil { + t.Fatal(err) + } + for { + time.Sleep(time.Hour) + } +} + +func TestAttemptGrandchildHelperProcess(t *testing.T) { + if os.Getenv("RDEV_ATTEMPT_GRANDCHILD_HELPER") != "1" { + return + } + for { + time.Sleep(time.Hour) + } +} + +func TestAttemptStateCaptureHelperProcess(t *testing.T) { + if os.Getenv("RDEV_ATTEMPT_STATE_CAPTURE_HELPER") != "1" { + return + } + content, err := os.ReadFile(os.Getenv("RDEV_ATTEMPT_STATE_PATH")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("RDEV_ATTEMPT_STATE_CAPTURE"), content, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAttemptFailingHelperProcess(t *testing.T) { + if os.Getenv("RDEV_ATTEMPT_FAILING_HELPER") == "1" { + os.Exit(7) + } +} + +func windowsEntryAttemptArgs(fixture windowsEntryFixture, cacheDir, attemptDir string, launcher attemptLauncher) []string { + args := fixture.baseArgs(cacheDir) + separator := slices.Index(args, "--") + return slices.Concat(args[:separator], []string{"--attempt-dir", attemptDir, "--launcher", string(launcher)}, args[separator:]) +} + +func waitForAttemptTest(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("timed out waiting for attempt test condition") + } + time.Sleep(5 * time.Millisecond) + } +} + +func fileExistsForAttemptTest(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func attemptPIDsForTest(t *testing.T, path string) []int { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + fields := strings.Fields(string(content)) + if len(fields) != 2 { + t.Fatalf("attempt helper PIDs = %q, want direct and grandchild", content) + } + pids := make([]int, len(fields)) + for index, field := range fields { + pid, err := strconv.Atoi(field) + if err != nil { + t.Fatal(err) + } + pids[index] = pid + } + return pids +} + +func attemptPIDFileReady(path string) bool { + content, err := os.ReadFile(path) + return err == nil && len(strings.Fields(string(content))) == 2 +} + +func waitForAttemptProcessExit(pid int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for attemptProcessRunningForTest(pid) && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + return !attemptProcessRunningForTest(pid) +} diff --git a/internal/bootstrapcmd/windowsentry/attempt_test_path_other_test.go b/internal/bootstrapcmd/windowsentry/attempt_test_path_other_test.go new file mode 100644 index 0000000..b100e9a --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/attempt_test_path_other_test.go @@ -0,0 +1,58 @@ +//go:build !windows && !rdev_bootstrap_focused + +package windowsentry + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func privateAttemptDirForTest(t *testing.T) string { + t.Helper() + directory := filepath.Join(canonicalWindowsEntryTestTempDir(t), "attempt-opaque-id") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + return directory +} + +func privateLauncherFileForTest(t *testing.T, directory, name string) string { + t.Helper() + file, err := createPrivateAttemptFile(directory, name) + if err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return filepath.Join(directory, name) +} + +func attemptProcessRunningForTest(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} + +func killAttemptProcessForTest(pid int) error { + return syscall.Kill(pid, syscall.SIGKILL) +} + +func assertPrivateAttemptPathForTest(t *testing.T, path string, directory bool) { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink != 0 || info.IsDir() != directory { + t.Fatalf("attempt path has unsafe type: %s", path) + } + want := os.FileMode(0o600) + if directory { + want = 0o700 + } + if info.Mode().Perm() != want { + t.Fatalf("attempt path mode = %o, want %o", info.Mode().Perm(), want) + } +} diff --git a/internal/bootstrapcmd/windowsentry/attempt_test_path_windows_test.go b/internal/bootstrapcmd/windowsentry/attempt_test_path_windows_test.go new file mode 100644 index 0000000..32156b1 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/attempt_test_path_windows_test.go @@ -0,0 +1,85 @@ +//go:build windows && !rdev_bootstrap_focused + +package windowsentry + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "sync/atomic" + "syscall" + "testing" +) + +var windowsAttemptTestSequence atomic.Uint64 + +func privateAttemptDirForTest(t *testing.T) string { + t.Helper() + localAppData, err := winKnownLocalAppData() + if err != nil { + t.Fatal(err) + } + directory := filepath.Join( + localAppData, + "RemoteDevSkillkit", + "attempts", + "test-"+strconv.Itoa(os.Getpid())+"-"+strconv.FormatUint(windowsAttemptTestSequence.Add(1), 10), + ) + if err := preparePrivateAttemptDirectory(directory); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + return directory +} + +func privateLauncherFileForTest(t *testing.T, directory, name string) string { + t.Helper() + file, err := createPrivateAttemptFile(directory, name) + if err != nil { + t.Fatal(err) + } + path := file.Name() + if err := file.Close(); err != nil { + t.Fatal(err) + } + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + t.Fatal(err) + } + aclTool := filepath.Join(os.Getenv("SystemRoot"), "System32", "icacls.exe") + if err := exec.Command(aclTool, path, "/inheritance:r", "/grant:r", "*"+trustees.current+":F", "*S-1-5-18:F", "*S-1-5-32-544:F").Run(); err != nil { + t.Fatal(err) + } + return path +} + +func attemptProcessRunningForTest(pid int) bool { + const synchronize = 0x00100000 + handle, err := syscall.OpenProcess(synchronize, false, uint32(pid)) + if err != nil { + return false + } + defer syscall.CloseHandle(handle) + result, err := syscall.WaitForSingleObject(handle, 0) + return err == nil && result == syscall.WAIT_TIMEOUT +} + +func killAttemptProcessForTest(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + return process.Kill() +} + +func assertPrivateAttemptPathForTest(t *testing.T, path string, directory bool) { + t.Helper() + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + t.Fatal(err) + } + if err := validateWindowsPrivatePath(path, directory, directory, trustees); err != nil { + t.Fatal(err) + } +} diff --git a/internal/bootstrapcmd/windowsentry/cache.go b/internal/bootstrapcmd/windowsentry/cache.go new file mode 100644 index 0000000..95c18c8 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/cache.go @@ -0,0 +1,77 @@ +package windowsentry + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/EitanWong/remote-dev-skillkit/internal/release" +) + +type managedCacheFile struct { + path string + maxBytes int64 +} + +func validateOptionalPrivateCacheFile(path string, expectedSize int64) error { + if _, err := os.Lstat(path); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + return validatePrivateCacheFile(path, expectedSize) +} + +func cachePaths(cacheDir string, asset release.LayeredAsset) (string, string, error) { + rawRoot := strings.TrimSpace(cacheDir) + if rawRoot == "" || strings.HasPrefix(rawRoot, `\\`) || strings.HasPrefix(rawRoot, "//") { + return "", "", fmt.Errorf("layered cache must use a local path") + } + root, err := filepath.Abs(rawRoot) + if err != nil { + return "", "", err + } + digest := strings.TrimPrefix(asset.SHA256, "sha256:") + name := filepath.Base(filepath.FromSlash(asset.RelativePath)) + if !validWindowsCacheBasename(name) { + return "", "", fmt.Errorf("layered asset has an unsafe Windows filename") + } + runtimeDir := filepath.Join(root, "runtime", digest) + contentDir := filepath.Join(root, "content") + outputPath := filepath.Join(runtimeDir, name) + contentPath := filepath.Join(contentDir, digest) + if err := preparePrivateCache( + root, + []string{root, filepath.Join(root, "runtime"), runtimeDir, contentDir}, + []managedCacheFile{ + {path: outputPath, maxBytes: asset.SizeBytes}, + {path: outputPath + ".part", maxBytes: asset.SizeBytes}, + {path: outputPath + ".tmp", maxBytes: asset.SizeBytes}, + {path: contentPath, maxBytes: asset.SizeBytes}, + {path: contentPath + ".tmp", maxBytes: asset.SizeBytes}, + }, + ); err != nil { + return "", "", err + } + return outputPath, contentPath, nil +} + +func validWindowsCacheBasename(name string) bool { + if name == "" || len(name) > 128 || strings.TrimRight(name, ". ") != name { + return false + } + for index := 0; index < len(name); index++ { + character := name[index] + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || strings.ContainsRune("._-", rune(character)) { + continue + } + return false + } + stem := strings.ToUpper(strings.SplitN(name, ".", 2)[0]) + if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" { + return false + } + return !(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') +} diff --git a/internal/bootstrapcmd/windowsentry/cache_postvalidation_other_test.go b/internal/bootstrapcmd/windowsentry/cache_postvalidation_other_test.go new file mode 100644 index 0000000..3da8ebb --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/cache_postvalidation_other_test.go @@ -0,0 +1,47 @@ +//go:build !windows && !rdev_bootstrap_focused + +package windowsentry + +import ( + "context" + "os" + "os/exec" + "testing" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" +) + +func TestWindowsEntryRejectsCacheFileReplacedDuringDownload(t *testing.T) { + fixture := newWindowsEntryFixture(t) + transport := &recordingTransport{responses: map[string]transportFixture{ + fixture.manifestURL: {status: 200, content: fixture.manifestJSON}, + }} + commandCalled := false + app := App{ + Transport: transport, + Now: fixture.now, + download: func(_ context.Context, opts assetdownload.Options) (assetdownload.Result, error) { + if err := os.WriteFile(opts.OutputPath, fixture.core, 0o600); err != nil { + return assetdownload.Result{}, err + } + target := opts.CachePath + ".target" + if err := os.WriteFile(target, fixture.core, 0o600); err != nil { + return assetdownload.Result{}, err + } + if err := os.Symlink(target, opts.CachePath); err != nil { + return assetdownload.Result{}, err + } + return assetdownload.Result{OutputPath: opts.OutputPath, Bytes: int64(len(fixture.core))}, nil + }, + CommandContext: func(ctx context.Context, path string, args ...string) *exec.Cmd { + commandCalled = true + return successfulTestCommand(ctx, path, args...) + }, + } + if err := app.Run(t.Context(), fixture.args(t, windowsEntryTestCacheDir(t))); err == nil { + t.Fatal("cache file replacement during download was accepted") + } + if commandCalled { + t.Fatal("runtime command was created after cache post-validation failed") + } +} diff --git a/internal/bootstrapcmd/windowsentry/cache_security_other.go b/internal/bootstrapcmd/windowsentry/cache_security_other.go new file mode 100644 index 0000000..625daa7 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/cache_security_other.go @@ -0,0 +1,85 @@ +//go:build !windows + +package windowsentry + +import ( + "fmt" + "os" + "path/filepath" +) + +func preparePrivateCache(root string, directories []string, files []managedCacheFile) error { + if err := rejectNearestCacheSymlink(root); err != nil { + return err + } + for _, directory := range directories { + if err := ensurePrivateCacheDirectory(directory); err != nil { + return err + } + } + for _, file := range files { + info, err := os.Lstat(file.path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() > file.maxBytes { + return fmt.Errorf("layered cache contains an unsafe managed file") + } + if err := os.Chmod(file.path, 0o600); err != nil { + return err + } + } + return nil +} + +func rejectNearestCacheSymlink(path string) error { + for current := filepath.Clean(path); ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("layered cache must not traverse a symbolic link") + } + } else if !os.IsNotExist(err) { + return err + } + parent := filepath.Dir(current) + if parent == current { + return nil + } + } +} + +func ensurePrivateCacheDirectory(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("layered cache directory is not a private directory") + } + return os.Chmod(path, 0o700) +} + +func validatePrivateCacheFile(path string, expectedSize int64) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() != expectedSize { + return fmt.Errorf("layered cache file is not a private regular file with the signed size") + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("layered cache file permissions are not private") + } + return nil +} + +func openPrivateRuntime(path string) (*os.File, error) { + return os.Open(path) +} diff --git a/internal/bootstrapcmd/windowsentry/cache_security_windows.go b/internal/bootstrapcmd/windowsentry/cache_security_windows.go new file mode 100644 index 0000000..91f6554 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/cache_security_windows.go @@ -0,0 +1,393 @@ +//go:build windows + +package windowsentry + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" +) + +type windowsPrivateTrustees struct { + current string + administrators string + system string +} + +func preparePrivateCache(root string, directories []string, files []managedCacheFile) error { + if err := validateWindowsCacheRoot(root); err != nil { + return err + } + return prepareWindowsPrivateState(directories, files) +} + +func prepareWindowsPrivateState(directories []string, files []managedCacheFile) error { + descriptor, trustees, err := windowsPrivateSecurityDescriptor() + if err != nil { + return err + } + defer descriptor.close() + for _, directory := range directories { + if err := ensureWindowsPrivateDirectory(directory, descriptor, trustees); err != nil { + return err + } + } + for _, file := range files { + if err := ensureWindowsPrivateManagedFile(file, trustees); err != nil { + return err + } + } + return nil +} + +func ensureWindowsPrivateManagedFile(file managedCacheFile, trustees windowsPrivateTrustees) error { + _, err := windowsPathAttributes(file.path) + if windowsPathMissing(err) { + created, createErr := createPrivateWindowsFile(filepath.Dir(file.path), filepath.Base(file.path), syscall.FILE_ATTRIBUTE_NORMAL) + if createErr != nil && !errors.Is(createErr, syscall.ERROR_ALREADY_EXISTS) && !errors.Is(createErr, syscall.ERROR_FILE_EXISTS) { + return createErr + } + if createErr == nil { + if closeErr := created.Close(); closeErr != nil { + return closeErr + } + } + return validateExistingWindowsManagedFile(file, trustees) + } + if err != nil { + return err + } + return validateExistingWindowsManagedFile(file, trustees) +} + +func validateWindowsCacheRoot(root string) error { + if err := validateWindowsLocalPath(root); err != nil { + return err + } + localAppData, err := winKnownLocalAppData() + if err != nil { + return fmt.Errorf("resolve Windows LocalAppData: %w", err) + } + base := filepath.Join(filepath.Clean(localAppData), "RemoteDevSkillkit", "cache") + basePrefix := base + string(os.PathSeparator) + if !strings.EqualFold(root, base) && (len(root) <= len(basePrefix) || !strings.EqualFold(root[:len(basePrefix)], basePrefix)) { + return fmt.Errorf("layered cache must remain under LocalAppData\\RemoteDevSkillkit\\cache") + } + return rejectWindowsReparseAncestors(root) +} + +func validateWindowsLocalPath(path string) error { + clean := filepath.Clean(path) + volume := filepath.VolumeName(clean) + if path != clean || !filepath.IsAbs(clean) || strings.HasPrefix(clean, `\\`) || len(volume) != 2 || volume[1] != ':' { + return fmt.Errorf("layered path must use an absolute local Windows drive") + } + rootPointer, err := syscall.UTF16PtrFromString(volume + `\`) + if err != nil { + return err + } + driveType := winGetDriveType(rootPointer) + if driveType < winDriveRemovable || driveType > winDriveRAMDisk || driveType == winDriveRemote { + return fmt.Errorf("layered path must use a local Windows volume") + } + return nil +} + +func rejectWindowsReparseAncestors(path string) error { + if err := validateWindowsLocalPath(path); err != nil { + return err + } + volumeRoot := filepath.VolumeName(path) + `\` + relative, err := filepath.Rel(volumeRoot, path) + if err != nil { + return err + } + current := volumeRoot + for _, component := range strings.Split(relative, `\`) { + if component == "" || component == "." { + continue + } + current = filepath.Join(current, component) + attributes, err := windowsPathAttributes(current) + if windowsPathMissing(err) { + return nil + } + if err != nil { + return err + } + if attributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("layered path must not traverse a Windows reparse point") + } + } + return nil +} + +func ensureWindowsPrivateDirectory(path string, descriptor winSecurityDescriptor, trustees windowsPrivateTrustees) error { + attributes, err := windowsPathAttributes(path) + if err == nil { + if attributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 || attributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("layered cache path is not a private directory") + } + return validateWindowsPrivatePath(path, true, true, trustees) + } + if !windowsPathMissing(err) { + return err + } + parent := filepath.Dir(path) + if parent == path { + return fmt.Errorf("create layered cache directory without a parent") + } + if err := ensureWindowsPrivateParent(parent, descriptor, trustees); err != nil { + return err + } + if err := createWindowsPrivateDirectory(path, descriptor); err != nil && !errors.Is(err, syscall.ERROR_ALREADY_EXISTS) { + return err + } + return validateWindowsPrivatePath(path, true, true, trustees) +} + +func ensureWindowsPrivateParent(path string, descriptor winSecurityDescriptor, trustees windowsPrivateTrustees) error { + attributes, err := windowsPathAttributes(path) + if err == nil { + if attributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 || attributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("layered cache parent is not a local directory") + } + return nil + } + if !windowsPathMissing(err) { + return err + } + parent := filepath.Dir(path) + if parent == path { + return fmt.Errorf("layered cache parent does not exist") + } + if err := ensureWindowsPrivateParent(parent, descriptor, trustees); err != nil { + return err + } + if err := createWindowsPrivateDirectory(path, descriptor); err != nil && !errors.Is(err, syscall.ERROR_ALREADY_EXISTS) { + return err + } + return validateWindowsPrivatePath(path, true, true, trustees) +} + +func createWindowsPrivateDirectory(path string, descriptor winSecurityDescriptor) error { + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return err + } + return syscall.CreateDirectory(pointer, descriptor.attributes()) +} + +func windowsPrivateSecurityDescriptor() (winSecurityDescriptor, windowsPrivateTrustees, error) { + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return winSecurityDescriptor{}, windowsPrivateTrustees{}, err + } + descriptor, err := newWinSecurityDescriptor(fmt.Sprintf( + "O:%sD:P(A;OICI;FA;;;%s)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", + trustees.current, + trustees.current, + )) + if err != nil { + return winSecurityDescriptor{}, windowsPrivateTrustees{}, fmt.Errorf("create private Windows security descriptor: %w", err) + } + return descriptor, trustees, nil +} + +func currentWindowsPrivateTrustees() (windowsPrivateTrustees, error) { + current, err := currentWinUserSID() + if err != nil { + return windowsPrivateTrustees{}, err + } + return windowsPrivateTrustees{ + current: current, + administrators: "S-1-5-32-544", + system: "S-1-5-18", + }, nil +} + +func validateExistingWindowsManagedFile(file managedCacheFile, trustees windowsPrivateTrustees) error { + attributes, err := windowsPathAttributes(file.path) + if windowsPathMissing(err) { + return nil + } + if err != nil { + return err + } + if attributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 || attributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 { + return fmt.Errorf("layered cache contains an unsafe managed file") + } + handle, err := openWindowsPrivateFile(file.path, false, trustees) + if err != nil { + return err + } + defer handle.Close() + info, err := handle.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() > file.maxBytes { + return fmt.Errorf("layered cache contains an oversized or non-regular managed file") + } + return nil +} + +func validatePrivateCacheFile(path string, expectedSize int64) error { + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return err + } + file, err := openWindowsPrivateFile(path, false, trustees) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() != expectedSize { + return fmt.Errorf("layered cache file is not a private regular file with the signed size") + } + return nil +} + +func openPrivateRuntime(path string) (*os.File, error) { + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return nil, err + } + return openWindowsPrivateFile(path, true, trustees) +} + +func openWindowsPrivateFile(path string, lock bool, trustees windowsPrivateTrustees) (*os.File, error) { + if err := rejectWindowsReparseAncestors(filepath.Dir(path)); err != nil { + return nil, err + } + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + share := uint32(syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE) + if lock { + share = syscall.FILE_SHARE_READ + } + handle, err := syscall.CreateFile( + pointer, + syscall.GENERIC_READ|winReadControl, + share, + nil, + syscall.OPEN_EXISTING, + syscall.FILE_ATTRIBUTE_NORMAL|syscall.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, err + } + if err := validateWindowsPrivateHandle(handle, false, true, 3, trustees); err != nil { + syscall.CloseHandle(handle) + return nil, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + syscall.CloseHandle(handle) + return nil, fmt.Errorf("wrap private Windows file handle") + } + return file, nil +} + +func validateWindowsPrivatePath(path string, directory, requireProtected bool, trustees windowsPrivateTrustees) error { + if err := rejectWindowsReparseAncestors(filepath.Dir(path)); err != nil { + return err + } + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return err + } + flags := uint32(syscall.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= syscall.FILE_FLAG_BACKUP_SEMANTICS + } + handle, err := syscall.CreateFile( + pointer, + winReadControl|winFileReadAttributes, + syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, + syscall.OPEN_EXISTING, + flags, + 0, + ) + if err != nil { + return err + } + defer syscall.CloseHandle(handle) + return validateWindowsPrivateHandle(handle, directory, requireProtected, 3, trustees) +} + +func validateWindowsPrivateHandle(handle syscall.Handle, directory, requireProtected bool, expectedFlags int, trustees windowsPrivateTrustees) error { + if err := validateWindowsPathType(handle, directory); err != nil { + return err + } + state, err := readWinSecurityState(handle) + if err != nil { + return err + } + return validateWindowsPrivateSecurityState(state, requireProtected, expectedFlags, trustees) +} + +func validateWindowsPrivateSecurityState(state winSecurityState, requireProtected bool, expectedFlags int, trustees windowsPrivateTrustees) error { + if state.control&winSEDACLPresent == 0 || requireProtected && state.control&winSEDACLProtected == 0 { + return fmt.Errorf("private Windows path must have a present protected DACL") + } + if !trustees.contains(state.owner) { + return fmt.Errorf("private Windows path owner is not trusted") + } + if len(state.aces) != 3 { + return fmt.Errorf("private Windows path DACL must contain exactly three ACEs") + } + seen := make(map[string]bool, 3) + for _, ace := range state.aces { + if ace.typeID != winAccessAllowedACEType || ace.mask != winPrivateFullControl || expectedFlags >= 0 && int(ace.flags) != expectedFlags { + return fmt.Errorf("private Windows DACL contains an unsupported ACE") + } + if !trustees.contains(ace.sid) || seen[ace.sid] { + return fmt.Errorf("private Windows DACL grants an untrusted SID") + } + seen[ace.sid] = true + } + if !seen[trustees.current] || !seen[trustees.administrators] || !seen[trustees.system] { + return fmt.Errorf("private Windows DACL is missing a trusted trustee") + } + return nil +} + +func validateWindowsPathType(handle syscall.Handle, directory bool) error { + var information syscall.ByHandleFileInformation + if err := syscall.GetFileInformationByHandle(handle, &information); err != nil { + return err + } + isDirectory := information.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 + if information.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 || isDirectory != directory { + return fmt.Errorf("private Windows path has unsafe file attributes") + } + return nil +} + +func (trustees windowsPrivateTrustees) contains(sid string) bool { + return sid == trustees.current || sid == trustees.administrators || sid == trustees.system +} + +func windowsPathAttributes(path string) (uint32, error) { + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return syscall.GetFileAttributes(pointer) +} + +func windowsPathMissing(err error) bool { + return errors.Is(err, syscall.ERROR_FILE_NOT_FOUND) || errors.Is(err, syscall.ERROR_PATH_NOT_FOUND) +} diff --git a/internal/bootstrapcmd/windowsentry/cache_security_windows_test.go b/internal/bootstrapcmd/windowsentry/cache_security_windows_test.go new file mode 100644 index 0000000..871ae38 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/cache_security_windows_test.go @@ -0,0 +1,183 @@ +//go:build windows + +package windowsentry + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/EitanWong/remote-dev-skillkit/internal/release" +) + +func TestWindowsEntryPrivateCacheDACLAndRuntimeLock(t *testing.T) { + cacheRoot := windowsEntryTestCacheDir(t) + asset := release.LayeredAsset{ + RelativePath: "rdev-core.exe", + SHA256: "sha256:" + strings.Repeat("a", 64), + SizeBytes: 4, + } + outputPath, _, err := cachePaths(cacheRoot, asset) + if err != nil { + t.Fatal(err) + } + if info, err := os.Stat(outputPath); err != nil || info.Size() != 0 { + t.Fatalf("cache preparation must create a private placeholder for managed files: info=%v err=%v", info, err) + } + if attributes, err := windowsPathAttributes(outputPath); err != nil { + t.Fatal(err) + } else if attributes&winFileAttributeTemporary != 0 { + t.Fatal("managed cache placeholder must not use the temporary-file attribute") + } + if err := os.WriteFile(outputPath, []byte("core"), 0o600); err != nil { + t.Fatal(err) + } + if err := validatePrivateCacheFile(outputPath, 4); err != nil { + t.Fatal(err) + } + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + t.Fatal(err) + } + if err := validateWindowsPrivatePath(filepath.Dir(outputPath), true, true, trustees); err != nil { + t.Fatal(err) + } + runtimeFile, err := openPrivateRuntime(outputPath) + if err != nil { + t.Fatal(err) + } + if err := validateWindowsPrivateHandle(syscall.Handle(runtimeFile.Fd()), false, true, 3, trustees); err != nil { + t.Fatalf("private cache file must use a protected exact file DACL: %v", err) + } + if err := os.Remove(outputPath); err == nil { + _ = runtimeFile.Close() + t.Fatal("locked runtime allowed pathname deletion") + } + if err := runtimeFile.Close(); err != nil { + t.Fatal(err) + } + if err := os.Remove(outputPath); err != nil { + t.Fatalf("runtime remained locked after handle close: %v", err) + } +} + +func TestWindowsPrivateDescriptorUsesExactProtectedDACL(t *testing.T) { + directory, err := createPrivateTemporaryDirectory("rdev-dacl-shape-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(directory) + file, err := createPrivateTemporaryFile(directory, "shape.tmp") + if err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + t.Fatal(err) + } + for _, path := range []string{directory, file.Name()} { + isDirectory := path == directory + state := securityStateForTest(t, path, isDirectory) + if err := validateWindowsPrivateSecurityState(state, true, 3, trustees); err != nil { + t.Fatalf("private descriptor for %s is not exact: %v", filepath.Base(path), err) + } + } +} + +func securityStateForTest(t *testing.T, path string, directory bool) winSecurityState { + t.Helper() + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + flags := uint32(syscall.FILE_FLAG_OPEN_REPARSE_POINT) + if directory { + flags |= syscall.FILE_FLAG_BACKUP_SEMANTICS + } + handle, err := syscall.CreateFile(pointer, winReadControl|winFileReadAttributes, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, flags, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(handle) + state, err := readWinSecurityState(handle) + if err != nil { + t.Fatal(err) + } + return state +} + +func TestWindowsEntryPrivateCurlTemporaryFiles(t *testing.T) { + directory, err := createPrivateTemporaryDirectory("rdev-bootstrap-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + file, err := createPrivateTemporaryFile(directory, "body.tmp") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte("body")); err != nil { + t.Fatal(err) + } + if err := validatePrivateTemporaryFile(file, file.Name(), 4); err != nil { + t.Fatal(err) + } + if err := os.Remove(file.Name()); err == nil { + _ = file.Close() + t.Fatal("private temporary file allowed pathname deletion while its identity handle was open") + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if err := os.Remove(file.Name()); err != nil { + t.Fatal(err) + } +} + +func TestValidateWindowsPrivateSecurityStateRejectsEveryNonExactDACL(t *testing.T) { + trustees := windowsPrivateTrustees{ + current: "S-1-5-21-1000", + administrators: "S-1-5-32-544", + system: "S-1-5-18", + } + valid := winSecurityState{ + control: winSEDACLPresent | winSEDACLProtected, + owner: trustees.current, + aces: []winACE{ + {typeID: winAccessAllowedACEType, flags: 3, mask: winPrivateFullControl, sid: trustees.current}, + {typeID: winAccessAllowedACEType, flags: 3, mask: winPrivateFullControl, sid: trustees.administrators}, + {typeID: winAccessAllowedACEType, flags: 3, mask: winPrivateFullControl, sid: trustees.system}, + }, + } + if err := validateWindowsPrivateSecurityState(valid, true, 3, trustees); err != nil { + t.Fatalf("exact protected file DACL was rejected: %v", err) + } + + tests := []struct { + name string + state winSecurityState + }{ + {name: "missing DACL", state: winSecurityState{owner: valid.owner, aces: valid.aces}}, + {name: "unprotected DACL", state: winSecurityState{control: winSEDACLPresent, owner: valid.owner, aces: valid.aces}}, + {name: "untrusted owner", state: winSecurityState{control: valid.control, owner: "S-1-5-32-545", aces: valid.aces}}, + {name: "missing ACE", state: winSecurityState{control: valid.control, owner: valid.owner, aces: valid.aces[:2]}}, + {name: "extra ACE", state: winSecurityState{control: valid.control, owner: valid.owner, aces: append(append([]winACE{}, valid.aces...), winACE{typeID: winAccessAllowedACEType, mask: winPrivateFullControl, sid: "S-1-5-32-545"})}}, + {name: "duplicate ACE", state: winSecurityState{control: valid.control, owner: valid.owner, aces: []winACE{valid.aces[0], valid.aces[1], valid.aces[0]}}}, + {name: "untrusted SID", state: winSecurityState{control: valid.control, owner: valid.owner, aces: []winACE{valid.aces[0], valid.aces[1], {typeID: winAccessAllowedACEType, mask: winPrivateFullControl, sid: "S-1-5-32-545"}}}}, + {name: "wrong ACE type", state: winSecurityState{control: valid.control, owner: valid.owner, aces: []winACE{{typeID: 1, mask: winPrivateFullControl, sid: trustees.current}, valid.aces[1], valid.aces[2]}}}, + {name: "wrong mask", state: winSecurityState{control: valid.control, owner: valid.owner, aces: []winACE{{typeID: winAccessAllowedACEType, mask: winPrivateFullControl - 1, sid: trustees.current}, valid.aces[1], valid.aces[2]}}}, + {name: "wrong file flags", state: winSecurityState{control: valid.control, owner: valid.owner, aces: []winACE{{typeID: winAccessAllowedACEType, mask: winPrivateFullControl, sid: trustees.current}, valid.aces[1], valid.aces[2]}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := validateWindowsPrivateSecurityState(test.state, true, 3, trustees); err == nil { + t.Fatal("non-exact private DACL was accepted") + } + }) + } +} diff --git a/internal/bootstrapcmd/windowsentry/cache_test_path_other_test.go b/internal/bootstrapcmd/windowsentry/cache_test_path_other_test.go new file mode 100644 index 0000000..4861d6a --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/cache_test_path_other_test.go @@ -0,0 +1,13 @@ +//go:build !windows && !rdev_bootstrap_focused + +package windowsentry + +import ( + "path/filepath" + "testing" +) + +func windowsEntryTestCacheDir(t *testing.T) string { + t.Helper() + return filepath.Join(canonicalWindowsEntryTestTempDir(t), "cache") +} diff --git a/internal/bootstrapcmd/windowsentry/cache_test_path_windows_test.go b/internal/bootstrapcmd/windowsentry/cache_test_path_windows_test.go new file mode 100644 index 0000000..fc91306 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/cache_test_path_windows_test.go @@ -0,0 +1,29 @@ +//go:build windows + +package windowsentry + +import ( + "os" + "path/filepath" + "strconv" + "sync/atomic" + "testing" +) + +var windowsEntryTestCacheSequence atomic.Uint64 + +func windowsEntryTestCacheDir(t *testing.T) string { + t.Helper() + localAppData, err := winKnownLocalAppData() + if err != nil { + t.Fatal(err) + } + directory := filepath.Join( + localAppData, + "RemoteDevSkillkit", + "cache", + "test-"+strconv.Itoa(os.Getpid())+"-"+strconv.FormatUint(windowsEntryTestCacheSequence.Add(1), 10), + ) + t.Cleanup(func() { _ = os.RemoveAll(directory) }) + return directory +} diff --git a/internal/bootstrapcmd/windowsentry/core_lifecycle_other.go b/internal/bootstrapcmd/windowsentry/core_lifecycle_other.go new file mode 100644 index 0000000..50498d6 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/core_lifecycle_other.go @@ -0,0 +1,76 @@ +//go:build !windows + +package windowsentry + +import ( + "context" + "errors" + "fmt" + "os/exec" + "syscall" + "time" +) + +type coreLifecycle struct{} + +func newCoreLifecycle(cmd *exec.Cmd) (*coreLifecycle, error) { + if cmd == nil { + return nil, fmt.Errorf("core command is required") + } + attributes := syscall.SysProcAttr{} + if cmd.SysProcAttr != nil { + attributes = *cmd.SysProcAttr + } + attributes.Setpgid = true + attributes.Pgid = 0 + cmd.SysProcAttr = &attributes + return &coreLifecycle{}, nil +} + +func (*coreLifecycle) run(ctx context.Context, cmd *exec.Cmd) (bool, error) { + if err := cmd.Start(); err != nil { + return true, err + } + processGroupID := cmd.Process.Pid + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + + var waitErr error + canceled := false + select { + case waitErr = <-waitDone: + case <-ctx.Done(): + canceled = true + waitErr = ctx.Err() + } + killErr := killCoreProcessGroup(processGroupID) + if canceled { + waitErr = errors.Join(waitErr, <-waitDone) + } + emptyErr := waitCoreProcessGroupEmpty(processGroupID) + return killErr == nil && emptyErr == nil, errors.Join(waitErr, killErr, emptyErr) +} + +func (*coreLifecycle) close() error { return nil } + +func killCoreProcessGroup(processGroupID int) error { + err := syscall.Kill(-processGroupID, syscall.SIGKILL) + if err == syscall.ESRCH { + return nil + } + return err +} + +func waitCoreProcessGroupEmpty(processGroupID int) error { + for attempt := 0; attempt < 1000; attempt++ { + err := syscall.Kill(-processGroupID, 0) + if err == syscall.ESRCH { + return nil + } + if err != nil && err != syscall.EPERM { + return err + } + time.Sleep(5 * time.Millisecond) + } + return fmt.Errorf("core process group did not exit") +} diff --git a/internal/bootstrapcmd/windowsentry/core_lifecycle_windows.go b/internal/bootstrapcmd/windowsentry/core_lifecycle_windows.go new file mode 100644 index 0000000..c760fec --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/core_lifecycle_windows.go @@ -0,0 +1,235 @@ +//go:build windows + +package windowsentry + +import ( + "context" + "errors" + "os" + "os/exec" + "syscall" + "time" + "unsafe" +) + +const ( + winCreateSuspended = 0x00000004 + winJobExtendedLimitInfo = 9 + winJobBasicAccountingInfo = 1 + winJobLimitKillOnClose = 0x00002000 + winProcessTerminate = 0x0001 + winProcessSetQuota = 0x0100 + winThreadSuspendResume = 0x0002 + winInvalidSuspendCount uint32 = 0xffffffff +) + +var ( + winCreateJobObjectW = winKernel32.NewProc("CreateJobObjectW") + winSetInformationJobObject = winKernel32.NewProc("SetInformationJobObject") + winAssignProcessToJobObject = winKernel32.NewProc("AssignProcessToJobObject") + winTerminateJobObject = winKernel32.NewProc("TerminateJobObject") + winQueryInformationJobObject = winKernel32.NewProc("QueryInformationJobObject") + winThread32First = winKernel32.NewProc("Thread32First") + winThread32Next = winKernel32.NewProc("Thread32Next") + winOpenThread = winKernel32.NewProc("OpenThread") + winResumeThread = winKernel32.NewProc("ResumeThread") + errWindowsCoreLifecycle = errors.New("core lifecycle failure") +) + +type winThreadEntry32 struct { + Size uint32 + Usage uint32 + ThreadID uint32 + OwnerProcessID uint32 + BasePriority int32 + DeltaPriority int32 + Flags uint32 +} + +type coreLifecycle struct { + job syscall.Handle +} + +func newCoreLifecycle(cmd *exec.Cmd) (*coreLifecycle, error) { + if cmd == nil { + return nil, errWindowsCoreLifecycle + } + jobValue, _, callErr := winCreateJobObjectW.Call(0, 0) + if jobValue == 0 { + return nil, winCallError(callErr) + } + lifecycle := &coreLifecycle{job: syscall.Handle(jobValue)} + limits := [18]uint64{} + *(*uint32)(unsafe.Pointer(&limits[2])) = winJobLimitKillOnClose + result, _, callErr := winSetInformationJobObject.Call( + jobValue, + winJobExtendedLimitInfo, + uintptr(unsafe.Pointer(&limits)), + unsafe.Sizeof(limits), + ) + if result == 0 { + _ = callErr + _ = lifecycle.close() + return nil, errWindowsCoreLifecycle + } + attributes := syscall.SysProcAttr{} + if cmd.SysProcAttr != nil { + attributes = *cmd.SysProcAttr + } + attributes.CreationFlags |= winCreateSuspended + cmd.SysProcAttr = &attributes + return lifecycle, nil +} + +func (lifecycle *coreLifecycle) run(_ context.Context, cmd *exec.Cmd) (bool, error) { + if err := cmd.Start(); err != nil { + _ = lifecycle.close() + return true, errWindowsCoreLifecycle + } + assigned, err := lifecycle.assign(cmd.Process.Pid) + if err != nil { + return lifecycle.abort(cmd, assigned) + } + if err := resumeWindowsProcess(cmd.Process.Pid); err != nil { + return lifecycle.abort(cmd, true) + } + waitErr := cmd.Wait() + terminateErr := lifecycle.terminate() + emptyErr := lifecycle.waitEmpty() + closeErr := lifecycle.close() + if waitErr != nil || terminateErr != nil || emptyErr != nil || closeErr != nil { + return emptyErr == nil, errWindowsCoreLifecycle + } + return true, nil +} + +func (lifecycle *coreLifecycle) assign(processID int) (bool, error) { + process, err := syscall.OpenProcess(winProcessTerminate|winProcessSetQuota, false, uint32(processID)) + if err != nil { + return false, errWindowsCoreLifecycle + } + result, _, callErr := winAssignProcessToJobObject.Call(uintptr(lifecycle.job), uintptr(process)) + closeErr := syscall.CloseHandle(process) + if result == 0 { + _ = callErr + return false, errWindowsCoreLifecycle + } + if closeErr != nil { + return true, errWindowsCoreLifecycle + } + return true, nil +} + +func (lifecycle *coreLifecycle) abort(cmd *exec.Cmd, assigned bool) (bool, error) { + if assigned { + terminateErr := lifecycle.terminate() + if terminateErr != nil { + closeErr := lifecycle.close() + if closeErr == nil { + _ = cmd.Wait() + return false, errWindowsCoreLifecycle + } + killErr := cmd.Process.Kill() + if killErr == nil || errors.Is(killErr, os.ErrProcessDone) { + _ = cmd.Wait() + } + return false, errWindowsCoreLifecycle + } + _ = cmd.Wait() + emptyErr := lifecycle.waitEmpty() + _ = lifecycle.close() + return emptyErr == nil, errWindowsCoreLifecycle + } + killErr := cmd.Process.Kill() + if killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { + _ = lifecycle.close() + return false, errWindowsCoreLifecycle + } + _ = cmd.Wait() + _ = lifecycle.close() + return true, errWindowsCoreLifecycle +} + +func (lifecycle *coreLifecycle) terminate() error { + if lifecycle == nil || lifecycle.job == 0 { + return errWindowsCoreLifecycle + } + result, _, callErr := winTerminateJobObject.Call(uintptr(lifecycle.job), 1) + if result == 0 { + return winCallError(callErr) + } + return nil +} + +func (lifecycle *coreLifecycle) waitEmpty() error { + if lifecycle == nil || lifecycle.job == 0 { + return errWindowsCoreLifecycle + } + for attempt := 0; attempt < 1000; attempt++ { + information := [6]uint64{} + result, _, callErr := winQueryInformationJobObject.Call( + uintptr(lifecycle.job), + winJobBasicAccountingInfo, + uintptr(unsafe.Pointer(&information)), + unsafe.Sizeof(information), + 0, + ) + if result == 0 { + return winCallError(callErr) + } + if *(*uint32)(unsafe.Pointer(&information[5])) == 0 { + return nil + } + time.Sleep(5 * time.Millisecond) + } + return errWindowsCoreLifecycle +} + +func (lifecycle *coreLifecycle) close() error { + if lifecycle == nil || lifecycle.job == 0 { + return nil + } + err := syscall.CloseHandle(lifecycle.job) + if err == nil { + lifecycle.job = 0 + } + return err +} + +func resumeWindowsProcess(processID int) (resultErr error) { + snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPTHREAD, 0) + if err != nil { + return err + } + defer func() { + if syscall.CloseHandle(snapshot) != nil { + resultErr = errWindowsCoreLifecycle + } + }() + entry := winThreadEntry32{Size: uint32(unsafe.Sizeof(winThreadEntry32{}))} + result, _, callErr := winThread32First.Call(uintptr(snapshot), uintptr(unsafe.Pointer(&entry))) + for result != 0 { + if entry.OwnerProcessID == uint32(processID) { + threadValue, _, openErr := winOpenThread.Call(winThreadSuspendResume, 0, uintptr(entry.ThreadID)) + if threadValue == 0 { + _ = openErr + return errWindowsCoreLifecycle + } + resumed, _, resumeErr := winResumeThread.Call(threadValue) + closeErr := syscall.CloseHandle(syscall.Handle(threadValue)) + if uint32(resumed) == winInvalidSuspendCount { + _ = resumeErr + return errWindowsCoreLifecycle + } + if closeErr != nil { + return errWindowsCoreLifecycle + } + return nil + } + result, _, callErr = winThread32Next.Call(uintptr(snapshot), uintptr(unsafe.Pointer(&entry))) + } + if callErr != syscall.ERROR_NO_MORE_FILES { + return errWindowsCoreLifecycle + } + return errWindowsCoreLifecycle +} diff --git a/internal/bootstrapcmd/windowsentry/core_lifecycle_windows_test.go b/internal/bootstrapcmd/windowsentry/core_lifecycle_windows_test.go new file mode 100644 index 0000000..b036add --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/core_lifecycle_windows_test.go @@ -0,0 +1,120 @@ +//go:build windows + +package windowsentry + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestWindowsCoreLifecycleCancelsProcessTree(t *testing.T) { + readyPath := filepath.Join(t.TempDir(), "core-tree.pids") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestWindowsCoreLifecycleDirectHelper$") + cmd.Env = append(os.Environ(), "RDEV_WINDOWS_CORE_DIRECT=1", "RDEV_WINDOWS_CORE_READY="+readyPath) + lifecycle, err := newCoreLifecycle(cmd) + if err != nil { + t.Fatal(err) + } + type result struct { + cleaned bool + err error + } + done := make(chan result, 1) + go func() { + cleaned, runErr := lifecycle.run(ctx, cmd) + done <- result{cleaned: cleaned, err: runErr} + }() + pids := waitWindowsCorePIDs(t, readyPath) + cancel() + got := <-done + if !got.cleaned || ctx.Err() != context.Canceled || got.err == nil { + t.Fatalf("canceled Windows core lifecycle = cleaned %t, error %v", got.cleaned, got.err) + } + for _, pid := range pids { + if waitWindowsCoreProcessExit(pid) { + continue + } + process, _ := os.FindProcess(pid) + _ = process.Kill() + t.Fatalf("Windows job process %d remained alive", pid) + } +} + +func TestWindowsCoreLifecycleDirectHelper(t *testing.T) { + if os.Getenv("RDEV_WINDOWS_CORE_DIRECT") != "1" { + return + } + grandchild := exec.Command(os.Args[0], "-test.run=^TestWindowsCoreLifecycleGrandchildHelper$") + grandchild.Env = append(os.Environ(), "RDEV_WINDOWS_CORE_GRANDCHILD=1") + if err := grandchild.Start(); err != nil { + t.Fatal(err) + } + content := strconv.Itoa(os.Getpid()) + " " + strconv.Itoa(grandchild.Process.Pid) + if err := os.WriteFile(os.Getenv("RDEV_WINDOWS_CORE_READY"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + for { + time.Sleep(time.Hour) + } +} + +func TestWindowsCoreLifecycleGrandchildHelper(t *testing.T) { + if os.Getenv("RDEV_WINDOWS_CORE_GRANDCHILD") != "1" { + return + } + for { + time.Sleep(time.Hour) + } +} + +func waitWindowsCorePIDs(t *testing.T, path string) []int { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + content, err := os.ReadFile(path) + if err == nil { + fields := strings.Fields(string(content)) + if len(fields) == 2 { + pids := make([]int, 2) + for index, field := range fields { + pid, parseErr := strconv.Atoi(field) + if parseErr != nil { + t.Fatal(parseErr) + } + pids[index] = pid + } + return pids + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("timed out waiting for Windows core process tree") + return nil +} + +func waitWindowsCoreProcessExit(pid int) bool { + const synchronize = 0x00100000 + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + handle, err := syscall.OpenProcess(synchronize, false, uint32(pid)) + if err != nil { + return true + } + status, waitErr := syscall.WaitForSingleObject(handle, 0) + _ = syscall.CloseHandle(handle) + if waitErr == nil && status != syscall.WAIT_TIMEOUT { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} diff --git a/internal/bootstrapcmd/windowsentry/curl_protocol.go b/internal/bootstrapcmd/windowsentry/curl_protocol.go new file mode 100644 index 0000000..8fb4946 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/curl_protocol.go @@ -0,0 +1,204 @@ +package windowsentry + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" +) + +const maxCurlHeaderBytes int64 = 64 << 10 + +func validateCurlURL(rawURL string) (httpsURL, error) { + if strings.ContainsAny(rawURL, "{}[]") { + return httpsURL{}, fmt.Errorf("curl URL contains unsupported expansion syntax") + } + return strictHTTPSURL(rawURL) +} + +func curlArguments(request assetdownload.TransportRequest, headerPath string) ([]string, error) { + parsed, err := validateCurlURL(request.URL) + if err != nil { + return nil, err + } + if request.MaxBytes <= 0 { + return nil, fmt.Errorf("curl request must have a signed byte bound") + } + if request.Offset < 0 || request.Offset > request.MaxBytes { + return nil, fmt.Errorf("curl request offset exceeds the signed byte bound") + } + if !windowsAbsoluteLocalFilePath(headerPath) { + return nil, fmt.Errorf("curl headers must use an absolute private path") + } + args := []string{ + "--disable", + "--globoff", + "--silent", + "--show-error", + "--proto", "=https", + "--max-redirs", "0", + "--connect-timeout", "10", + "--max-time", "120", + "--speed-limit", "1024", + "--speed-time", "30", + "--max-filesize", strconv.FormatInt(request.MaxBytes, 10), + } + if request.Offset > 0 { + args = append(args, "--range", strconv.FormatInt(request.Offset, 10)+"-") + } + args = append(args, + "--suppress-connect-headers", + "--include", + "--output", "-", + "--url", parsed.String(), + ) + return args, nil +} + +func windowsAbsoluteLocalFilePath(value string) bool { + if len(value) < 4 || !isASCIIAlpha(value[0]) || value[1] != ':' || value[2] != '\\' && value[2] != '/' || strings.ContainsAny(value, "\x00\r\n") { + return false + } + remainder := strings.ReplaceAll(value[3:], "/", `\`) + if remainder == "" || strings.Contains(remainder, ":") { + return false + } + for _, component := range strings.Split(remainder, `\`) { + if component == "" || component == "." || component == ".." { + return false + } + } + return true +} + +func isASCIIAlpha(value byte) bool { + return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' +} + +func parseCurlHeaders(content []byte) (int, int64, error) { + normalized := strings.ReplaceAll(string(content), "\r\n", "\n") + blocks := strings.Split(normalized, "\n\n") + for index := len(blocks) - 1; index >= 0; index-- { + lines := strings.Split(strings.TrimSpace(blocks[index]), "\n") + if len(lines) == 0 || !strings.HasPrefix(lines[0], "HTTP/") { + continue + } + statusFields := strings.Fields(lines[0]) + if len(statusFields) < 2 { + return 0, 0, fmt.Errorf("curl returned a malformed HTTP status") + } + status, err := strconv.Atoi(statusFields[1]) + if err != nil || status < 100 || status > 999 { + return 0, 0, fmt.Errorf("curl returned a malformed HTTP status") + } + contentLength := int64(-1) + for _, line := range lines[1:] { + name, value, found := strings.Cut(line, ":") + if !found || !strings.EqualFold(strings.TrimSpace(name), "Content-Length") { + continue + } + parsedLength, parseErr := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if parseErr != nil || parsedLength < 0 { + return 0, 0, fmt.Errorf("curl returned an invalid content length") + } + if contentLength >= 0 && contentLength != parsedLength { + return 0, 0, fmt.Errorf("curl returned conflicting content lengths") + } + contentLength = parsedLength + } + return status, contentLength, nil + } + return 0, 0, fmt.Errorf("curl returned no HTTP response headers") +} + +type boundedWriter struct { + writer io.Writer + remaining int64 +} + +func (writer *boundedWriter) Write(content []byte) (int, error) { + if int64(len(content)) > writer.remaining { + if writer.remaining <= 0 { + return 0, fmt.Errorf("curl response body exceeds its signed byte bound") + } + limit := writer.remaining + written, err := writer.writer.Write(content[:limit]) + writer.remaining -= int64(written) + if err != nil { + return written, err + } + if int64(written) != limit { + return written, io.ErrShortWrite + } + return written, fmt.Errorf("curl response body exceeds its signed byte bound") + } + written, err := writer.writer.Write(content) + writer.remaining -= int64(written) + if err == nil && written != len(content) { + err = io.ErrShortWrite + } + return written, err +} + +type curlResponseWriter struct { + headerWriter io.Writer + bodyWriter *boundedWriter + headerContent []byte + currentBlockStart int + finalHeaders bool +} + +func newCurlResponseWriter(headerWriter, bodyWriter io.Writer, maxBodyBytes int64) *curlResponseWriter { + return &curlResponseWriter{ + headerWriter: headerWriter, + bodyWriter: &boundedWriter{ + writer: bodyWriter, + remaining: maxBodyBytes, + }, + } +} + +func (writer *curlResponseWriter) Write(content []byte) (int, error) { + if writer.finalHeaders { + return writer.bodyWriter.Write(content) + } + written := 0 + for written < len(content) && !writer.finalHeaders { + if int64(len(writer.headerContent)) >= maxCurlHeaderBytes { + return written, fmt.Errorf("curl response headers exceed %d bytes", maxCurlHeaderBytes) + } + writer.headerContent = append(writer.headerContent, content[written]) + written++ + if !bytes.HasSuffix(writer.headerContent, []byte("\r\n\r\n")) { + continue + } + status, _, err := parseCurlHeaders(writer.headerContent[writer.currentBlockStart:]) + if err != nil { + return written, err + } + writer.currentBlockStart = len(writer.headerContent) + if status >= 100 && status < 200 { + continue + } + if _, err := io.Copy(writer.headerWriter, bytes.NewReader(writer.headerContent)); err != nil { + return written, fmt.Errorf("write curl response headers: %w", err) + } + writer.headerContent = nil + writer.finalHeaders = true + } + if written == len(content) { + return written, nil + } + bodyWritten, err := writer.bodyWriter.Write(content[written:]) + return written + bodyWritten, err +} + +func (writer *curlResponseWriter) finish() error { + if !writer.finalHeaders { + return fmt.Errorf("curl returned no final HTTP response headers") + } + return nil +} diff --git a/internal/bootstrapcmd/windowsentry/curl_protocol_test.go b/internal/bootstrapcmd/windowsentry/curl_protocol_test.go new file mode 100644 index 0000000..5604f11 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/curl_protocol_test.go @@ -0,0 +1,192 @@ +package windowsentry + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" +) + +func TestWindowsEntryCurlRejectsUnsafeURLs(t *testing.T) { + for _, rawURL := range []string{ + "http://downloads.example.test/core.exe", + "https://user:secret@downloads.example.test/core.exe", + "https://downloads.example.test/core.exe?token=secret", + "https://downloads.example.test/core.exe#fragment", + "https://downloads.example.test/{core,helper}.exe", + "https://downloads.example.test/[a-z].exe", + "https:/missing-host.exe", + "https://:443/core.exe", + "https://downloads.example.test:bad/core.exe", + "https://downloads.example.test:65536/core.exe", + "https://[::1/core.exe", + } { + if _, err := validateCurlURL(rawURL); err == nil { + t.Fatalf("unsafe curl URL %q was accepted", rawURL) + } + } + if _, err := validateCurlURL("https://downloads.example.test/core.exe"); err != nil { + t.Fatalf("safe HTTPS URL rejected: %v", err) + } +} + +func TestWindowsEntryCurlArgumentsKeepTransportByteOnly(t *testing.T) { + request := assetdownload.TransportRequest{ + URL: "https://downloads.example.test/core.exe", + Offset: 4096, + MaxBytes: 16384, + } + args, err := curlArguments(request, `C:\private\headers.tmp`) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(args, " ") + if len(args) == 0 || args[0] != "--disable" { + t.Fatalf("curl must disable user config before every other option: %q", args) + } + for _, want := range []string{ + "--globoff", + "--proto =https", + "--max-redirs 0", + "--connect-timeout 10", + "--max-time 120", + "--speed-limit 1024", + "--speed-time 30", + "--max-filesize 16384", + "--range 4096-", + "--suppress-connect-headers", + "--include", + "--output -", + "--url https://downloads.example.test/core.exe", + } { + if !strings.Contains(joined, want) { + t.Errorf("curl arguments missing %q: %s", want, joined) + } + } + for _, forbidden := range []string{"--dump-header", "--location", "--retry", "--remote-name", "--etag", "--remove-on-error"} { + if strings.Contains(joined, forbidden) { + t.Errorf("curl transport took ownership of forbidden behavior %q: %s", forbidden, joined) + } + } + if _, err := curlArguments(assetdownload.TransportRequest{URL: request.URL, Offset: 2, MaxBytes: 1}, `C:\private\headers.tmp`); err == nil { + t.Fatal("curl accepted an offset beyond the signed byte bound") + } + if _, err := curlArguments(assetdownload.TransportRequest{URL: request.URL, MaxBytes: 0}, `C:\private\headers.tmp`); err == nil { + t.Fatal("curl accepted an unbounded request") + } +} + +func TestWindowsEntryCurlRejectsUnsafeHeaderPaths(t *testing.T) { + request := assetdownload.TransportRequest{URL: "https://downloads.example.test/core.exe", MaxBytes: 4} + for _, headerPath := range []string{ + "headers.tmp", + `\\server\share\headers.tmp`, + `1:\private\headers.tmp`, + `C:\private\..\headers.tmp`, + `C:\private\headers.tmp:ads`, + } { + if _, err := curlArguments(request, headerPath); err == nil { + t.Errorf("unsafe curl header path %q was accepted", headerPath) + } + } +} + +func TestWindowsEntryCurlRejectsConflictingContentLengths(t *testing.T) { + content := []byte("HTTP/1.1 200 OK\r\nContent-Length: 4\r\nContent-Length: 5\r\n\r\n") + if _, _, err := parseCurlHeaders(content); err == nil { + t.Fatal("curl headers with conflicting content lengths were accepted") + } +} + +func TestWindowsEntryCurlBodyWriterEnforcesHardBound(t *testing.T) { + var headers bytes.Buffer + var body bytes.Buffer + writer := newCurlResponseWriter(&headers, &body, 4) + responseHeaders := "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n" + written, err := writer.Write([]byte(responseHeaders + "12345")) + if err == nil || written != len(responseHeaders)+4 || body.String() != "1234" { + t.Fatalf("bounded curl response write = %d, %v, %q", written, err, body.String()) + } +} + +func TestWindowsEntryCurlResponseWriterSplitsHeadersAcrossWrites(t *testing.T) { + var headers bytes.Buffer + var body bytes.Buffer + writer := newCurlResponseWriter(&headers, &body, 4) + for _, chunk := range []string{ + "HTTP/1.1 200 OK\r\nContent-Length: 4\r", + "\n\r", + "\nbo", + "dy", + } { + written, err := writer.Write([]byte(chunk)) + if err != nil || written != len(chunk) { + t.Fatalf("curl response chunk write = %d, %v, want %d", written, err, len(chunk)) + } + } + if err := writer.finish(); err != nil { + t.Fatal(err) + } + if got, want := headers.String(), "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n"; got != want { + t.Fatalf("curl response headers = %q, want %q", got, want) + } + if got := body.String(); got != "body" { + t.Fatalf("curl response body = %q, want body", got) + } +} + +func TestWindowsEntryCurlResponseWriterAcceptsEarlyHintsBeforeFinalResponse(t *testing.T) { + var headers bytes.Buffer + var body bytes.Buffer + writer := newCurlResponseWriter(&headers, &body, 4) + response := "HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n" + + "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody" + for _, chunk := range []string{response[:37], response[37:61], response[61:]} { + written, err := writer.Write([]byte(chunk)) + if err != nil || written != len(chunk) { + t.Fatalf("curl response chunk write = %d, %v, want %d", written, err, len(chunk)) + } + } + if err := writer.finish(); err != nil { + t.Fatal(err) + } + status, contentLength, err := parseCurlHeaders(headers.Bytes()) + if err != nil { + t.Fatal(err) + } + if status != 200 || contentLength != 4 { + t.Fatalf("final curl response = status %d length %d", status, contentLength) + } + if got := body.String(); got != "body" { + t.Fatalf("curl response body = %q, want body", got) + } +} + +func TestWindowsEntryCurlResponseWriterEnforcesHeaderBound(t *testing.T) { + var headers bytes.Buffer + var body bytes.Buffer + writer := newCurlResponseWriter(&headers, &body, 1) + written, err := writer.Write(bytes.Repeat([]byte("h"), int(maxCurlHeaderBytes)+1)) + if err == nil || int64(written) != maxCurlHeaderBytes { + t.Fatalf("oversized curl header write = %d, %v", written, err) + } + if headers.Len() != 0 || body.Len() != 0 { + t.Fatalf("oversized curl response reached files: headers=%d body=%d", headers.Len(), body.Len()) + } +} + +func TestWindowsEntryCurlBodyWriterRejectsShortWrite(t *testing.T) { + writer := &boundedWriter{writer: shortNilWriter{}, remaining: 4} + written, err := writer.Write([]byte("body")) + if written != 3 || err != io.ErrShortWrite { + t.Fatalf("short curl body write = %d, %v, want 3, %v", written, err, io.ErrShortWrite) + } +} + +type shortNilWriter struct{} + +func (shortNilWriter) Write(content []byte) (int, error) { + return len(content) - 1, nil +} diff --git a/internal/bootstrapcmd/windowsentry/curl_transport_stub.go b/internal/bootstrapcmd/windowsentry/curl_transport_stub.go new file mode 100644 index 0000000..6c69ecd --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/curl_transport_stub.go @@ -0,0 +1,13 @@ +//go:build !windows + +package windowsentry + +import ( + "fmt" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" +) + +func defaultTransport() (assetdownload.Transport, error) { + return nil, fmt.Errorf("focused Windows transport requires Windows") +} diff --git a/internal/bootstrapcmd/windowsentry/curl_transport_windows.go b/internal/bootstrapcmd/windowsentry/curl_transport_windows.go new file mode 100644 index 0000000..76eabd4 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/curl_transport_windows.go @@ -0,0 +1,165 @@ +//go:build windows + +package windowsentry + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/EitanWong/remote-dev-skillkit/internal/assetdownload" +) + +type CurlTransport struct { + SystemRoot string + Executable string +} + +func defaultTransport() (assetdownload.Transport, error) { + systemRoot, err := actualWindowsDirectory() + if err != nil { + return nil, err + } + transport := CurlTransport{SystemRoot: systemRoot} + if _, err := windowsSystemCurlPath(transport.SystemRoot, ""); err != nil { + return nil, err + } + return transport, nil +} + +func (transport CurlTransport) Fetch(ctx context.Context, request assetdownload.TransportRequest) (_ assetdownload.TransportResponse, resultErr error) { + actualRoot, err := actualWindowsDirectory() + if err != nil { + return assetdownload.TransportResponse{}, err + } + systemRoot := strings.TrimSpace(transport.SystemRoot) + if systemRoot == "" { + systemRoot = actualRoot + } + if !strings.EqualFold(filepath.Clean(systemRoot), actualRoot) { + return assetdownload.TransportResponse{}, fmt.Errorf("curl SystemRoot does not match the Windows system directory") + } + executable, err := windowsSystemCurlPath(systemRoot, transport.Executable) + if err != nil { + return assetdownload.TransportResponse{}, err + } + if err := validateWindowsSystemExecutable(executable); err != nil { + return assetdownload.TransportResponse{}, err + } + tempDir, err := createPrivateTemporaryDirectory("rdev-bootstrap-curl-") + if err != nil { + return assetdownload.TransportResponse{}, err + } + keepTemp := false + var header *os.File + var body *os.File + defer func() { + if !keepTemp { + if header != nil { + resultErr = errors.Join(resultErr, header.Close()) + } + if body != nil { + resultErr = errors.Join(resultErr, body.Close()) + } + resultErr = errors.Join(resultErr, os.RemoveAll(tempDir)) + } + }() + headerPath := filepath.Join(tempDir, "headers.tmp") + bodyPath := filepath.Join(tempDir, "body.tmp") + header, err = createPrivateTemporaryFile(tempDir, "headers.tmp") + if err != nil { + return assetdownload.TransportResponse{}, err + } + body, err = createPrivateTemporaryFile(tempDir, "body.tmp") + if err != nil { + return assetdownload.TransportResponse{}, err + } + args, err := curlArguments(request, headerPath) + if err != nil { + return assetdownload.TransportResponse{}, err + } + command := exec.CommandContext(ctx, executable, args...) + responseWriter := newCurlResponseWriter(header, body, request.MaxBytes) + command.Stdout = responseWriter + command.Stderr = io.Discard + runErr := command.Run() + if runErr != nil { + return assetdownload.TransportResponse{}, fmt.Errorf("system curl request failed: %w", runErr) + } + if err := responseWriter.finish(); err != nil { + return assetdownload.TransportResponse{}, err + } + if err := header.Sync(); err != nil { + return assetdownload.TransportResponse{}, err + } + if err := body.Sync(); err != nil { + return assetdownload.TransportResponse{}, err + } + if err := validatePrivateTemporaryFile(header, headerPath, maxCurlHeaderBytes); err != nil { + return assetdownload.TransportResponse{}, err + } + if err := validatePrivateTemporaryFile(body, bodyPath, request.MaxBytes); err != nil { + return assetdownload.TransportResponse{}, err + } + if _, err := header.Seek(0, io.SeekStart); err != nil { + return assetdownload.TransportResponse{}, err + } + headerContent, err := io.ReadAll(io.LimitReader(header, maxCurlHeaderBytes+1)) + if err != nil { + return assetdownload.TransportResponse{}, err + } + if int64(len(headerContent)) > maxCurlHeaderBytes { + return assetdownload.TransportResponse{}, fmt.Errorf("curl response headers exceed %d bytes", maxCurlHeaderBytes) + } + status, contentLength, err := parseCurlHeaders(headerContent) + if err != nil { + return assetdownload.TransportResponse{}, err + } + if err := header.Close(); err != nil { + return assetdownload.TransportResponse{}, err + } + header = nil + if _, err := body.Seek(0, io.SeekStart); err != nil { + return assetdownload.TransportResponse{}, err + } + keepTemp = true + return assetdownload.TransportResponse{ + StatusCode: status, + ContentLength: contentLength, + Body: &temporaryBody{File: body, dir: tempDir}, + }, nil +} + +func windowsSystemCurlPath(systemRoot, configured string) (string, error) { + root := strings.TrimSpace(systemRoot) + cleanRoot := filepath.Clean(root) + volume := filepath.VolumeName(cleanRoot) + if root == "" || root != cleanRoot || !filepath.IsAbs(cleanRoot) || strings.HasPrefix(cleanRoot, `\\`) || len(volume) != 2 || volume[1] != ':' { + return "", fmt.Errorf("SystemRoot must be an absolute local drive path") + } + expected := filepath.Join(cleanRoot, "System32", "curl.exe") + configured = strings.TrimSpace(configured) + if configured == "" { + return expected, nil + } + if filepath.Clean(configured) != configured || !strings.EqualFold(configured, expected) { + return "", fmt.Errorf("curl executable must be the System32 copy") + } + return expected, nil +} + +type temporaryBody struct { + *os.File + dir string +} + +func (body *temporaryBody) Close() error { + closeErr := body.File.Close() + removeErr := os.RemoveAll(body.dir) + return errors.Join(closeErr, removeErr) +} diff --git a/internal/bootstrapcmd/windowsentry/curl_transport_windows_test.go b/internal/bootstrapcmd/windowsentry/curl_transport_windows_test.go new file mode 100644 index 0000000..be4f23a --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/curl_transport_windows_test.go @@ -0,0 +1,34 @@ +package windowsentry + +import ( + "strings" + "testing" +) + +func TestWindowsEntryCurlAcceptsOnlySystemExecutable(t *testing.T) { + want := `C:\Windows\System32\curl.exe` + for _, configured := range []string{"", want, `c:\windows\system32\CURL.EXE`} { + got, err := windowsSystemCurlPath(`C:\Windows`, configured) + if err != nil { + t.Fatalf("system curl path %q rejected: %v", configured, err) + } + if !strings.EqualFold(got, want) { + t.Fatalf("system curl path = %q, want %q", got, want) + } + } + for _, configured := range []string{ + `curl.exe`, + `C:\Tools\curl.exe`, + `C:\Windows\SysWOW64\curl.exe`, + `\\server\share\curl.exe`, + } { + if _, err := windowsSystemCurlPath(`C:\Windows`, configured); err == nil { + t.Fatalf("non-system curl path %q was accepted", configured) + } + } + for _, root := range []string{"", `Windows`, `\\server\Windows`, `C:\Windows\..\Tools`} { + if _, err := windowsSystemCurlPath(root, ""); err == nil { + t.Fatalf("unsafe SystemRoot %q was accepted", root) + } + } +} diff --git a/internal/bootstrapcmd/windowsentry/private_path_other.go b/internal/bootstrapcmd/windowsentry/private_path_other.go new file mode 100644 index 0000000..7585242 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/private_path_other.go @@ -0,0 +1,20 @@ +//go:build !windows + +package windowsentry + +import "os" + +func ProtectPrivatePath(path string, directory bool) error { + mode := os.FileMode(0o600) + if directory { + mode = 0o700 + } + if err := os.Chmod(path, mode); err != nil { + return err + } + return ValidatePrivatePath(path, directory) +} + +func ValidatePrivatePath(path string, directory bool) error { + return validatePrivateLauncherPath(path, directory) +} diff --git a/internal/bootstrapcmd/windowsentry/private_path_windows.go b/internal/bootstrapcmd/windowsentry/private_path_windows.go new file mode 100644 index 0000000..7e5d1f6 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/private_path_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package windowsentry + +import ( + "fmt" + "syscall" + "unsafe" +) + +const winProtectedDACLSecurityInfo uint32 = 0x80000000 + +var winSetFileSecurityW = winAdvapi32.NewProc("SetFileSecurityW") + +func ProtectPrivatePath(path string, directory bool) error { + if err := validateWindowsLocalPath(path); err != nil { + return err + } + if err := rejectWindowsReparseAncestors(path); err != nil { + return err + } + attributes, err := windowsPathAttributes(path) + if err != nil { + return err + } + if attributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 || (attributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0) != directory { + return fmt.Errorf("private Windows path has an unexpected type") + } + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return err + } + flags := "" + if directory { + flags = "OICI" + } + descriptor, err := newWinSecurityDescriptor(fmt.Sprintf( + "D:P(A;%s;FA;;;%s)(A;%s;FA;;;SY)(A;%s;FA;;;BA)", + flags, trustees.current, flags, flags, + )) + if err != nil { + return err + } + defer descriptor.close() + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return err + } + result, _, callErr := winSetFileSecurityW.Call( + uintptr(unsafe.Pointer(pointer)), + uintptr(winDACLSecurityInfo|winProtectedDACLSecurityInfo), + descriptor.pointer, + ) + if result == 0 { + return winCallError(callErr) + } + return ValidatePrivatePath(path, directory) +} + +func ValidatePrivatePath(path string, directory bool) error { + return validatePrivateLauncherPath(path, directory) +} diff --git a/internal/bootstrapcmd/windowsentry/temp_security_windows.go b/internal/bootstrapcmd/windowsentry/temp_security_windows.go new file mode 100644 index 0000000..be940bf --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/temp_security_windows.go @@ -0,0 +1,169 @@ +//go:build windows + +package windowsentry + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "sync/atomic" + "syscall" + "time" +) + +const privateWindowsTempCreateAttempts = 128 + +var privateWindowsTempSequence atomic.Uint64 + +func actualWindowsDirectory() (string, error) { + directory, err := winSystemWindowsDirectory() + if err != nil { + return "", fmt.Errorf("resolve Windows system directory: %w", err) + } + if err := validateWindowsLocalPath(filepath.Clean(directory)); err != nil { + return "", err + } + return filepath.Clean(directory), nil +} + +func validateWindowsSystemExecutable(path string) error { + if err := rejectWindowsReparseAncestors(filepath.Dir(path)); err != nil { + return err + } + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return err + } + handle, err := syscall.CreateFile( + pointer, + syscall.GENERIC_READ, + syscall.FILE_SHARE_READ, + nil, + syscall.OPEN_EXISTING, + syscall.FILE_ATTRIBUTE_NORMAL|syscall.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return fmt.Errorf("open Windows system curl: %w", err) + } + defer syscall.CloseHandle(handle) + var information syscall.ByHandleFileInformation + if err := syscall.GetFileInformationByHandle(handle, &information); err != nil { + return err + } + if information.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 || information.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 { + return fmt.Errorf("Windows system curl is not a local regular file") + } + return nil +} + +func createPrivateTemporaryDirectory(prefix string) (string, error) { + if prefix == "" || filepath.Base(prefix) != prefix { + return "", fmt.Errorf("invalid private temporary directory prefix") + } + localAppData, err := winKnownLocalAppData() + if err != nil { + return "", err + } + base := filepath.Join(filepath.Clean(localAppData), "Temp") + if err := rejectWindowsReparseAncestors(base); err != nil { + return "", err + } + attributes, err := windowsPathAttributes(base) + if err != nil || attributes&syscall.FILE_ATTRIBUTE_DIRECTORY == 0 || attributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return "", fmt.Errorf("Windows user temporary directory is unavailable") + } + descriptor, trustees, err := windowsPrivateSecurityDescriptor() + if err != nil { + return "", err + } + defer descriptor.close() + for attempt := 0; attempt < privateWindowsTempCreateAttempts; attempt++ { + name := prefix + strconv.Itoa(os.Getpid()) + "-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatUint(privateWindowsTempSequence.Add(1), 36) + path := filepath.Join(base, name) + err := createWindowsPrivateDirectory(path, descriptor) + if errors.Is(err, syscall.ERROR_ALREADY_EXISTS) { + continue + } + if err != nil { + return "", err + } + if err := validateWindowsPrivatePath(path, true, true, trustees); err != nil { + _ = os.Remove(path) + return "", err + } + return path, nil + } + return "", fmt.Errorf("create private Windows temporary directory after %d attempts", privateWindowsTempCreateAttempts) +} + +func createPrivateTemporaryFile(directory, name string) (*os.File, error) { + return createPrivateWindowsFile(directory, name, winFileAttributeTemporary) +} + +func createPrivateWindowsFile(directory, name string, attributes uint32) (*os.File, error) { + if filepath.Base(name) != name || name == "" { + return nil, fmt.Errorf("invalid private Windows filename") + } + descriptor, trustees, err := windowsPrivateSecurityDescriptor() + if err != nil { + return nil, err + } + defer descriptor.close() + if err := validateWindowsPrivatePath(directory, true, true, trustees); err != nil { + return nil, err + } + path := filepath.Join(directory, name) + pointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := syscall.CreateFile( + pointer, + syscall.GENERIC_READ|syscall.GENERIC_WRITE|winReadControl, + syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, + descriptor.attributes(), + syscall.CREATE_NEW, + attributes, + 0, + ) + if err != nil { + return nil, err + } + if err := validateWindowsPrivateHandle(handle, false, true, 3, trustees); err != nil { + syscall.CloseHandle(handle) + _ = os.Remove(path) + return nil, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + syscall.CloseHandle(handle) + _ = os.Remove(path) + return nil, fmt.Errorf("wrap private Windows temporary file") + } + return file, nil +} + +func validatePrivateTemporaryFile(file *os.File, path string, maxBytes int64) error { + if file == nil { + return fmt.Errorf("private Windows temporary file handle is required") + } + trustees, err := currentWindowsPrivateTrustees() + if err != nil { + return err + } + if err := validateWindowsPrivateHandle(syscall.Handle(file.Fd()), false, true, 3, trustees); err != nil { + return err + } + info, err := file.Stat() + if err != nil { + return err + } + pathInfo, pathErr := os.Lstat(path) + if pathErr != nil || pathInfo.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, pathInfo) || !info.Mode().IsRegular() || info.Size() > maxBytes { + return fmt.Errorf("private Windows temporary file changed or exceeded its byte bound") + } + return nil +} diff --git a/internal/bootstrapcmd/windowsentry/winapi_windows.go b/internal/bootstrapcmd/windowsentry/winapi_windows.go new file mode 100644 index 0000000..6e07847 --- /dev/null +++ b/internal/bootstrapcmd/windowsentry/winapi_windows.go @@ -0,0 +1,297 @@ +//go:build windows + +package windowsentry + +import ( + "fmt" + "path/filepath" + "syscall" + "unsafe" +) + +const ( + winReadControl uint32 = 0x00020000 + winFileReadAttributes uint32 = 0x00000080 + winFileAttributeTemporary uint32 = 0x00000100 + winDriveRemovable uint32 = 2 + winDriveRemote uint32 = 4 + winDriveRAMDisk uint32 = 6 + winSEFileObject uint32 = 1 + winOwnerSecurityInfo uint32 = 0x00000001 + winDACLSecurityInfo uint32 = 0x00000004 + winSEDACLPresent uint16 = 0x0004 + winSEDACLProtected uint16 = 0x1000 + winAccessAllowedACEType byte = 0 + winPrivateFullControl uint32 = 0x001f01ff +) + +var ( + winAdvapi32 = syscall.NewLazyDLL("advapi32.dll") + winKernel32 = syscall.NewLazyDLL("kernel32.dll") + winShell32 = syscall.NewLazyDLL("shell32.dll") + winConvertStringSecurityDescriptorW = winAdvapi32.NewProc("ConvertStringSecurityDescriptorToSecurityDescriptorW") + winGetSecurityInfo = winAdvapi32.NewProc("GetSecurityInfo") + winIsValidSid = winAdvapi32.NewProc("IsValidSid") + winGetDriveTypeW = winKernel32.NewProc("GetDriveTypeW") + winGetSystemWindowsDirectoryW = winKernel32.NewProc("GetSystemWindowsDirectoryW") + winSHGetKnownFolderPath = winShell32.NewProc("SHGetKnownFolderPath") + winLocalAppDataFolderID = winGUID{0xf1b32785, 0x6fba, 0x4fcf, [8]byte{0x9d, 0x55, 0x7b, 0x8e, 0x7f, 0x15, 0x70, 0x91}} +) + +type winGUID struct { + data1 uint32 + data2 uint16 + data3 uint16 + data4 [8]byte +} + +type winSecurityDescriptor struct { + pointer uintptr +} + +func newWinSecurityDescriptor(sddl string) (winSecurityDescriptor, error) { + encoded, err := syscall.UTF16PtrFromString(sddl) + if err != nil { + return winSecurityDescriptor{}, err + } + var descriptor uintptr + result, _, callErr := winConvertStringSecurityDescriptorW.Call( + uintptr(unsafe.Pointer(encoded)), + 1, + uintptr(unsafe.Pointer(&descriptor)), + 0, + ) + if result == 0 { + return winSecurityDescriptor{}, winCallError(callErr) + } + if descriptor == 0 { + return winSecurityDescriptor{}, fmt.Errorf("create private Windows security descriptor: empty result") + } + return winSecurityDescriptor{pointer: descriptor}, nil +} + +func (descriptor winSecurityDescriptor) close() error { + if descriptor.pointer == 0 { + return nil + } + _, err := syscall.LocalFree(syscall.Handle(descriptor.pointer)) + return err +} + +func (descriptor winSecurityDescriptor) attributes() *syscall.SecurityAttributes { + return &syscall.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(syscall.SecurityAttributes{})), + SecurityDescriptor: descriptor.pointer, + } +} + +type winACE struct { + typeID byte + flags byte + mask uint32 + sid string +} + +type winSecurityState struct { + control uint16 + owner string + aces []winACE +} + +type winACLHeader struct { + revision byte + reserved byte + size uint16 + aceCount uint16 + padding uint16 +} + +type winACEHeader struct { + typeID byte + flags byte + size uint16 +} + +func readWinSecurityState(handle syscall.Handle) (winSecurityState, error) { + var owner unsafe.Pointer + var dacl unsafe.Pointer + var descriptor unsafe.Pointer + result, _, _ := winGetSecurityInfo.Call( + uintptr(handle), + uintptr(winSEFileObject), + uintptr(winOwnerSecurityInfo|winDACLSecurityInfo), + uintptr(unsafe.Pointer(&owner)), + 0, + uintptr(unsafe.Pointer(&dacl)), + 0, + uintptr(unsafe.Pointer(&descriptor)), + ) + if result != 0 { + return winSecurityState{}, syscall.Errno(result) + } + if descriptor == nil { + return winSecurityState{}, fmt.Errorf("private Windows security descriptor is empty") + } + defer syscall.LocalFree(syscall.Handle(uintptr(descriptor))) + if owner == nil || !validWinSID(owner) { + return winSecurityState{}, fmt.Errorf("private Windows owner SID is invalid") + } + ownerString, err := (*syscall.SID)(owner).String() + if err != nil { + return winSecurityState{}, err + } + state := winSecurityState{ + control: *(*uint16)(unsafe.Add(descriptor, 2)), + owner: ownerString, + } + if dacl == nil { + return state, nil + } + header := (*winACLHeader)(dacl) + const aclHeaderBytes = uintptr(8) + if uintptr(header.size) < aclHeaderBytes { + return winSecurityState{}, fmt.Errorf("private Windows DACL is malformed") + } + offset := aclHeaderBytes + state.aces = make([]winACE, 0, header.aceCount) + for index := uint16(0); index < header.aceCount; index++ { + if offset+unsafe.Sizeof(winACEHeader{}) > uintptr(header.size) { + return winSecurityState{}, fmt.Errorf("private Windows ACE %d exceeds the DACL", index) + } + acePointer := unsafe.Add(dacl, offset) + aceHeader := (*winACEHeader)(acePointer) + aceSize := uintptr(aceHeader.size) + const sidOffset = uintptr(8) + if aceSize < sidOffset+8 || offset+aceSize > uintptr(header.size) { + return winSecurityState{}, fmt.Errorf("private Windows ACE %d is malformed", index) + } + sidPointer := unsafe.Add(acePointer, sidOffset) + subAuthorityCount := uintptr(*(*byte)(unsafe.Add(sidPointer, 1))) + sidLength := uintptr(8) + 4*subAuthorityCount + if *(*byte)(sidPointer) != 1 || subAuthorityCount > 15 || sidLength > aceSize-sidOffset { + return winSecurityState{}, fmt.Errorf("private Windows ACE %d SID exceeds the ACE", index) + } + if !validWinSID(sidPointer) { + return winSecurityState{}, fmt.Errorf("private Windows ACE %d has an invalid SID", index) + } + sid := (*syscall.SID)(sidPointer) + if uintptr(sid.Len()) != sidLength { + return winSecurityState{}, fmt.Errorf("private Windows ACE %d SID exceeds the ACE", index) + } + sidString, err := sid.String() + if err != nil { + return winSecurityState{}, err + } + state.aces = append(state.aces, winACE{ + typeID: aceHeader.typeID, + flags: aceHeader.flags, + mask: *(*uint32)(unsafe.Add(acePointer, 4)), + sid: sidString, + }) + offset += aceSize + } + return state, nil +} + +func validWinSID(pointer unsafe.Pointer) bool { + result, _, _ := winIsValidSid.Call(uintptr(pointer)) + return result != 0 +} + +func currentWinUserSID() (string, error) { + token, err := syscall.OpenCurrentProcessToken() + if err != nil { + return "", err + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil || user == nil || user.User.Sid == nil { + return "", fmt.Errorf("read current Windows user SID: %w", err) + } + return user.User.Sid.String() +} + +func winKnownLocalAppData() (string, error) { + var path *uint16 + result, _, _ := winSHGetKnownFolderPath.Call( + uintptr(unsafe.Pointer(&winLocalAppDataFolderID)), + 0, + 0, + uintptr(unsafe.Pointer(&path)), + ) + if result != 0 { + return "", fmt.Errorf("resolve Windows LocalAppData: HRESULT 0x%x", uint32(result)) + } + if path == nil { + return "", fmt.Errorf("resolve Windows LocalAppData: empty path") + } + value, valueErr := winUTF16PointerString(path) + freeErr := winFreeCOMMemory(unsafe.Pointer(path)) + if valueErr != nil { + return "", valueErr + } + if freeErr != nil { + return "", freeErr + } + return value, nil +} + +func winFreeCOMMemory(pointer unsafe.Pointer) error { + windowsDirectory, err := winSystemWindowsDirectory() + if err != nil { + return err + } + dll, err := syscall.LoadDLL(filepath.Join(windowsDirectory, "System32", "ole32.dll")) + if err != nil { + return fmt.Errorf("load Windows COM allocator: %w", err) + } + defer dll.Release() + procedure, err := dll.FindProc("CoTaskMemFree") + if err != nil { + return fmt.Errorf("resolve Windows COM allocator: %w", err) + } + procedure.Call(uintptr(pointer)) + return nil +} + +func winSystemWindowsDirectory() (string, error) { + size := uint32(260) + for { + buffer := make([]uint16, size) + length, _, callErr := winGetSystemWindowsDirectoryW.Call( + uintptr(unsafe.Pointer(&buffer[0])), + uintptr(size), + ) + if length == 0 { + return "", winCallError(callErr) + } + if length < uintptr(size) { + return syscall.UTF16ToString(buffer[:length]), nil + } + size = uint32(length) + 1 + if size > 32768 { + return "", fmt.Errorf("Windows system directory path is too long") + } + } +} + +func winGetDriveType(root *uint16) uint32 { + result, _, _ := winGetDriveTypeW.Call(uintptr(unsafe.Pointer(root))) + return uint32(result) +} + +func winUTF16PointerString(pointer *uint16) (string, error) { + for length := 0; length < 32768; length++ { + if *(*uint16)(unsafe.Add(unsafe.Pointer(pointer), uintptr(length)*2)) == 0 { + return syscall.UTF16ToString(unsafe.Slice(pointer, length)), nil + } + } + return "", fmt.Errorf("Windows path exceeds 32767 UTF-16 code units") +} + +func winCallError(err error) error { + if errno, ok := err.(syscall.Errno); ok && errno != 0 { + return errno + } + return syscall.EINVAL +} diff --git a/internal/cdnopt/plan.go b/internal/cdnopt/plan.go index f09e64c..343cfe3 100644 --- a/internal/cdnopt/plan.go +++ b/internal/cdnopt/plan.go @@ -58,10 +58,10 @@ func BuildPlan(opts Options) Plan { "do not install services, drivers, or hidden persistence", }, SafetyRules: []string{ - "optimizer is for helper asset downloads only", + "optimizer is for verified core asset downloads only and is initiated by rdev-bootstrap", "keep TLS SNI and HTTP Host bound to the original signed asset host", "use low bounded concurrency and small byte-range samples", - "record selected candidate and timing evidence in preconnect status before downloading the full helper", + "record selected candidate and timing evidence in preconnect status before rdev-bootstrap downloads the signed core runtime", "fall back to normal signed mirror order when optimization fails", }, AgentRule: "Treat this as a future explicit optimizer gate, not permission to mutate system networking or bypass local policy.", diff --git a/internal/cdnopt/plan_test.go b/internal/cdnopt/plan_test.go index fed45d4..6e2a099 100644 --- a/internal/cdnopt/plan_test.go +++ b/internal/cdnopt/plan_test.go @@ -48,7 +48,9 @@ func TestPlanNormalizesUnsafeInputsToSafeBounds(t *testing.T) { t.Fatalf("expected timeout capped at 30 seconds, got %d", plan.TimeoutSeconds) } if !containsText(plan.SafetyRules, "asset downloads only") || - !containsText(plan.SafetyRules, "SNI") { + !containsText(plan.SafetyRules, "SNI") || + !containsText(plan.SafetyRules, "rdev-bootstrap") || + containsText(plan.SafetyRules, "full helper") { t.Fatalf("expected asset-only and SNI safety rules, got %#v", plan.SafetyRules) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 87b4434..793d6d9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -893,33 +893,38 @@ type trustRevokeOptions struct { } type gatewayServeOptions struct { - Addr string - AuditLog string - StatePath string - StorageProvider string - StoragePath string - SigningKeyPath string - SigningKeyID string - ManifestSigningKeyPath string - ManifestSigningKeyID string - EnrollmentRootPublicKey string - EnrollmentKeyPath string - EnrollmentKeyID string - EnrollmentRevocations string - TLSCertPath string - TLSKeyPath string - ClientCAPath string - OperatorAuthPath string - HostedOperatorAuthPath string - OIDCJWKSOperatorAuthPath string - SAMLOperatorAuthPath string - RdevAssetsDir string - AutoBuildRdevAssets bool - RdevWindowsAMD64Path string - RdevDarwinARM64Path string - RdevDarwinAMD64Path string - RdevLinuxAMD64Path string - RdevLinuxARM64Path string + Addr string + AuditLog string + StatePath string + StorageProvider string + StoragePath string + SigningKeyPath string + SigningKeyID string + ManifestSigningKeyPath string + ManifestSigningKeyID string + EnrollmentRootPublicKey string + EnrollmentKeyPath string + EnrollmentKeyID string + EnrollmentRevocations string + TLSCertPath string + TLSKeyPath string + ClientCAPath string + OperatorAuthPath string + HostedOperatorAuthPath string + OIDCJWKSOperatorAuthPath string + SAMLOperatorAuthPath string + RdevAssetsDir string + AutoBuildRdevAssets bool + LayeredAssetManifestPath string + LayeredReleaseRootPublicKey string + LayeredReleaseVersion string + RdevHostWindowsAMD64Path string + RdevBootstrapWindowsAMD64Path string + RdevBootstrapWindowsARM64Path string + RdevBootstrapDarwinARM64Path string + RdevBootstrapDarwinAMD64Path string + RdevBootstrapLinuxAMD64Path string + RdevBootstrapLinuxARM64Path string } func (a App) operatorAuth(args []string) error { @@ -1465,48 +1470,16 @@ func (a App) acceptance(ctx context.Context, args []string) error { case "windows-temporary": fs := flag.NewFlagSet("acceptance windows-temporary", flag.ContinueOnError) fs.SetOutput(a.Stderr) - out := fs.String("out", "", "empty output directory for the Windows temporary acceptance plan") - gatewayURL := fs.String("gateway", "", "gateway URL for attended temporary enrollment") - ticketCode := fs.String("ticket-code", "", "attended temporary ticket code") - downloadURL := fs.String("download-url", "", "rdev-host.exe download URL") - expectedSHA256 := fs.String("expected-sha256", "", "expected SHA-256 for rdev-host.exe") - bootstrapScript := fs.String("bootstrap-script", "", "local windows-temporary.ps1 path; defaults to scripts/bootstrap/windows-temporary.ps1") - bootstrapScriptURL := fs.String("bootstrap-script-url", "", "optional URL for downloading windows-temporary.ps1 on the target host") - bootstrapScriptSHA256 := fs.String("bootstrap-script-sha256", "", "expected SHA-256 for windows-temporary.ps1; defaults to local script hash when available") - manifestURL := fs.String("manifest-url", "", "signed join manifest URL") - manifestRootPublicKey := fs.String("manifest-root-public-key", "", "pinned manifest root public key") - releaseManifestURL := fs.String("release-manifest-url", "", "signed rdev-host release manifest URL") - releaseBundleURL := fs.String("release-bundle-url", "", "signed release bundle index URL") - releaseBundleRequiredArtifacts := fs.String("release-bundle-required-artifacts", "rdev-host.exe,rdev-verify.exe", "comma-separated artifact ids required in the release bundle") - releaseRootPublicKey := fs.String("release-root-public-key", "", "pinned release root public key") - verifierDownloadURL := fs.String("verifier-download-url", "", "rdev-verify.exe download URL") - verifierSHA256 := fs.String("verifier-sha256", "", "expected SHA-256 for rdev-verify.exe") - trustPin := fs.String("trust-pin", "", "optional gateway trust pin for development acceptance") - hostName := fs.String("host-name", "", "optional host display name override") - force := fs.Bool("force", false, "overwrite generated launcher if it already exists") + out := fs.String("out", "", "empty output directory for the Windows layered entry acceptance plan") + handoffArchive := fs.String("handoff-archive", "", "private Windows-ConnectionEntry.zip produced by connection-entry materialization") + force := fs.Bool("force", false, "overwrite copied acceptance inputs if they already exist") if err := fs.Parse(args[1:]); err != nil { return err } return a.acceptanceWindowsTemporary(acceptance.WindowsTemporaryOptions{ - OutDir: *out, - GatewayURL: *gatewayURL, - TicketCode: *ticketCode, - DownloadURL: *downloadURL, - ExpectedSHA256: *expectedSHA256, - BootstrapScriptPath: *bootstrapScript, - BootstrapScriptURL: *bootstrapScriptURL, - BootstrapScriptExpectedSHA256: *bootstrapScriptSHA256, - ManifestURL: *manifestURL, - ManifestRootPublicKey: *manifestRootPublicKey, - ReleaseManifestURL: *releaseManifestURL, - ReleaseBundleURL: *releaseBundleURL, - ReleaseBundleRequiredArtifacts: *releaseBundleRequiredArtifacts, - ReleaseRootPublicKey: *releaseRootPublicKey, - VerifierDownloadURL: *verifierDownloadURL, - VerifierExpectedSHA256: *verifierSHA256, - TrustPin: *trustPin, - HostName: *hostName, - Force: *force, + OutDir: *out, + HandoffArchivePath: *handoffArchive, + Force: *force, }) case "windows-managed-service": fs := flag.NewFlagSet("acceptance windows-managed-service", flag.ContinueOnError) @@ -1725,18 +1698,24 @@ func (a App) acceptance(ctx context.Context, args []string) error { noPersistenceDir := fs.String("no-persistence-dir", "", "directory containing one evidence file per no-persistence check") denialProbesDir := fs.String("denial-probes-dir", "", "directory containing one evidence file per host-denial probe") notes := fs.String("notes", "", "optional operator notes file") + coldLayeredRun := fs.String("cold-layered-run", "", "cold Windows layered bootstrap report with from_cache=false") + warmLayeredRun := fs.String("warm-layered-run", "", "warm Windows layered bootstrap report with from_cache=true") + layeredEntryEvidence := fs.String("layered-entry-evidence", "", "strict non-sensitive Windows layered entry run evidence JSON") if err := fs.Parse(args[1:]); err != nil { return err } return a.acceptancePackageWindowsTemporary(acceptance.WindowsTemporaryPackageOptions{ - PlanPath: *plan, - OutDir: *out, - TranscriptPath: *transcript, - ReleaseVerificationPath: *releaseVerification, - AuditPath: *auditPath, - NoPersistenceDir: *noPersistenceDir, - DenialProbesDir: *denialProbesDir, - NotesPath: *notes, + PlanPath: *plan, + OutDir: *out, + TranscriptPath: *transcript, + ReleaseVerificationPath: *releaseVerification, + AuditPath: *auditPath, + NoPersistenceDir: *noPersistenceDir, + DenialProbesDir: *denialProbesDir, + NotesPath: *notes, + ColdLayeredRunPath: *coldLayeredRun, + WarmLayeredRunPath: *warmLayeredRun, + LayeredEntryEvidencePath: *layeredEntryEvidence, }) case "package-managed-mac-service": fs := flag.NewFlagSet("acceptance package-managed-mac-service", flag.ContinueOnError) @@ -1985,19 +1964,21 @@ func (a App) acceptanceWindowsTemporary(opts acceptance.WindowsTemporaryOptions) return err } payload := map[string]any{ - "ok": allAcceptanceChecksPassed(plan.Checks), - "schema": plan.SchemaVersion, - "out": plan.OutDir, - "plan": filepath.Join(plan.OutDir, "windows-temporary-plan.json"), - "launcher": plan.LauncherPath, - "bootstrap_script_hash": plan.BootstrapScriptSHA256, - "checks": plan.Checks, - "commands": plan.Commands, - "no_persistence_checks": plan.NoPersistenceChecks, - "denial_probes": plan.DenialProbes, - "required_evidence": plan.RequiredEvidence, - "recommended_actions": plan.RecommendedActions, - "note": "plan and launcher written only; no PowerShell command was executed by this command", + "ok": allAcceptanceChecksPassed(plan.Checks), + "schema": plan.SchemaVersion, + "plan": "windows-temporary-plan.json", + "handoff_archive": plan.HandoffArchivePath, + "handoff_archive_sha256": plan.HandoffArchiveSHA256, + "handoff_archive_size_bytes": plan.HandoffArchiveSizeBytes, + "preferred_launcher": plan.PowerShellLauncher, + "fallback_order": plan.FallbackOrder, + "checks": plan.Checks, + "commands": plan.Commands, + "no_persistence_checks": plan.NoPersistenceChecks, + "denial_probes": plan.DenialProbes, + "required_evidence": plan.RequiredEvidence, + "recommended_actions": plan.RecommendedActions, + "note": "acceptance inputs were copied only; no Windows launcher was executed by this command", } enc := json.NewEncoder(a.Stdout) enc.SetIndent("", " ") @@ -3442,7 +3423,7 @@ func (a App) supportSession(ctx context.Context, args []string) error { fs := flag.NewFlagSet("support-session connect", flag.ContinueOnError) fs.SetOutput(a.Stderr) repoRoot := fs.String("repo-root", ".", "checked-out remote-dev-skillkit repository root") - workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and helper assets") + workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and bootstrap assets") addr := fs.String("addr", "0.0.0.0:8787", "foreground gateway listen address") gatewayURL := fs.String("gateway-url", "", "already reachable gateway URL; omit to use configured RDEV_*_GATEWAY_URL or return cli_start_now_command") target := fs.String("target", "auto", "target platform hint: auto, windows, macos, linux") @@ -3461,6 +3442,9 @@ func (a App) supportSession(ctx context.Context, args []string) error { region := fs.String("region", string(tunnel.RegionGlobal), "tunnel region policy: global or cn-mainland") providerPolicyPath := fs.String("provider-policy", "", "path to a protected tunnel provider policy JSON file") allowDegradedDirectHandoff := fs.Bool("allow-degraded-direct-handoff", false, "allow sending a single-entry direct tunnel handoff for an attended session") + layeredAssetsDir := fs.String("layered-assets-dir", "", "protected pre-signed layered release directory containing layered-assets.json and the Windows core runtime") + layeredReleaseRoot := fs.String("layered-release-root-public-key", "", "pinned Ed25519 release root for --layered-assets-dir") + layeredReleaseVersion := fs.String("layered-release-version", "", "expected signed layered release version for --layered-assets-dir") if err := fs.Parse(args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { return nil @@ -3468,32 +3452,35 @@ func (a App) supportSession(ctx context.Context, args []string) error { return err } return a.supportSessionConnect(ctx, supportSessionConnectOptions{ - RepoRoot: *repoRoot, - WorkDir: *workDir, - Addr: *addr, - GatewayURL: *gatewayURL, - Target: *target, - Reason: *reason, - TTLSeconds: *ttl, - AutoActivate: *autoActivate, - Capabilities: splitCapabilities(*capabilities), - Locale: *locale, - OperatorTokenFile: *operatorTokenFile, - RdevCommand: *rdevCommand, - Start: *start, - ReadyFile: *readyFile, - StatusFile: *statusFile, - HandoffTextFile: *handoffTextFile, - ConnectedReportFile: *connectedReportFile, - Region: *region, - ProviderPolicyPath: *providerPolicyPath, - AllowDegradedDirectHandoff: *allowDegradedDirectHandoff, + RepoRoot: *repoRoot, + WorkDir: *workDir, + Addr: *addr, + GatewayURL: *gatewayURL, + Target: *target, + Reason: *reason, + TTLSeconds: *ttl, + AutoActivate: *autoActivate, + Capabilities: splitCapabilities(*capabilities), + Locale: *locale, + OperatorTokenFile: *operatorTokenFile, + RdevCommand: *rdevCommand, + Start: *start, + ReadyFile: *readyFile, + StatusFile: *statusFile, + HandoffTextFile: *handoffTextFile, + ConnectedReportFile: *connectedReportFile, + Region: *region, + ProviderPolicyPath: *providerPolicyPath, + AllowDegradedDirectHandoff: *allowDegradedDirectHandoff, + LayeredAssetsDir: *layeredAssetsDir, + LayeredReleaseRootPublicKey: *layeredReleaseRoot, + LayeredReleaseVersion: *layeredReleaseVersion, }) case "handoff": fs := flag.NewFlagSet("support-session handoff", flag.ContinueOnError) fs.SetOutput(a.Stderr) repoRoot := fs.String("repo-root", ".", "checked-out remote-dev-skillkit repository root") - workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and helper assets") + workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and bootstrap assets") addr := fs.String("addr", "0.0.0.0:8787", "foreground gateway listen address") gatewayURL := fs.String("gateway-url", "", "already reachable gateway URL; omit when no gateway is running") target := fs.String("target", "auto", "target platform hint: auto, windows, macos, linux") @@ -3527,11 +3514,11 @@ func (a App) supportSession(ctx context.Context, args []string) error { fs := flag.NewFlagSet("support-session prepare", flag.ContinueOnError) fs.SetOutput(a.Stderr) repoRoot := fs.String("repo-root", ".", "checked-out remote-dev-skillkit repository root") - workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and helper assets") + workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and bootstrap assets") addr := fs.String("addr", "0.0.0.0:8787", "foreground gateway listen address") gatewayURL := fs.String("gateway-url", "", "gateway URL reachable by the target host; defaults to the best local candidate for --addr") target := fs.String("target", "auto", "target platform hint: auto, windows, macos, linux") - buildAssets := fs.Bool("build-assets", false, "build missing platform rdev helper assets from the checkout") + buildAssets := fs.Bool("build-assets", false, "build missing platform rdev-bootstrap assets from the checkout") rdevCommand := fs.String("rdev-command", "", "command name or absolute path for generated local Agent commands; default auto-detects a stable rdev binary") if err := fs.Parse(args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -3554,7 +3541,7 @@ func (a App) supportSession(ctx context.Context, args []string) error { repoRoot := fs.String("repo-root", ".", "checked-out remote-dev-skillkit repository root") addr := fs.String("addr", "0.0.0.0:8787", "foreground gateway listen address") gatewayURL := fs.String("gateway-url", "", "gateway URL reachable by the target host; defaults to the best local candidate for --addr") - workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and helper assets") + workDir := fs.String("work-dir", "", "session working directory for gateway state, keys, audit, and bootstrap assets") target := fs.String("target", "auto", "target platform hint: auto, windows, macos, linux") reason := fs.String("reason", "visible temporary remote support", "support session reason") ttl := fs.Int("ttl-seconds", 7200, "temporary invite TTL in seconds") @@ -3569,6 +3556,9 @@ func (a App) supportSession(ctx context.Context, args []string) error { region := fs.String("region", string(tunnel.RegionGlobal), "tunnel region policy: global or cn-mainland") providerPolicyPath := fs.String("provider-policy", "", "path to a protected tunnel provider policy JSON file") allowDegradedDirectHandoff := fs.Bool("allow-degraded-direct-handoff", false, "allow sending a single-entry direct tunnel handoff for an attended session") + layeredAssetsDir := fs.String("layered-assets-dir", "", "protected pre-signed layered release directory containing layered-assets.json and the Windows core runtime") + layeredReleaseRoot := fs.String("layered-release-root-public-key", "", "pinned Ed25519 release root for --layered-assets-dir") + layeredReleaseVersion := fs.String("layered-release-version", "", "expected signed layered release version for --layered-assets-dir") if err := fs.Parse(args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { return nil @@ -3576,24 +3566,27 @@ func (a App) supportSession(ctx context.Context, args []string) error { return err } return a.supportSessionStart(ctx, supportSessionStartOptions{ - RepoRoot: *repoRoot, - Addr: *addr, - GatewayURL: *gatewayURL, - WorkDir: *workDir, - Target: *target, - Reason: *reason, - TTLSeconds: *ttl, - AutoActivate: *autoActivate, - Capabilities: splitCapabilities(*capabilities), - Locale: *locale, - RdevCommand: *rdevCommand, - ReadyFile: *readyFile, - StatusFile: *statusFile, - HandoffTextFile: *handoffTextFile, - ConnectedReportFile: *connectedReportFile, - Region: *region, - ProviderPolicyPath: *providerPolicyPath, - AllowDegradedDirectHandoff: *allowDegradedDirectHandoff, + RepoRoot: *repoRoot, + Addr: *addr, + GatewayURL: *gatewayURL, + WorkDir: *workDir, + Target: *target, + Reason: *reason, + TTLSeconds: *ttl, + AutoActivate: *autoActivate, + Capabilities: splitCapabilities(*capabilities), + Locale: *locale, + RdevCommand: *rdevCommand, + ReadyFile: *readyFile, + StatusFile: *statusFile, + HandoffTextFile: *handoffTextFile, + ConnectedReportFile: *connectedReportFile, + Region: *region, + ProviderPolicyPath: *providerPolicyPath, + AllowDegradedDirectHandoff: *allowDegradedDirectHandoff, + LayeredAssetsDir: *layeredAssetsDir, + LayeredReleaseRootPublicKey: *layeredReleaseRoot, + LayeredReleaseVersion: *layeredReleaseVersion, }) case "create": fs := flag.NewFlagSet("support-session create", flag.ContinueOnError) @@ -3842,47 +3835,59 @@ func (a App) supportSession(ctx context.Context, args []string) error { } type supportSessionStartOptions struct { - RepoRoot string - Addr string - GatewayURL string - WorkDir string - Target string - Reason string - TTLSeconds int - AutoActivate bool - Capabilities []string - Locale string - RdevCommand string - ReadyFile string - StatusFile string - HandoffTextFile string - ConnectedReportFile string - Region string - ProviderPolicyPath string - AllowDegradedDirectHandoff bool + RepoRoot string + Addr string + GatewayURL string + WorkDir string + Target string + Reason string + TTLSeconds int + AutoActivate bool + Capabilities []string + Locale string + RdevCommand string + ReadyFile string + StatusFile string + HandoffTextFile string + ConnectedReportFile string + Region string + ProviderPolicyPath string + AllowDegradedDirectHandoff bool + LayeredAssetsDir string + LayeredReleaseRootPublicKey string + LayeredReleaseVersion string } type supportSessionConnectOptions struct { - RepoRoot string - WorkDir string - Addr string - GatewayURL string - Target string - Reason string - TTLSeconds int - AutoActivate bool - Capabilities []string - Locale string - OperatorTokenFile string - RdevCommand string - Start bool - ReadyFile string - StatusFile string - HandoffTextFile string - ConnectedReportFile string - Region string - ProviderPolicyPath string - AllowDegradedDirectHandoff bool + RepoRoot string + WorkDir string + Addr string + GatewayURL string + Target string + Reason string + TTLSeconds int + AutoActivate bool + Capabilities []string + Locale string + OperatorTokenFile string + RdevCommand string + Start bool + ReadyFile string + StatusFile string + HandoffTextFile string + ConnectedReportFile string + Region string + ProviderPolicyPath string + AllowDegradedDirectHandoff bool + LayeredAssetsDir string + LayeredReleaseRootPublicKey string + LayeredReleaseVersion string +} + +type supportSessionLayeredCandidateOptions struct { + AssetsDir string + RootPublicKey string + ExpectedVersion string } type supportSessionPrepareOptions struct { @@ -3935,24 +3940,27 @@ func (a App) supportSessionConnect(ctx context.Context, opts supportSessionConne } if opts.Start { return a.supportSessionStart(ctx, supportSessionStartOptions{ - RepoRoot: opts.RepoRoot, - Addr: opts.Addr, - GatewayURL: opts.GatewayURL, - WorkDir: opts.WorkDir, - Target: opts.Target, - Reason: opts.Reason, - TTLSeconds: opts.TTLSeconds, - AutoActivate: opts.AutoActivate, - Capabilities: opts.Capabilities, - Locale: opts.Locale, - RdevCommand: opts.RdevCommand, - ReadyFile: opts.ReadyFile, - StatusFile: opts.StatusFile, - HandoffTextFile: opts.HandoffTextFile, - ConnectedReportFile: opts.ConnectedReportFile, - Region: opts.Region, - ProviderPolicyPath: opts.ProviderPolicyPath, - AllowDegradedDirectHandoff: opts.AllowDegradedDirectHandoff, + RepoRoot: opts.RepoRoot, + Addr: opts.Addr, + GatewayURL: opts.GatewayURL, + WorkDir: opts.WorkDir, + Target: opts.Target, + Reason: opts.Reason, + TTLSeconds: opts.TTLSeconds, + AutoActivate: opts.AutoActivate, + Capabilities: opts.Capabilities, + Locale: opts.Locale, + RdevCommand: opts.RdevCommand, + ReadyFile: opts.ReadyFile, + StatusFile: opts.StatusFile, + HandoffTextFile: opts.HandoffTextFile, + ConnectedReportFile: opts.ConnectedReportFile, + Region: opts.Region, + ProviderPolicyPath: opts.ProviderPolicyPath, + AllowDegradedDirectHandoff: opts.AllowDegradedDirectHandoff, + LayeredAssetsDir: opts.LayeredAssetsDir, + LayeredReleaseRootPublicKey: opts.LayeredReleaseRootPublicKey, + LayeredReleaseVersion: opts.LayeredReleaseVersion, }) } gatewayURL := strings.TrimRight(strings.TrimSpace(opts.GatewayURL), "/") @@ -4489,7 +4497,7 @@ func (a App) supportSessionStart(ctx context.Context, opts supportSessionStartOp } gatewayCandidates, _ := prepared["gateway_url_candidates"].([]supportsession.GatewayURLCandidate) - // Pre-check: verify that platform helper binaries were actually built into + // Pre-check: verify that platform bootstrap binaries were actually built into // workDir/bin before the gateway starts serving /assets/* routes. // // This catches the common mistake of running `prepare --build-assets` with @@ -4508,11 +4516,12 @@ func (a App) supportSessionStart(ctx context.Context, opts supportSessionStartOp path string } checks := []platformCheck{ - {"windows-amd64", filepath.Join(binDir, "rdev-windows-amd64.exe")}, - {"darwin-arm64", filepath.Join(binDir, "rdev-darwin-arm64")}, - {"darwin-amd64", filepath.Join(binDir, "rdev-darwin-amd64")}, - {"linux-amd64", filepath.Join(binDir, "rdev-linux-amd64")}, - {"linux-arm64", filepath.Join(binDir, "rdev-linux-arm64")}, + {"windows-amd64", filepath.Join(binDir, "rdev-bootstrap-windows-amd64.exe")}, + {"windows-arm64", filepath.Join(binDir, "rdev-bootstrap-windows-arm64.exe")}, + {"darwin-arm64", filepath.Join(binDir, "rdev-bootstrap-darwin-arm64")}, + {"darwin-amd64", filepath.Join(binDir, "rdev-bootstrap-darwin-amd64")}, + {"linux-amd64", filepath.Join(binDir, "rdev-bootstrap-linux-amd64")}, + {"linux-arm64", filepath.Join(binDir, "rdev-bootstrap-linux-arm64")}, } var missing []string for _, c := range checks { @@ -4520,7 +4529,7 @@ func (a App) supportSessionStart(ctx context.Context, opts supportSessionStartOp missing = append(missing, c.name) } } - return fmt.Errorf("support-session connect cannot generate a target handoff until helper assets are ready; missing platform helpers %v in %s; rerun the standard recovery command: rdev support-session connect --start --repo-root %s --work-dir %s", missing, binDir, repoRoot, workDir) + return fmt.Errorf("support-session connect cannot generate a target handoff until bootstrap assets are ready; missing platform bootstraps %v in %s; rerun the standard recovery command: rdev support-session connect --start --repo-root %s --work-dir %s", missing, binDir, repoRoot, workDir) } } @@ -4569,13 +4578,13 @@ func (a App) supportSessionStart(ctx context.Context, opts supportSessionStartOp auditStore := audit.NewJSONLStore(auditLogPath) gw.WithAuditSink(&auditStore) server := httpapi.NewServerWithStateStore(gw, store) - server.Assets = httpapi.AssetConfig{ - RdevWindowsAMD64Path: filepath.Join(workDir, "bin", "rdev-windows-amd64.exe"), - RdevDarwinARM64Path: filepath.Join(workDir, "bin", "rdev-darwin-arm64"), - RdevDarwinAMD64Path: filepath.Join(workDir, "bin", "rdev-darwin-amd64"), - RdevLinuxAMD64Path: filepath.Join(workDir, "bin", "rdev-linux-amd64"), - RdevLinuxARM64Path: filepath.Join(workDir, "bin", "rdev-linux-arm64"), + assets, err := supportSessionAssetConfig(workDir, supportSessionLayeredCandidateOptions{ + AssetsDir: opts.LayeredAssetsDir, RootPublicKey: opts.LayeredReleaseRootPublicKey, ExpectedVersion: opts.LayeredReleaseVersion, + }, time.Now().UTC()) + if err != nil { + return fmt.Errorf("prepare verified Windows layered release assets: %w", err) } + server.Assets = assets gatewayServer := startGatewayServer(addr, server.Handler(), nil) defer func() { _ = shutdownGatewayServer(gatewayServer) }() a.recordSupportSessionStartEvent("local_gateway_started") @@ -5042,7 +5051,6 @@ func createSupportSessionPayload(ctx context.Context, opts supportSessionCreateO NetworkScope: "auto", AuthorityProfile: "standard", OperatorTokenFile: opts.OperatorTokenFile, - RdevCommand: opts.RdevCommand, Once: false, AutoActivate: opts.AutoActivate, }) @@ -5083,20 +5091,20 @@ func probeTargetBootstrapReadiness(ctx context.Context, client *http.Client, gat "all_ready": allReady, "probed": len(assets) > 0, "assets": results, - "agent_rule": "if all_ready is false for a platform terminal command, run rdev support-session connect --start or prepare --build-assets instead of asking the target user to install rdev manually", + "agent_rule": "if all_ready is false for a platform terminal command, run rdev support-session connect --start or prepare --build-assets to publish rdev-bootstrap", } } func supportSessionRequiredAssets(target string) []string { switch target { case "windows": - return []string{"rdev-windows-amd64.exe.sha256"} + return []string{"rdev-bootstrap-windows-amd64.exe.sha256"} case "macos": - return []string{"rdev-darwin-arm64.sha256", "rdev-darwin-amd64.sha256"} + return []string{"rdev-bootstrap-darwin-arm64.sha256", "rdev-bootstrap-darwin-amd64.sha256"} case "linux": - return []string{"rdev-linux-amd64.sha256", "rdev-linux-arm64.sha256"} + return []string{"rdev-bootstrap-linux-amd64.sha256", "rdev-bootstrap-linux-arm64.sha256"} default: - return []string{"rdev-windows-amd64.exe.sha256", "rdev-darwin-arm64.sha256", "rdev-darwin-amd64.sha256", "rdev-linux-amd64.sha256", "rdev-linux-arm64.sha256"} + return []string{"rdev-bootstrap-windows-amd64.exe.sha256", "rdev-bootstrap-darwin-arm64.sha256", "rdev-bootstrap-darwin-amd64.sha256", "rdev-bootstrap-linux-amd64.sha256", "rdev-bootstrap-linux-arm64.sha256"} } } @@ -5162,7 +5170,7 @@ func prepareSupportSessionEnvironment(ctx context.Context, opts supportSessionPr repoRootTrusted := repoRootSource != "hint" && repoRootSource != "hint-unverified" && repoRootSource != "default" report["repo_root_trusted"] = repoRootTrusted if !repoRootTrusted { - report["repo_root_warning"] = "repo root came only from --repo-root; set RDEV_SOURCE_ROOT or run from the active checkout to avoid stale helper assets" + report["repo_root_warning"] = "repo root came only from --repo-root; set RDEV_SOURCE_ROOT or run from the active checkout to avoid stale bootstrap assets" } return report, nil } @@ -8041,7 +8049,6 @@ func (a App) connectionEntry(args []string) error { targetOS := fs.String("target-os", runtime.GOOS, "target OS: windows, darwin, or linux") ownership := fs.String("ownership", "", "target ownership: owned or third-party; inferred from invite mode when omitted") sessionMode := fs.String("session-mode", "", "session mode override: attended-temporary, managed, or break-glass") - releaseBundleURL := fs.String("release-bundle-url", "", "signed release bundle index URL for target-side package verification") releaseBundlePath := fs.String("release-bundle", "", "target-local signed release bundle path for owned managed-service entries") releaseBundleRequiredArtifacts := fs.String("release-bundle-required-artifacts", "", "comma-separated artifact ids required in the release bundle") releaseRootPublicKey := fs.String("release-root-public-key", "", "pinned release root public key") @@ -8049,46 +8056,39 @@ func (a App) connectionEntry(args []string) error { managedServiceName := fs.String("managed-service-name", "", "optional Windows managed service name") managedServiceLabel := fs.String("managed-service-label", "", "optional macOS LaunchAgent label") managedUnitName := fs.String("managed-unit-name", "", "optional Linux systemd user unit name") - windowsHostDownloadURL := fs.String("windows-host-download-url", "", "rdev-host.exe download URL for Windows temporary entry materialization") - windowsHostSHA256 := fs.String("windows-host-sha256", "", "expected SHA-256 for rdev-host.exe") - windowsVerifierDownloadURL := fs.String("windows-verifier-download-url", "", "rdev-verify.exe download URL") - windowsVerifierSHA256 := fs.String("windows-verifier-sha256", "", "expected SHA-256 for rdev-verify.exe") - windowsBootstrapScriptURL := fs.String("windows-bootstrap-script-url", "", "optional URL for downloading windows-temporary.ps1 on the target host") - windowsBootstrapScriptSHA256 := fs.String("windows-bootstrap-script-sha256", "", "expected SHA-256 for windows-temporary.ps1") - windowsBootstrapScriptPath := fs.String("windows-bootstrap-script", "", "local windows-temporary.ps1 path; defaults to scripts/bootstrap/windows-temporary.ps1") + windowsBootstrapBinaryPath := fs.String("windows-bootstrap-binary", "", "verified rdev-bootstrap.exe path for layered Windows temporary entry materialization") + windowsBootstrapReleaseManifestPath := fs.String("windows-bootstrap-release-manifest", "", "signed release manifest path for --windows-bootstrap-binary") + layeredAssetsManifestURL := fs.String("layered-assets-manifest-url", "", "HTTPS layered asset manifest URL for Windows temporary entry materialization") + layeredReleaseVersion := fs.String("layered-release-version", "", "expected signed release version for Windows layered assets") hostName := fs.String("host-name", "", "optional target host display name") targetArch := fs.String("target-arch", runtime.GOARCH, "target architecture: amd64 or arm64") - rdevCommand := fs.String("rdev-command", "rdev", "rdev command embedded in the generated Connection Entry runner launcher") + bootstrapCommand := fs.String("bootstrap-command", "rdev-bootstrap", "rdev-bootstrap command embedded in the generated Connection Entry runner launcher") force := fs.Bool("force", false, "overwrite generated nested Windows launcher files when supported") if err := fs.Parse(args[1:]); err != nil { return err } plan, err := connectionentry.FromInvite(connectionentry.Options{ - InviteJSON: *inviteJSON, - InvitePath: *invitePath, - OutDir: *out, - TargetOS: *targetOS, - Ownership: *ownership, - SessionMode: *sessionMode, - ReleaseBundleURL: *releaseBundleURL, - ReleaseBundleRequiredArtifacts: *releaseBundleRequiredArtifacts, - ReleaseBundlePath: *releaseBundlePath, - ReleaseRootPublicKey: *releaseRootPublicKey, - ManagedBinaryPath: *managedBinaryPath, - ManagedServiceName: *managedServiceName, - ManagedServiceLabel: *managedServiceLabel, - ManagedUnitName: *managedUnitName, - WindowsHostDownloadURL: *windowsHostDownloadURL, - WindowsHostExpectedSHA256: *windowsHostSHA256, - WindowsVerifierDownloadURL: *windowsVerifierDownloadURL, - WindowsVerifierExpectedSHA256: *windowsVerifierSHA256, - WindowsBootstrapScriptURL: *windowsBootstrapScriptURL, - WindowsBootstrapScriptSHA256: *windowsBootstrapScriptSHA256, - WindowsBootstrapScriptPath: *windowsBootstrapScriptPath, - HostName: *hostName, - TargetArch: *targetArch, - RdevCommand: *rdevCommand, - Force: *force, + InviteJSON: *inviteJSON, + InvitePath: *invitePath, + OutDir: *out, + TargetOS: *targetOS, + Ownership: *ownership, + SessionMode: *sessionMode, + ReleaseBundleRequiredArtifacts: *releaseBundleRequiredArtifacts, + ReleaseBundlePath: *releaseBundlePath, + ReleaseRootPublicKey: *releaseRootPublicKey, + ManagedBinaryPath: *managedBinaryPath, + ManagedServiceName: *managedServiceName, + ManagedServiceLabel: *managedServiceLabel, + ManagedUnitName: *managedUnitName, + WindowsBootstrapBinaryPath: *windowsBootstrapBinaryPath, + WindowsBootstrapReleaseManifestPath: *windowsBootstrapReleaseManifestPath, + LayeredAssetsManifestURL: *layeredAssetsManifestURL, + LayeredReleaseVersion: *layeredReleaseVersion, + HostName: *hostName, + TargetArch: *targetArch, + BootstrapCommand: *bootstrapCommand, + Force: *force, }) if err != nil { return err @@ -8108,10 +8108,10 @@ func (a App) connectionEntry(args []string) error { fs := flag.NewFlagSet("connection-entry run", flag.ContinueOnError) fs.SetOutput(a.Stderr) manifestPath := fs.String("runner-manifest", "", "Connection Entry runner manifest path") - rdevCommand := fs.String("rdev-command", "rdev", "rdev command to execute for host serve") - dryRun := fs.Bool("dry-run", false, "probe and print selected path without starting host serve") + bootstrapCommand := fs.String("bootstrap-command", "rdev-bootstrap", "rdev-bootstrap command used to verify and start the core runtime") + dryRun := fs.Bool("dry-run", false, "probe and print selected path without starting the core runtime") probeTimeout := fs.Duration("probe-timeout", 5*time.Second, "per-path gateway probe timeout") - extraHostArgs := fs.String("host-args", "", "optional comma-separated extra rdev host serve args") + extraHostArgs := fs.String("host-args", "", "optional comma-separated extra bootstrap-started core args") resultOut := fs.String("result-out", "", "optional path to write the raw Connection Entry runner result JSON for acceptance evidence") helperTranscriptOut := fs.String("helper-transcript-out", "", "optional path to write standard helper transcript evidence for relay/connectivity acceptance") evidenceDir := fs.String("evidence-dir", "", "optional directory to write runner-result.json, helper-transcript.txt, gateway-status.json, host-status.json, connection-status.json, and audit.jsonl") @@ -8119,11 +8119,11 @@ func (a App) connectionEntry(args []string) error { return err } result, err := connectionrunner.Run(connectionrunner.RunOptions{ - ManifestPath: *manifestPath, - RdevCommand: *rdevCommand, - DryRun: *dryRun, - ProbeTimeout: *probeTimeout, - ExtraHostArgs: splitCapabilities(*extraHostArgs), + ManifestPath: *manifestPath, + BootstrapCommand: *bootstrapCommand, + DryRun: *dryRun, + ProbeTimeout: *probeTimeout, + ExtraHostArgs: splitCapabilities(*extraHostArgs), }) if err != nil { return err @@ -8203,7 +8203,6 @@ func (a App) invite(ctx context.Context, args []string) error { networkScope := fs.String("network-scope", "auto", "network scope hint: auto, internet, lan, relay, mesh, or ssh") authorityProfile := fs.String("authority-profile", "max-control", "agent authority profile: standard or max-control") operatorTokenFile := fs.String("operator-token-file", "", "file containing an operator auth bearer token") - rdevCommand := fs.String("rdev-command", "rdev", "command name or absolute path to run on the target host") once := fs.Bool("once", false, "ask the target host process to exit after one task") autoActivate := fs.Bool("auto-activate", false, "auto-activate the first attended-temporary host created by this standard Connection Entry") if err := fs.Parse(args[1:]); err != nil { @@ -8219,7 +8218,6 @@ func (a App) invite(ctx context.Context, args []string) error { NetworkScope: *networkScope, AuthorityProfile: *authorityProfile, OperatorTokenFile: *operatorTokenFile, - RdevCommand: *rdevCommand, Once: *once, AutoActivate: *autoActivate, }) @@ -8239,7 +8237,6 @@ type inviteCreateOptions struct { NetworkScope string AuthorityProfile string OperatorTokenFile string - RdevCommand string Once bool AutoActivate bool } @@ -8272,7 +8269,6 @@ func (a App) inviteCreate(ctx context.Context, opts inviteCreateOptions) error { AuthorityProfile: opts.AuthorityProfile, Once: opts.Once, RequireHostActivation: !opts.AutoActivate, - RdevCommand: opts.RdevCommand, }) if err != nil { return err @@ -8564,13 +8560,18 @@ func (a App) gateway(args []string) error { hostedOperatorAuth := fs.String("hosted-operator-auth", "", "optional hosted operator auth JSON file for EdDSA JWT role tokens") oidcJWKSOperatorAuth := fs.String("oidc-jwks-operator-auth", "", "optional OIDC JWKS operator auth JSON file for RS256 JWT role tokens") samlOperatorAuth := fs.String("saml-operator-auth", "", "optional SAML operator auth JSON file for signed SAMLResponse bearer tokens") - rdevAssetsDir := fs.String("rdev-assets-dir", "", "optional directory containing rdev-windows-amd64.exe, rdev-darwin-arm64, rdev-darwin-amd64, rdev-linux-amd64, and rdev-linux-arm64 helpers") - autoBuildRdevAssets := fs.Bool("auto-build-rdev-assets", true, "auto-build missing platform rdev helpers for dev gateway Connection Entry bootstraps when a checkout and Go are available") - rdevWindowsAMD64 := fs.String("rdev-windows-amd64", "", "optional rdev.exe helper served to Windows amd64 Connection Entry bootstraps") - rdevDarwinARM64 := fs.String("rdev-darwin-arm64", "", "optional rdev helper served to macOS arm64 Connection Entry bootstraps") - rdevDarwinAMD64 := fs.String("rdev-darwin-amd64", "", "optional rdev helper served to macOS amd64 Connection Entry bootstraps") - rdevLinuxAMD64 := fs.String("rdev-linux-amd64", "", "optional rdev helper served to Linux amd64 Connection Entry bootstraps") - rdevLinuxARM64 := fs.String("rdev-linux-arm64", "", "optional rdev helper served to Linux arm64 Connection Entry bootstraps") + rdevAssetsDir := fs.String("rdev-assets-dir", "", "optional directory containing layered assets and platform rdev-bootstrap binaries") + autoBuildRdevAssets := fs.Bool("auto-build-rdev-assets", true, "auto-build missing platform rdev-bootstrap assets for dev gateway Connection Entry when a checkout and Go are available") + layeredAssetsManifest := fs.String("layered-assets-manifest", "", "optional layered-assets.json manifest served to layered Connection Entry bootstraps") + layeredReleaseRoot := fs.String("layered-release-root-public-key", "", "pinned Ed25519 release root for the layered asset manifest") + layeredReleaseVersion := fs.String("layered-release-version", "", "expected signed layered release version") + rdevHostWindowsAMD64 := fs.String("rdev-host-windows-amd64", "", "optional rdev-host.exe core runtime served to Windows amd64 layered Connection Entry bootstraps") + rdevBootstrapWindowsAMD64 := fs.String("rdev-bootstrap-windows-amd64", "", "optional Windows amd64 rdev-bootstrap executable") + rdevBootstrapWindowsARM64 := fs.String("rdev-bootstrap-windows-arm64", "", "optional Windows arm64 rdev-bootstrap executable") + rdevBootstrapDarwinARM64 := fs.String("rdev-bootstrap-darwin-arm64", "", "optional macOS arm64 rdev-bootstrap executable") + rdevBootstrapDarwinAMD64 := fs.String("rdev-bootstrap-darwin-amd64", "", "optional macOS amd64 rdev-bootstrap executable") + rdevBootstrapLinuxAMD64 := fs.String("rdev-bootstrap-linux-amd64", "", "optional Linux amd64 rdev-bootstrap executable") + rdevBootstrapLinuxARM64 := fs.String("rdev-bootstrap-linux-arm64", "", "optional Linux arm64 rdev-bootstrap executable") if err := fs.Parse(args[1:]); err != nil { return err } @@ -8578,33 +8579,38 @@ func (a App) gateway(args []string) error { return fmt.Errorf("gateway serve currently requires --dev") } return a.gatewayServeDev(gatewayServeOptions{ - Addr: *addr, - AuditLog: *auditLog, - StatePath: *statePath, - StorageProvider: *storageProvider, - StoragePath: *storagePath, - SigningKeyPath: *signingKey, - SigningKeyID: *signingKeyID, - ManifestSigningKeyPath: *manifestSigningKey, - ManifestSigningKeyID: *manifestSigningKeyID, - EnrollmentRootPublicKey: *enrollmentRootPublicKey, - EnrollmentKeyPath: *enrollmentKey, - EnrollmentKeyID: *enrollmentKeyID, - EnrollmentRevocations: *enrollmentRevocations, - TLSCertPath: *tlsCert, - TLSKeyPath: *tlsKey, - ClientCAPath: *clientCA, - OperatorAuthPath: *operatorAuth, - HostedOperatorAuthPath: *hostedOperatorAuth, - OIDCJWKSOperatorAuthPath: *oidcJWKSOperatorAuth, - SAMLOperatorAuthPath: *samlOperatorAuth, - RdevAssetsDir: *rdevAssetsDir, - AutoBuildRdevAssets: *autoBuildRdevAssets, - RdevWindowsAMD64Path: *rdevWindowsAMD64, - RdevDarwinARM64Path: *rdevDarwinARM64, - RdevDarwinAMD64Path: *rdevDarwinAMD64, - RdevLinuxAMD64Path: *rdevLinuxAMD64, - RdevLinuxARM64Path: *rdevLinuxARM64, + Addr: *addr, + AuditLog: *auditLog, + StatePath: *statePath, + StorageProvider: *storageProvider, + StoragePath: *storagePath, + SigningKeyPath: *signingKey, + SigningKeyID: *signingKeyID, + ManifestSigningKeyPath: *manifestSigningKey, + ManifestSigningKeyID: *manifestSigningKeyID, + EnrollmentRootPublicKey: *enrollmentRootPublicKey, + EnrollmentKeyPath: *enrollmentKey, + EnrollmentKeyID: *enrollmentKeyID, + EnrollmentRevocations: *enrollmentRevocations, + TLSCertPath: *tlsCert, + TLSKeyPath: *tlsKey, + ClientCAPath: *clientCA, + OperatorAuthPath: *operatorAuth, + HostedOperatorAuthPath: *hostedOperatorAuth, + OIDCJWKSOperatorAuthPath: *oidcJWKSOperatorAuth, + SAMLOperatorAuthPath: *samlOperatorAuth, + RdevAssetsDir: *rdevAssetsDir, + AutoBuildRdevAssets: *autoBuildRdevAssets, + LayeredAssetManifestPath: *layeredAssetsManifest, + LayeredReleaseRootPublicKey: *layeredReleaseRoot, + LayeredReleaseVersion: *layeredReleaseVersion, + RdevHostWindowsAMD64Path: *rdevHostWindowsAMD64, + RdevBootstrapWindowsAMD64Path: *rdevBootstrapWindowsAMD64, + RdevBootstrapWindowsARM64Path: *rdevBootstrapWindowsARM64, + RdevBootstrapDarwinARM64Path: *rdevBootstrapDarwinARM64, + RdevBootstrapDarwinAMD64Path: *rdevBootstrapDarwinAMD64, + RdevBootstrapLinuxAMD64Path: *rdevBootstrapLinuxAMD64, + RdevBootstrapLinuxARM64Path: *rdevBootstrapLinuxARM64, }) case "storage": if len(args) < 2 { @@ -9542,6 +9548,7 @@ func (a App) release(args []string) error { out := fs.String("out", "", "empty output directory for the release candidate") version := fs.String("version", "", "release version, for example v0.1.0") gatewayURL := fs.String("gateway-url", "", "default gateway URL to include in Skillkit install docs") + targetPlatform := fs.String("target-platform", "", "target platform for platform-specific release assets, for example windows/amd64") artifacts := fs.String("artifacts", "", "comma-separated built artifact paths to stage, sign, and include") requiredArtifacts := fs.String("require-artifacts", "", "comma-separated artifact ids that must be present in the release bundle") keyPath := fs.String("key", "", "Ed25519 release signing key file") @@ -9549,7 +9556,7 @@ func (a App) release(args []string) error { if err := fs.Parse(args[1:]); err != nil { return err } - return a.releasePrepareCandidate(*sourceRoot, *out, *version, *gatewayURL, splitCapabilities(*artifacts), splitCapabilities(*requiredArtifacts), *keyPath, *keyID) + return a.releasePrepareCandidate(*sourceRoot, *out, *version, *gatewayURL, *targetPlatform, splitCapabilities(*artifacts), splitCapabilities(*requiredArtifacts), *keyPath, *keyID) case "verify-candidate": fs := flag.NewFlagSet("release verify-candidate", flag.ContinueOnError) fs.SetOutput(a.Stderr) @@ -9707,7 +9714,7 @@ func (a App) gatewayServeDev(opts gatewayServeOptions) error { } if ready && strings.TrimSpace(assetsDir) != "" { opts.RdevAssetsDir = assetsDir - _, _ = fmt.Fprintf(a.Stderr, "rdev gateway dev auto-built rdev helper assets at %s\n", assetsDir) + _, _ = fmt.Fprintf(a.Stderr, "rdev gateway dev auto-built rdev-bootstrap assets at %s\n", assetsDir) } else { _, _ = fmt.Fprintf(a.Stderr, "rdev gateway dev warning: target bootstrap self-repair assets are not all ready; use rdev support-session connect --start or --rdev-assets-dir for one-command target setup\n") } @@ -9740,37 +9747,146 @@ func (a App) gatewayServeDev(opts gatewayServeOptions) error { func gatewayAssetConfig(opts gatewayServeOptions) httpapi.AssetConfig { assets := httpapi.AssetConfig{} if dir := strings.TrimSpace(opts.RdevAssetsDir); dir != "" { - assets.RdevWindowsAMD64Path = filepath.Join(dir, "rdev-windows-amd64.exe") - assets.RdevDarwinARM64Path = filepath.Join(dir, "rdev-darwin-arm64") - assets.RdevDarwinAMD64Path = filepath.Join(dir, "rdev-darwin-amd64") - assets.RdevLinuxAMD64Path = filepath.Join(dir, "rdev-linux-amd64") - assets.RdevLinuxARM64Path = filepath.Join(dir, "rdev-linux-arm64") + assets.RdevHostWindowsAMD64Path = filepath.Join(dir, "rdev-host-windows-amd64.exe") + candidateManifestPath := filepath.Join(dir, "layered-assets.json") + candidateCorePath := filepath.Join(dir, "assets", "rdev-host-windows-amd64.exe") + if pathExists(candidateManifestPath) { + assets.LayeredAssetManifestPath = candidateManifestPath + } + if pathExists(candidateCorePath) { + assets.RdevHostWindowsAMD64Path = candidateCorePath + } + assets.RdevBootstrapWindowsAMD64Path = filepath.Join(dir, "rdev-bootstrap-windows-amd64.exe") + assets.RdevBootstrapWindowsARM64Path = filepath.Join(dir, "rdev-bootstrap-windows-arm64.exe") + assets.RdevBootstrapDarwinARM64Path = filepath.Join(dir, "rdev-bootstrap-darwin-arm64") + assets.RdevBootstrapDarwinAMD64Path = filepath.Join(dir, "rdev-bootstrap-darwin-amd64") + assets.RdevBootstrapLinuxAMD64Path = filepath.Join(dir, "rdev-bootstrap-linux-amd64") + assets.RdevBootstrapLinuxARM64Path = filepath.Join(dir, "rdev-bootstrap-linux-arm64") } - if strings.TrimSpace(opts.RdevWindowsAMD64Path) != "" { - assets.RdevWindowsAMD64Path = opts.RdevWindowsAMD64Path + assets.LayeredReleaseRootPublicKey = strings.TrimSpace(opts.LayeredReleaseRootPublicKey) + assets.LayeredReleaseVersion = strings.TrimSpace(opts.LayeredReleaseVersion) + if strings.TrimSpace(opts.LayeredAssetManifestPath) != "" { + assets.LayeredAssetManifestPath = opts.LayeredAssetManifestPath } - if strings.TrimSpace(opts.RdevDarwinARM64Path) != "" { - assets.RdevDarwinARM64Path = opts.RdevDarwinARM64Path + if strings.TrimSpace(opts.RdevHostWindowsAMD64Path) != "" { + assets.RdevHostWindowsAMD64Path = opts.RdevHostWindowsAMD64Path } - if strings.TrimSpace(opts.RdevDarwinAMD64Path) != "" { - assets.RdevDarwinAMD64Path = opts.RdevDarwinAMD64Path + if strings.TrimSpace(opts.RdevBootstrapWindowsAMD64Path) != "" { + assets.RdevBootstrapWindowsAMD64Path = opts.RdevBootstrapWindowsAMD64Path } - if strings.TrimSpace(opts.RdevLinuxAMD64Path) != "" { - assets.RdevLinuxAMD64Path = opts.RdevLinuxAMD64Path + if strings.TrimSpace(opts.RdevBootstrapWindowsARM64Path) != "" { + assets.RdevBootstrapWindowsARM64Path = opts.RdevBootstrapWindowsARM64Path } - if strings.TrimSpace(opts.RdevLinuxARM64Path) != "" { - assets.RdevLinuxARM64Path = opts.RdevLinuxARM64Path + if strings.TrimSpace(opts.RdevBootstrapDarwinARM64Path) != "" { + assets.RdevBootstrapDarwinARM64Path = opts.RdevBootstrapDarwinARM64Path + } + if strings.TrimSpace(opts.RdevBootstrapDarwinAMD64Path) != "" { + assets.RdevBootstrapDarwinAMD64Path = opts.RdevBootstrapDarwinAMD64Path + } + if strings.TrimSpace(opts.RdevBootstrapLinuxAMD64Path) != "" { + assets.RdevBootstrapLinuxAMD64Path = opts.RdevBootstrapLinuxAMD64Path + } + if strings.TrimSpace(opts.RdevBootstrapLinuxARM64Path) != "" { + assets.RdevBootstrapLinuxARM64Path = opts.RdevBootstrapLinuxARM64Path } return assets } +func supportSessionAssetConfig(workDir string, layered supportSessionLayeredCandidateOptions, now time.Time) (httpapi.AssetConfig, error) { + binDir := filepath.Join(strings.TrimSpace(workDir), "bin") + assets := httpapi.AssetConfig{ + RdevBootstrapWindowsAMD64Path: filepath.Join(binDir, "rdev-bootstrap-windows-amd64.exe"), + RdevBootstrapWindowsARM64Path: filepath.Join(binDir, "rdev-bootstrap-windows-arm64.exe"), + RdevBootstrapDarwinARM64Path: filepath.Join(binDir, "rdev-bootstrap-darwin-arm64"), + RdevBootstrapDarwinAMD64Path: filepath.Join(binDir, "rdev-bootstrap-darwin-amd64"), + RdevBootstrapLinuxAMD64Path: filepath.Join(binDir, "rdev-bootstrap-linux-amd64"), + RdevBootstrapLinuxARM64Path: filepath.Join(binDir, "rdev-bootstrap-linux-arm64"), + } + values := []string{strings.TrimSpace(layered.AssetsDir), strings.TrimSpace(layered.RootPublicKey), strings.TrimSpace(layered.ExpectedVersion)} + configured := 0 + for _, value := range values { + if value != "" { + configured++ + } + } + if configured == 0 { + return assets, nil + } + if configured != len(values) { + return httpapi.AssetConfig{}, fmt.Errorf("layered release directory, root public key, and version must be configured together") + } + candidateDir, err := canonicalPathThroughExistingAncestor(layered.AssetsDir) + if err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("resolve layered release directory: %w", err) + } + if err := tunnel.ValidateProtectedDirectory(candidateDir); err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("unsafe layered release directory: %w", err) + } + manifestPath := filepath.Join(candidateDir, "layered-assets.json") + corePath := filepath.Join(candidateDir, "assets", "rdev-host-windows-amd64.exe") + for _, path := range []string{manifestPath, corePath} { + info, err := os.Lstat(path) + if err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("inspect layered release asset: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return httpapi.AssetConfig{}, fmt.Errorf("layered release assets must be regular files") + } + } + root, err := parseRootPublicKey(layered.RootPublicKey) + if err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("parse layered release root: %w", err) + } + content, err := os.ReadFile(manifestPath) + if err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("read layered release manifest: %w", err) + } + manifest, err := release.DecodeLayeredAssetManifest(content) + if err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("decode layered release manifest: %w", err) + } + if err := release.VerifyLayeredAssetManifest(manifest, root, now); err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("verify layered release manifest: %w", err) + } + if manifest.Version != layered.ExpectedVersion { + return httpapi.AssetConfig{}, fmt.Errorf("layered release version does not match expected version") + } + asset, err := release.SelectLayeredAsset(manifest, "windows/amd64", "core-runtime", nil) + if err != nil || asset.ID != "rdev-host-windows-amd64" || asset.RelativePath != "assets/rdev-host-windows-amd64.exe" { + return httpapi.AssetConfig{}, fmt.Errorf("layered release manifest does not contain the required Windows core runtime") + } + file, err := os.Open(corePath) + if err != nil { + return httpapi.AssetConfig{}, fmt.Errorf("open Windows core runtime: %w", err) + } + digest := sha256.New() + size, copyErr := io.Copy(digest, file) + closeErr := file.Close() + if copyErr != nil || closeErr != nil { + return httpapi.AssetConfig{}, fmt.Errorf("hash Windows core runtime") + } + if size != asset.SizeBytes || asset.SHA256 != "sha256:"+hex.EncodeToString(digest.Sum(nil)) { + return httpapi.AssetConfig{}, fmt.Errorf("Windows core runtime does not match signed manifest") + } + assets.LayeredAssetManifestPath = manifestPath + assets.LayeredReleaseRootPublicKey = strings.TrimSpace(layered.RootPublicKey) + assets.LayeredReleaseVersion = strings.TrimSpace(layered.ExpectedVersion) + assets.RdevHostWindowsAMD64Path = corePath + return assets, nil +} + func gatewayHasExplicitAssetConfig(opts gatewayServeOptions) bool { return strings.TrimSpace(opts.RdevAssetsDir) != "" || - strings.TrimSpace(opts.RdevWindowsAMD64Path) != "" || - strings.TrimSpace(opts.RdevDarwinARM64Path) != "" || - strings.TrimSpace(opts.RdevDarwinAMD64Path) != "" || - strings.TrimSpace(opts.RdevLinuxAMD64Path) != "" || - strings.TrimSpace(opts.RdevLinuxARM64Path) != "" + strings.TrimSpace(opts.LayeredAssetManifestPath) != "" || + strings.TrimSpace(opts.LayeredReleaseRootPublicKey) != "" || + strings.TrimSpace(opts.LayeredReleaseVersion) != "" || + strings.TrimSpace(opts.RdevHostWindowsAMD64Path) != "" || + strings.TrimSpace(opts.RdevBootstrapWindowsAMD64Path) != "" || + strings.TrimSpace(opts.RdevBootstrapWindowsARM64Path) != "" || + strings.TrimSpace(opts.RdevBootstrapDarwinARM64Path) != "" || + strings.TrimSpace(opts.RdevBootstrapDarwinAMD64Path) != "" || + strings.TrimSpace(opts.RdevBootstrapLinuxAMD64Path) != "" || + strings.TrimSpace(opts.RdevBootstrapLinuxARM64Path) != "" } func prepareGatewayAutoBuildRdevAssets(ctx context.Context, addr string) (string, bool, error) { @@ -10834,7 +10950,7 @@ func (a App) releaseVerifyBundle(bundlePath, rootPublicKey string, requiredArtif return nil } -func (a App) releasePrepareCandidate(sourceRoot, outPath, version, gatewayURL string, artifactPaths, requiredArtifacts []string, keyPath, keyID string) error { +func (a App) releasePrepareCandidate(sourceRoot, outPath, version, gatewayURL, targetPlatform string, artifactPaths, requiredArtifacts []string, keyPath, keyID string) error { if outPath == "" { return fmt.Errorf("out is required") } @@ -10856,6 +10972,7 @@ func (a App) releasePrepareCandidate(sourceRoot, outPath, version, gatewayURL st OutDir: outPath, Version: version, GatewayURL: gatewayURL, + TargetPlatform: targetPlatform, ArtifactPaths: artifactPaths, RequiredArtifacts: requiredArtifacts, Key: key, @@ -12091,7 +12208,7 @@ Usage: rdev acceptance fresh-agent-support-session --out fresh-agent-support-session rdev acceptance managed-mac --out acceptance-run --repo . rdev acceptance managed-mac-service --out service-plan --gateway https://api.example.com/v1 --ticket-code ABCD-1234 --repo . --release-bundle /opt/rdev/release-bundle.json --release-root-public-key release-root:... --release-require-artifacts rdev,rdev-host,rdev-verify - rdev acceptance windows-temporary --out windows-plan --gateway https://api.example.com/v1 --ticket-code ABCD-1234 --download-url https://agent.example/rdev-host.exe --expected-sha256 --release-bundle-url https://agent.example/release-bundle.json --release-root-public-key release-root:... --verifier-download-url https://agent.example/rdev-verify.exe --verifier-sha256 + rdev acceptance windows-temporary --out windows-plan --handoff-archive ./Windows-ConnectionEntry.zip rdev acceptance windows-managed-service --out windows-service-plan --binary 'C:\Program Files\rdev\rdev.exe' --gateway https://api.example.com/v1 --ticket-code ABCD-1234 --release-bundle 'C:\Program Files\rdev\release-bundle.json' --release-root-public-key release-root:... --release-require-artifacts rdev.exe,rdev-host.exe,rdev-verify.exe rdev acceptance linux-managed-service --out linux-service-plan --binary /opt/rdev/rdev --gateway https://api.example.com/v1 --ticket-code ABCD-1234 --release-bundle /opt/rdev/release-bundle.json --release-root-public-key release-root:... --release-require-artifacts rdev,rdev-host,rdev-verify rdev acceptance verify --report acceptance-run/report.json @@ -12108,7 +12225,7 @@ Usage: rdev acceptance scaffold-post-release-download --post-release-install-dir post-release-install --out post-release-download-evidence-input rdev acceptance post-release-evidence-status --scaffold post-release-download-evidence-input rdev acceptance package-managed-mac-service --plan service-plan/service-plan.json --out mac-service-evidence --review-transcript review.txt --start-transcript start.txt --inspect-transcript inspect.txt --logs launchagent.log --release-gate release-gate.json --audit audit.jsonl --reconnect reconnect.txt --managed-report managed-mac/report.json --stop-transcript stop.txt --uninstall-transcript uninstall.txt - rdev acceptance package-windows-temporary --plan windows-plan/windows-temporary-plan.json --out windows-evidence --transcript transcript.txt --release-verification rdev-verify.json --audit audit.jsonl --no-persistence-dir no-persistence --denial-probes-dir denial-probes + rdev acceptance package-windows-temporary --plan windows-plan/windows-temporary-plan.json --out windows-evidence --transcript transcript.txt --release-verification rdev-verify.json --audit audit.jsonl --no-persistence-dir no-persistence --denial-probes-dir denial-probes --cold-layered-run cold-layered-run.json --warm-layered-run warm-layered-run.json --layered-entry-evidence layered-entry-evidence.json rdev acceptance package-linux-managed-service --plan linux-service-plan/linux-managed-service-plan.json --out linux-evidence --start-transcript start.txt --status-transcript status.txt --logs journal.txt --release-gate release-gate.json --audit audit.jsonl --reconnect reconnect.txt --session-evidence-dir session-evidence --stop-transcript stop.txt --uninstall-transcript uninstall.txt rdev acceptance package-relay-adapter --relay-package relay-adapter --out relay-evidence --evidence-dir relay-evidence-input rdev acceptance package-hosted-provider-runtime --hosted-provider-package hosted-provider --out hosted-runtime-evidence --evidence-dir hosted-runtime-evidence-input diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index fdf3495..ae43ad0 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,6 +1,7 @@ package cli import ( + "archive/zip" "bytes" "context" "crypto/ed25519" @@ -33,6 +34,7 @@ import ( "time" "github.com/EitanWong/remote-dev-skillkit/internal/audit" + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd" "github.com/EitanWong/remote-dev-skillkit/internal/connectionrunner" "github.com/EitanWong/remote-dev-skillkit/internal/controlplane" "github.com/EitanWong/remote-dev-skillkit/internal/gateway" @@ -44,6 +46,7 @@ import ( "github.com/EitanWong/remote-dev-skillkit/internal/operatorauth" "github.com/EitanWong/remote-dev-skillkit/internal/policy" "github.com/EitanWong/remote-dev-skillkit/internal/protectedstore" + "github.com/EitanWong/remote-dev-skillkit/internal/release" "github.com/EitanWong/remote-dev-skillkit/internal/signing" "github.com/EitanWong/remote-dev-skillkit/internal/supportsession" "github.com/EitanWong/remote-dev-skillkit/internal/tunnel" @@ -2125,8 +2128,8 @@ func TestSupportSessionStatusTimeoutKeepsDownloadGuidance(t *testing.T) { "next_action":"Keep waiting for the download to finish; do not misdiagnose this as the target command not running.", "target_preconnect_summary":{ "status":"target-downloading", - "phase":"downloading-helper", - "agent_interpretation":"The target command reached the gateway and is downloading the helper; this is not disconnected or user inaction." + "phase":"downloading-core", + "agent_interpretation":"The target command reached the gateway and rdev-bootstrap is downloading the verified core runtime; this is not disconnected or user inaction." } }` + "\n")) })) @@ -2265,7 +2268,7 @@ func TestSupportSessionPlanStandardizesOneCommandConnection(t *testing.T) { t.Fatalf("auto authorization should be scoped and minimal: %#v", payload.AutoActivate) } startGateway := strings.Join(payload.Commands["start_gateway"], "\x00") - if !strings.Contains(startGateway, "--rdev-windows-amd64") || + if !strings.Contains(startGateway, "--rdev-bootstrap-windows-amd64") || !strings.Contains(startGateway, "--manifest-signing-key") || !strings.Contains(startGateway, "--state") { t.Fatalf("gateway plan should carry assets and durable state: %#v", payload.Commands["start_gateway"]) @@ -2669,7 +2672,7 @@ func TestSupportSessionPlanDefaultGatewayDoesNotUseWildcardURL(t *testing.T) { } } -func TestSupportSessionPrepareBuildsHelperAssetsForOneCommandTargets(t *testing.T) { +func TestSupportSessionPrepareBuildsBootstrapAssetsForOneCommandTargets(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer app := NewApp(&stdout, &stderr) @@ -2762,9 +2765,9 @@ func TestSupportSessionPrepareBuildsHelperAssetsForOneCommandTargets(t *testing. payload.AssetReport.DownloadBudgetBytes <= 0 || !payload.AssetReport.AllGzipWithinBudget || payload.AssetReport.BootstrapTargetBytes <= 0 || - !strings.Contains(payload.AssetReport.FirstConnectSizeStrategy, "gzip") || + !strings.Contains(payload.AssetReport.FirstConnectSizeStrategy, "1 MiB") || !strings.Contains(payload.AssetReport.FirstConnectSizeStrategy, "rdev-bootstrap") || - len(payload.AssetReport.Assets) != 5 { + len(payload.AssetReport.Assets) != 6 { t.Fatalf("unexpected prepare payload: %#v", payload) } for _, asset := range payload.AssetReport.Assets { @@ -2912,7 +2915,7 @@ func TestSupportSessionCreateReturnsReadyTargetCommandAndWatcher(t *testing.T) { t.Fatalf("expected configured relay continuity policy, got %#v", payload.ConnectionContinuityPolicy) } if payload.TargetBootstrapRequirements.SchemaVersion != "rdev.support-session-target-bootstrap-requirements.v1" || - !slices.Contains(payload.TargetBootstrapRequirements.RequiredAssets, "rdev-windows-amd64.exe") || + !slices.Contains(payload.TargetBootstrapRequirements.RequiredAssets, "rdev-bootstrap-windows-amd64.exe") || !slices.Contains(payload.TargetBootstrapRequirements.StandardFix, "rdev support-session connect --start") || !slices.Contains(payload.TargetBootstrapRequirements.Forbidden, "using ExecutionPolicy Bypass") { t.Fatalf("expected Windows bootstrap requirements and standard recovery, got %#v", payload.TargetBootstrapRequirements) @@ -2920,7 +2923,7 @@ func TestSupportSessionCreateReturnsReadyTargetCommandAndWatcher(t *testing.T) { if payload.TargetBootstrapReadiness.SchemaVersion != "rdev.support-session-target-bootstrap-readiness.v1" || payload.TargetBootstrapReadiness.AllReady || !strings.Contains(payload.TargetBootstrapReadiness.AgentRule, "support-session connect --start") { - t.Fatalf("expected create to report missing gateway helper assets, got %#v", payload.TargetBootstrapReadiness) + t.Fatalf("expected create to report missing gateway bootstrap assets, got %#v", payload.TargetBootstrapReadiness) } if payload.UserHandoff.SchemaVersion != "rdev.support-session-user-handoff.v1" || payload.UserHandoff.CopyPasteKind != "windows" || @@ -4463,19 +4466,276 @@ func TestWriteJSONFile0600TightensExistingFilePermissions(t *testing.T) { func TestGatewayAssetConfigUsesDirectoryWithExplicitOverrides(t *testing.T) { dir := filepath.Join(t.TempDir(), "bin") - override := filepath.Join(t.TempDir(), "custom-rdev.exe") + override := filepath.Join(t.TempDir(), "custom-rdev-bootstrap.exe") assets := gatewayAssetConfig(gatewayServeOptions{ - RdevAssetsDir: dir, - RdevWindowsAMD64Path: override, + RdevAssetsDir: dir, + RdevBootstrapWindowsAMD64Path: override, }) - if assets.RdevWindowsAMD64Path != override { - t.Fatalf("explicit Windows helper should override assets dir: %#v", assets) + if assets.RdevBootstrapWindowsAMD64Path != override { + t.Fatalf("explicit Windows bootstrap should override assets dir: %#v", assets) } - if assets.RdevDarwinARM64Path != filepath.Join(dir, "rdev-darwin-arm64") || - assets.RdevDarwinAMD64Path != filepath.Join(dir, "rdev-darwin-amd64") || - assets.RdevLinuxAMD64Path != filepath.Join(dir, "rdev-linux-amd64") || - assets.RdevLinuxARM64Path != filepath.Join(dir, "rdev-linux-arm64") { - t.Fatalf("assets dir should populate platform helper paths: %#v", assets) + if assets.RdevHostWindowsAMD64Path != filepath.Join(dir, "rdev-host-windows-amd64.exe") || + assets.RdevBootstrapDarwinARM64Path != filepath.Join(dir, "rdev-bootstrap-darwin-arm64") || + assets.RdevBootstrapDarwinAMD64Path != filepath.Join(dir, "rdev-bootstrap-darwin-amd64") || + assets.RdevBootstrapLinuxAMD64Path != filepath.Join(dir, "rdev-bootstrap-linux-amd64") || + assets.RdevBootstrapLinuxARM64Path != filepath.Join(dir, "rdev-bootstrap-linux-arm64") { + t.Fatalf("assets dir should populate platform bootstrap paths: %#v", assets) + } +} + +func TestGatewayServeHelpListsBootstrapOnlyConnectionAssets(t *testing.T) { + var stderr bytes.Buffer + app := NewApp(io.Discard, &stderr) + if err := app.Run(context.Background(), []string{"gateway", "serve", "-h"}); err == nil { + t.Fatal("gateway serve help did not stop after printing flags") + } + help := stderr.String() + for _, required := range []string{ + "-rdev-bootstrap-windows-amd64", + "-rdev-bootstrap-windows-arm64", + "-rdev-bootstrap-darwin-amd64", + "-rdev-bootstrap-darwin-arm64", + "-rdev-bootstrap-linux-amd64", + "-rdev-bootstrap-linux-arm64", + } { + if !strings.Contains(help, required) { + t.Fatalf("gateway serve help omitted bootstrap asset flag %q:\n%s", required, help) + } + } + for _, forbidden := range []string{ + "-rdev-windows-amd64", + "-rdev-darwin-amd64", + "-rdev-darwin-arm64", + "-rdev-linux-amd64", + "-rdev-linux-arm64", + } { + if strings.Contains(help, forbidden) { + t.Fatalf("gateway serve help still exposes legacy full-helper asset flag %q:\n%s", forbidden, help) + } + } +} + +func TestConnectionEntryHelpExposesBootstrapCommandOnly(t *testing.T) { + for _, subcommand := range []string{"plan", "run"} { + var stderr bytes.Buffer + app := NewApp(io.Discard, &stderr) + _ = app.Run(context.Background(), []string{"connection-entry", subcommand, "-h"}) + help := stderr.String() + if !strings.Contains(help, "-bootstrap-command") { + t.Fatalf("connection-entry %s help omitted bootstrap command:\n%s", subcommand, help) + } + for _, forbidden := range []string{"-rdev-command", "host serve"} { + if strings.Contains(help, forbidden) { + t.Fatalf("connection-entry %s help contains legacy path %q:\n%s", subcommand, forbidden, help) + } + } + } + var inviteHelp bytes.Buffer + app := NewApp(io.Discard, &inviteHelp) + _ = app.Run(context.Background(), []string{"invite", "create", "-h"}) + if strings.Contains(inviteHelp.String(), "-rdev-command") { + t.Fatalf("invite create help exposes legacy target executable selector:\n%s", inviteHelp.String()) + } +} + +func TestGatewayServeConfiguresLayeredCandidateAssets(t *testing.T) { + candidateDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(candidateDir, "assets"), 0o700); err != nil { + t.Fatal(err) + } + writeFileForCLITest(t, filepath.Join(candidateDir, "layered-assets.json"), "{}\n") + writeFileForCLITest(t, filepath.Join(candidateDir, "assets", "rdev-host-windows-amd64.exe"), "host\n") + override := filepath.Join(t.TempDir(), "layered-assets.json") + + assets := gatewayAssetConfig(gatewayServeOptions{RdevAssetsDir: candidateDir}) + if assets.LayeredAssetManifestPath != filepath.Join(candidateDir, "layered-assets.json") { + t.Fatalf("candidate assets dir should configure layered manifest: %#v", assets) + } + if assets.RdevHostWindowsAMD64Path != filepath.Join(candidateDir, "assets", "rdev-host-windows-amd64.exe") { + t.Fatalf("candidate assets dir should configure staged Windows core runtime: %#v", assets) + } + + assets = gatewayAssetConfig(gatewayServeOptions{ + RdevAssetsDir: candidateDir, + LayeredAssetManifestPath: override, + }) + if assets.LayeredAssetManifestPath != override { + t.Fatalf("explicit layered manifest should override candidate assets dir: %#v", assets) + } + + legacyCandidateDir := t.TempDir() + writeFileForCLITest(t, filepath.Join(legacyCandidateDir, "layered-assets.json"), "{}\n") + writeFileForCLITest(t, filepath.Join(legacyCandidateDir, "rdev-host-windows-amd64.exe"), "host\n") + assets = gatewayAssetConfig(gatewayServeOptions{RdevAssetsDir: legacyCandidateDir}) + if assets.LayeredAssetManifestPath != filepath.Join(legacyCandidateDir, "layered-assets.json") || + assets.RdevHostWindowsAMD64Path != filepath.Join(legacyCandidateDir, "rdev-host-windows-amd64.exe") { + t.Fatalf("explicit canonical candidate layout should retain the flat core and configure its manifest: %#v", assets) + } + + app := NewApp(&bytes.Buffer{}, &bytes.Buffer{}) + err := app.Run(context.Background(), []string{ + "gateway", "serve", + "--dev", + "--state", filepath.Join(t.TempDir(), "state.json"), + "--rdev-assets-dir", candidateDir, + "--layered-assets-manifest", override, + }) + if err == nil || !strings.Contains(err.Error(), "persistent storage requires --signing-key") { + t.Fatalf("gateway serve did not accept layered candidate asset flags: %v", err) + } +} + +func TestSupportSessionAssetConfigServesVerifiedPreSignedWindowsCore(t *testing.T) { + fixture := newSupportSessionLayeredCandidateFixture(t) + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "bin"), 0o700); err != nil { + t.Fatal(err) + } + + assets, err := supportSessionAssetConfig(workDir, supportSessionLayeredCandidateOptions{ + AssetsDir: fixture.dir, + RootPublicKey: fixture.rootPublicKey, + ExpectedVersion: fixture.version, + }, fixture.now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + candidateDir, err := canonicalPathThroughExistingAncestor(fixture.dir) + if err != nil { + t.Fatal(err) + } + if assets.LayeredAssetManifestPath != filepath.Join(candidateDir, "layered-assets.json") || + assets.RdevHostWindowsAMD64Path != filepath.Join(candidateDir, "assets", "rdev-host-windows-amd64.exe") || + assets.RdevBootstrapWindowsARM64Path != filepath.Join(workDir, "bin", "rdev-bootstrap-windows-arm64.exe") { + t.Fatalf("layered support-session asset config = %#v", assets) + } + if _, err := os.Stat(filepath.Join(workDir, "bin", "layered-assets.json")); !os.IsNotExist(err) { + t.Fatalf("support session unexpectedly synthesized a layered manifest: %v", err) + } + + server := httpapi.NewServer(gateway.NewMemoryGateway()) + server.Assets = assets + for requestPath, want := range map[string][]byte{ + "/layered-assets.json": fixture.manifestContent, + "/assets/rdev-host-windows-amd64.exe": fixture.coreContent, + } { + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, requestPath, nil)) + if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), want) { + t.Fatalf("served layered asset %q = %d %q, want 200 and %q", requestPath, rec.Code, rec.Body.Bytes(), want) + } + } +} + +func TestSupportSessionAssetConfigRejectsTamperedPreSignedWindowsCore(t *testing.T) { + fixture := newSupportSessionLayeredCandidateFixture(t) + if err := os.WriteFile(fixture.corePath, []byte("tampered runtime\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := supportSessionAssetConfig(t.TempDir(), supportSessionLayeredCandidateOptions{ + AssetsDir: fixture.dir, + RootPublicKey: fixture.rootPublicKey, + ExpectedVersion: fixture.version, + }, fixture.now.Add(time.Minute)) + if err == nil || !strings.Contains(err.Error(), "does not match signed manifest") { + t.Fatalf("tampered Windows core runtime error = %v", err) + } +} + +type supportSessionLayeredCandidateFixture struct { + dir string + manifestPath string + corePath string + rootPublicKey string + version string + now time.Time + manifestContent []byte + coreContent []byte +} + +func newSupportSessionLayeredCandidateFixture(t *testing.T) supportSessionLayeredCandidateFixture { + t.Helper() + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + assetsDir := filepath.Join(dir, "assets") + if err := os.MkdirAll(assetsDir, 0o700); err != nil { + t.Fatal(err) + } + coreContent := []byte("pre-signed Windows core runtime\n") + corePath := filepath.Join(assetsDir, "rdev-host-windows-amd64.exe") + if err := os.WriteFile(corePath, coreContent, 0o600); err != nil { + t.Fatal(err) + } + key, err := signing.Generate("support-session-release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, time.July, 16, 10, 0, 0, 0, time.UTC) + version := "v0.2.0-support-session-test" + sum := sha256.Sum256(coreContent) + manifest, err := release.SignLayeredAssetManifest(release.LayeredAssetManifest{ + SchemaVersion: release.LayeredAssetManifestSchemaVersion, + Version: version, + GeneratedAt: now, + ExpiresAt: now.Add(time.Hour), + Assets: []release.LayeredAsset{{ + ID: "rdev-host-windows-amd64", + Platform: "windows/amd64", + Kind: "core-runtime", + RelativePath: "assets/rdev-host-windows-amd64.exe", + SHA256: "sha256:" + hex.EncodeToString(sum[:]), + SizeBytes: int64(len(coreContent)), + }}, + }, key) + if err != nil { + t.Fatal(err) + } + manifestContent, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + manifestContent = append(manifestContent, '\n') + manifestPath := filepath.Join(dir, "layered-assets.json") + if err := os.WriteFile(manifestPath, manifestContent, 0o600); err != nil { + t.Fatal(err) + } + return supportSessionLayeredCandidateFixture{ + dir: dir, + manifestPath: manifestPath, + corePath: corePath, + rootPublicKey: encodeRootPublicKey(key.ID, key.PublicKey), + version: version, + now: now, + manifestContent: manifestContent, + coreContent: coreContent, + } +} + +func TestGatewayServeConfiguresRdevHostWindowsAMD64Asset(t *testing.T) { + coreRuntime := filepath.Join(t.TempDir(), "rdev-host-windows-amd64.exe") + opts := gatewayServeOptions{RdevHostWindowsAMD64Path: coreRuntime} + assets := gatewayAssetConfig(opts) + if assets.RdevHostWindowsAMD64Path != coreRuntime { + t.Fatalf("gateway did not configure Windows host core runtime: %#v", assets) + } + if !gatewayHasExplicitAssetConfig(opts) { + t.Fatal("Windows host core runtime should count as explicit gateway asset configuration") + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + app := NewApp(&stdout, &stderr) + err := app.Run(context.Background(), []string{ + "gateway", "serve", + "--dev", + "--state", filepath.Join(t.TempDir(), "state.json"), + "--rdev-host-windows-amd64", coreRuntime, + }) + if err == nil || !strings.Contains(err.Error(), "persistent storage requires --signing-key") { + t.Fatalf("gateway serve did not recognize Windows host runtime flag: %v", err) } } @@ -4485,18 +4745,18 @@ func TestGatewayServeDevAutoBuildsRdevAssets(t *testing.T) { t.Fatal(err) } if !ready { - t.Fatalf("expected gateway helper assets to be ready in %s", assetsDir) + t.Fatalf("expected gateway bootstrap assets to be ready in %s", assetsDir) } assets := gatewayAssetConfig(gatewayServeOptions{RdevAssetsDir: assetsDir}) - if assets.RdevWindowsAMD64Path == "" { - t.Fatalf("expected auto-built Windows helper asset path, got %#v", assets) + if assets.RdevBootstrapWindowsAMD64Path == "" { + t.Fatalf("expected auto-built Windows bootstrap asset path, got %#v", assets) } - info, err := os.Stat(assets.RdevWindowsAMD64Path) + info, err := os.Stat(assets.RdevBootstrapWindowsAMD64Path) if err != nil { t.Fatal(err) } if info.IsDir() || info.Size() == 0 { - t.Fatalf("expected non-empty Windows helper asset, got %#v", info) + t.Fatalf("expected non-empty Windows bootstrap asset, got %#v", info) } } @@ -4920,14 +5180,14 @@ func TestInviteCreateUsesGatewayAndOutputsAgentPlan(t *testing.T) { if payload.Transport != "wss" || payload.TransportPlan.Mode != "wss" || len(payload.TransportPlan.Candidates) != 1 { t.Fatalf("explicit WSS invite should keep WSS-only plan: %#v", payload.TransportPlan) } - if !strings.Contains(payload.ManifestURL, "/v1/tickets/") || !strings.Contains(payload.HostCommand, "host serve --manifest-url") || !strings.Contains(payload.HostCommand, "--transport wss") { - t.Fatalf("invite should include manifest URL and WSS host command: %#v", payload) + if !strings.Contains(payload.ManifestURL, "/v1/tickets/") || !strings.Contains(payload.HostCommand, "/bootstrap.sh") || !strings.Contains(payload.HostCommand, "rdev-bootstrap") || strings.Contains(payload.HostCommand, "host serve") { + t.Fatalf("invite should include manifest URL and bootstrap-only host command: %#v", payload) } - if payload.ManifestRootPublicKey == "" || !strings.Contains(payload.HostCommand, "--manifest-root-public-key") { - t.Fatalf("invite should carry the manifest root in host_command: %#v", payload) + if payload.ManifestRootPublicKey == "" { + t.Fatalf("invite should carry the signed manifest root: %#v", payload) } - if len(payload.TransportPlan.Candidates) == 0 || !strings.Contains(payload.TransportPlan.Candidates[0].HostCommand, "--manifest-root-public-key") { - t.Fatalf("transport candidates should carry manifest root: %#v", payload.TransportPlan.Candidates) + if len(payload.TransportPlan.Candidates) == 0 || payload.TransportPlan.Candidates[0].HostCommand != payload.HostCommand { + t.Fatalf("transport candidates should share the bootstrap attempt command: %#v", payload.TransportPlan.Candidates) } if len(payload.HumanNextActions) == 0 || len(payload.AgentNextActions) == 0 || len(payload.ConnectivityChecks) == 0 { t.Fatalf("invite should split human and agent actions: %#v", payload) @@ -5085,18 +5345,19 @@ func TestInviteCreateDefaultsToAutoTransportPlan(t *testing.T) { TransportPlan struct { Mode string `json:"mode"` Candidates []struct { - Transport string `json:"transport"` + Transport string `json:"transport"` + HostCommand string `json:"host_command"` } `json:"candidates"` } `json:"transport_plan"` } if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { t.Fatalf("invalid invite JSON: %v\n%s", err, stdout.String()) } - if payload.Transport != "auto" || !strings.Contains(payload.HostCommand, "--transport auto") { - t.Fatalf("expected auto host command, got %#v", payload) + if payload.Transport != "auto" || !strings.Contains(payload.HostCommand, "rdev-bootstrap") || !strings.Contains(payload.HostCommand, "/bootstrap.sh") { + t.Fatalf("expected auto bootstrap command, got %#v", payload) } - if payload.ManifestRootPublicKey == "" || !strings.Contains(payload.HostCommand, "--manifest-root-public-key") { - t.Fatalf("expected auto host command to include manifest root, got %#v", payload) + if payload.ManifestRootPublicKey == "" { + t.Fatalf("expected invite to include manifest root, got %#v", payload) } if payload.TransportPlan.Mode != "auto" || len(payload.TransportPlan.Candidates) != 3 { t.Fatalf("expected three transport candidates, got %#v", payload.TransportPlan) @@ -5104,8 +5365,13 @@ func TestInviteCreateDefaultsToAutoTransportPlan(t *testing.T) { if payload.TransportPlan.Candidates[0].Transport != "wss" || payload.TransportPlan.Candidates[1].Transport != "long-poll" || payload.TransportPlan.Candidates[2].Transport != "poll" { t.Fatalf("unexpected transport fallback order: %#v", payload.TransportPlan.Candidates) } - if len(payload.FallbackCommands) != 2 || !strings.Contains(payload.FallbackCommands[0], "--transport long-poll") || !strings.Contains(payload.FallbackCommands[1], "--transport poll") || !strings.Contains(payload.FallbackCommands[0], "--manifest-root-public-key") { - t.Fatalf("expected long-poll and poll fallback commands, got %#v", payload.FallbackCommands) + if len(payload.FallbackCommands) != 0 { + t.Fatalf("bootstrap attempt must own transport fallback, got %#v", payload.FallbackCommands) + } + for _, candidate := range payload.TransportPlan.Candidates { + if candidate.HostCommand != payload.HostCommand { + t.Fatalf("transport candidates must share one bootstrap command: %#v", payload.TransportPlan.Candidates) + } } } @@ -5161,8 +5427,20 @@ func TestConnectionEntryPlanMaterializesGenericPackagePlan(t *testing.T) { t.Fatal(err) } - bootstrap := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(bootstrap, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { + releaseDir := t.TempDir() + keyPath := filepath.Join(releaseDir, "release-root.json") + releaseRoot := signReleaseArtifactWithCLIForTest(t, releaseDir, keyPath, "rdev-bootstrap.exe", "bootstrap-binary") + bootstrapPath := filepath.Join(releaseDir, "rdev-bootstrap.exe") + bootstrapManifestPath := bootstrapPath + ".rdev-release.json" + releaseKey, _, err := signing.LoadOrCreate(keyPath, "release-root") + if err != nil { + t.Fatal(err) + } + bootstrapManifest, err := release.SignArtifactForRelease(bootstrapPath, releaseKey, time.Now(), "v0.2.0", "windows/amd64") + if err != nil { + t.Fatal(err) + } + if err := release.WriteManifest(bootstrapManifestPath, bootstrapManifest); err != nil { t.Fatal(err) } outDir := filepath.Join(t.TempDir(), "entry") @@ -5173,14 +5451,13 @@ func TestConnectionEntryPlanMaterializesGenericPackagePlan(t *testing.T) { "--invite-json", inviteOut.String(), "--out", outDir, "--target-os", "windows", + "--target-arch", "amd64", "--ownership", "third-party", - "--windows-bootstrap-script", bootstrap, - "--windows-host-download-url", "https://agent.example.com/rdev-host.exe", - "--windows-host-sha256", strings.Repeat("a", 64), - "--release-bundle-url", "https://agent.example.com/release-bundle.json", - "--release-root-public-key", "release-root:" + strings.Repeat("b", 43), - "--windows-verifier-download-url", "https://agent.example.com/rdev-verify.exe", - "--windows-verifier-sha256", strings.Repeat("c", 64), + "--windows-bootstrap-binary", bootstrapPath, + "--windows-bootstrap-release-manifest", bootstrapManifestPath, + "--layered-assets-manifest-url", "https://agent.example.com/layered-assets.json", + "--layered-release-version", "v0.2.0", + "--release-root-public-key", releaseRoot, }); err != nil { t.Fatal(err) } @@ -5210,15 +5487,20 @@ func TestConnectionEntryPlanMaterializesGenericPackagePlan(t *testing.T) { if !payload.OK { t.Fatalf("expected connection entry plan ok, got %s", stdout.String()) } + launcherPath := payload.EntryPackagePlan.LauncherPath + if !filepath.IsAbs(launcherPath) { + launcherPath = filepath.Join(outDir, filepath.FromSlash(launcherPath)) + } if payload.EntryPackagePlan.SchemaVersion != "rdev.connection-entry.package-plan.v1" || payload.EntryPackagePlan.TargetOS != "windows" || payload.EntryPackagePlan.SessionMode != string(model.HostModeAttendedTemporary) || - payload.EntryPackagePlan.PlatformPlanKind != "windows-temporary-acceptance-plan" || - !fileExistsForCLITest(payload.EntryPackagePlan.LauncherPath) { - t.Fatalf("expected generic entry package plan wrapping Windows temporary plan, got %#v", payload.EntryPackagePlan) + payload.EntryPackagePlan.PlatformPlanKind != "windows-layered-handoff" || + !fileExistsForCLITest(launcherPath) { + t.Fatalf("expected generic entry package plan wrapping the Windows layered handoff, got %#v", payload.EntryPackagePlan) } - if !slices.Contains(payload.EntryPackagePlan.AgentOnlyParameters, "ticket_code") || - !slices.Contains(payload.EntryPackagePlan.AgentOnlyParameters, "manifest_root_public_key") { + if !slices.Contains(payload.EntryPackagePlan.AgentOnlyParameters, "manifest_url") || + !slices.Contains(payload.EntryPackagePlan.AgentOnlyParameters, "manifest_root_public_key") || + !slices.Contains(payload.EntryPackagePlan.AgentOnlyParameters, "layered_assets_manifest_url") { t.Fatalf("expected raw connection parameters to be agent-only, got %#v", payload.EntryPackagePlan.AgentOnlyParameters) } if payload.Plan.ConnectionEntryName != "Connection Entry" || @@ -5235,6 +5517,121 @@ func TestConnectionEntryPlanMaterializesGenericPackagePlan(t *testing.T) { } } +func TestWindowsConnectionEntryRequiresLayeredBootstrapHandoff(t *testing.T) { + gw := gateway.NewMemoryGateway() + server := httptest.NewServer(httpapi.NewServer(gw).Handler()) + defer server.Close() + + var inviteOut bytes.Buffer + if err := NewApp(&inviteOut, &bytes.Buffer{}).Run(context.Background(), []string{ + "invite", "create", + "--gateway", server.URL, + "--reason", "repair target host", + "--transport", "long-poll", + }); err != nil { + t.Fatal(err) + } + + releaseDir := t.TempDir() + keyPath := filepath.Join(releaseDir, "release-root.json") + releaseRoot := signReleaseArtifactWithCLIForTest(t, releaseDir, keyPath, "rdev-bootstrap.exe", "bootstrap-binary") + bootstrapPath := filepath.Join(releaseDir, "rdev-bootstrap.exe") + bootstrapManifestPath := bootstrapPath + ".rdev-release.json" + releaseKey, _, err := signing.LoadOrCreate(keyPath, "release-root") + if err != nil { + t.Fatal(err) + } + bootstrapManifest, err := release.SignArtifactForRelease(bootstrapPath, releaseKey, time.Now(), "v0.2.0", "windows/amd64") + if err != nil { + t.Fatal(err) + } + if err := release.WriteManifest(bootstrapManifestPath, bootstrapManifest); err != nil { + t.Fatal(err) + } + + t.Run("layered prerequisites", func(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "entry") + var stdout bytes.Buffer + err := NewApp(&stdout, &bytes.Buffer{}).Run(context.Background(), []string{ + "connection-entry", "plan", + "--invite-json", inviteOut.String(), + "--out", outDir, + "--target-os", "windows", + "--target-arch", "amd64", + "--ownership", "third-party", + "--windows-bootstrap-binary", bootstrapPath, + "--windows-bootstrap-release-manifest", bootstrapManifestPath, + "--layered-assets-manifest-url", "https://downloads.example.com/layered-assets.json", + "--layered-release-version", "v0.2.0", + "--release-root-public-key", releaseRoot, + }) + if err != nil { + t.Fatalf("expected valid layered inputs to materialize: %v\n%s", err, stdout.String()) + } + var payload struct { + OK bool `json:"ok"` + EntryPackagePlan struct { + PackageMode string `json:"package_mode"` + PlatformPlanKind string `json:"platform_plan_kind"` + LauncherPath string `json:"launcher_path"` + } `json:"entry_package_plan"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("invalid layered connection entry output: %v\n%s", err, stdout.String()) + } + launcherPath := payload.EntryPackagePlan.LauncherPath + if !filepath.IsAbs(launcherPath) { + launcherPath = filepath.Join(outDir, filepath.FromSlash(launcherPath)) + } + if !payload.OK || + !strings.Contains(payload.EntryPackagePlan.PackageMode, "layered") || + !strings.Contains(payload.EntryPackagePlan.PlatformPlanKind, "layered") || + !fileExistsForCLITest(launcherPath) { + t.Fatalf("expected layered Windows handoff selection, got %#v\n%s", payload.EntryPackagePlan, stdout.String()) + } + launcher, err := os.ReadFile(launcherPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(launcher), "--expected-release-version") || !strings.Contains(string(launcher), "v0.2.0") { + t.Fatalf("layered launcher did not pin the expected release version:\n%s", launcher) + } + }) + + t.Run("missing layered prerequisites", func(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "entry") + var stdout bytes.Buffer + err := NewApp(&stdout, &bytes.Buffer{}).Run(context.Background(), []string{ + "connection-entry", "plan", + "--invite-json", inviteOut.String(), + "--out", outDir, + "--target-os", "windows", + "--target-arch", "amd64", + "--ownership", "third-party", + "--release-root-public-key", releaseRoot, + }) + if err != nil { + t.Fatal(err) + } + var payload struct { + OK bool `json:"ok"` + EntryPackagePlan struct { + PlatformPlanKind string `json:"platform_plan_kind"` + LauncherPath string `json:"launcher_path"` + } `json:"entry_package_plan"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("invalid legacy connection entry output: %v\n%s", err, stdout.String()) + } + if payload.OK || payload.EntryPackagePlan.PlatformPlanKind != "connection-entry-runner" { + t.Fatalf("expected missing layered inputs to retain only the failed bootstrap runner plan, got %#v\n%s", payload.EntryPackagePlan, stdout.String()) + } + if fileExistsForCLITest(filepath.Join(outDir, "windows-temporary")) || strings.Contains(stdout.String(), "windows-temporary") { + t.Fatalf("missing layered inputs generated a legacy Windows helper path:\n%s", stdout.String()) + } + }) +} + func TestConnectionEntryRunWritesRunnerResultEvidence(t *testing.T) { gw := gateway.NewMemoryGateway() server := httptest.NewServer(httpapi.NewServer(gw).Handler()) @@ -5260,6 +5657,9 @@ func TestConnectionEntryRunWritesRunnerResultEvidence(t *testing.T) { "--out", outDir, "--target-os", "linux", "--ownership", "third-party", + "--release-root-public-key", "release-root:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "--layered-assets-manifest-url", "https://api.example.com/layered-assets.json", + "--layered-release-version", "v2.0.0-test", }); err != nil { t.Fatal(err) } @@ -5297,7 +5697,7 @@ func TestConnectionEntryRunWritesRunnerResultEvidence(t *testing.T) { } if result.SchemaVersion != "rdev.connection-entry.runner-result.v1" || result.SelectedPath != "native-direct-gateway" || - len(result.HostServeArgs) == 0 || + len(result.BootstrapArgs) == 0 || result.Executed { t.Fatalf("unexpected runner evidence: %#v\ncli output: %s", result, runOut.String()) } @@ -5349,6 +5749,8 @@ func TestConnectionEntryPlanMaterializesManagedLinuxPackagePlan(t *testing.T) { "--managed-binary", "/opt/rdev/rdev", "--release-bundle", "/opt/rdev/release-bundle.json", "--release-root-public-key", "release-root:" + strings.Repeat("b", 43), + "--layered-assets-manifest-url", "https://api.example.com/layered-assets.json", + "--layered-release-version", "v2.0.0-test", "--release-bundle-required-artifacts", "rdev,rdev-host,rdev-verify", }); err != nil { t.Fatal(err) @@ -9775,6 +10177,7 @@ func TestReleasePrepareCandidateStagesBundleAndSkillkit(t *testing.T) { } rdev := writeCLIArtifactForTest(t, artifactsDir, "rdev", "cli-binary") host := writeCLIArtifactForTest(t, artifactsDir, "rdev-host.exe", "host-binary") + bootstrap := writeCLIArtifactForTest(t, artifactsDir, "rdev-bootstrap.exe", "bootstrap-binary") verifier := writeCLIArtifactForTest(t, artifactsDir, "rdev-verify.exe", "verify-binary") out := filepath.Join(dir, "candidate") keyPath := filepath.Join(dir, "release-root.json") @@ -9787,7 +10190,7 @@ func TestReleasePrepareCandidateStagesBundleAndSkillkit(t *testing.T) { "--out", out, "--version", "v0.1.0", "--gateway-url", "https://api.example.com/v1", - "--artifacts", strings.Join([]string{rdev, host, verifier}, ","), + "--artifacts", strings.Join([]string{rdev, host, bootstrap, verifier}, ","), "--require-artifacts", "rdev-host.exe,rdev-verify.exe", "--key", keyPath, "--key-id", "release-root", @@ -9815,6 +10218,44 @@ func TestReleasePrepareCandidateStagesBundleAndSkillkit(t *testing.T) { } } +func TestReleasePrepareCandidateStagesLayeredWindowsCoreForTargetPlatform(t *testing.T) { + dir := t.TempDir() + artifactsDir := filepath.Join(dir, "artifacts") + if err := os.MkdirAll(artifactsDir, 0o700); err != nil { + t.Fatal(err) + } + hostBytes := "windows-host-binary" + host := writeCLIArtifactForTest(t, artifactsDir, "rdev-host.exe", hostBytes) + bootstrap := writeCLIArtifactForTest(t, artifactsDir, "rdev-bootstrap.exe", "bootstrap-binary") + verifier := writeCLIArtifactForTest(t, artifactsDir, "rdev-verify.exe", "verify-binary") + out := filepath.Join(dir, "candidate") + var stdout bytes.Buffer + err := NewApp(&stdout, &bytes.Buffer{}).Run(context.Background(), []string{ + "release", "prepare-candidate", + "--source-root", filepath.Join("..", ".."), + "--out", out, + "--version", "v0.2.0", + "--artifacts", strings.Join([]string{host, bootstrap, verifier}, ","), + "--target-platform", "windows/amd64", + "--key", filepath.Join(dir, "release-root.json"), + }) + if err != nil { + t.Fatalf("expected target platform to reach candidate preparation: %v\n%s", err, stdout.String()) + } + stagedPath := filepath.Join(out, "assets", "rdev-host-windows-amd64.exe") + staged, err := os.ReadFile(stagedPath) + if err != nil { + t.Fatalf("expected staged Windows core runtime: %v", err) + } + if string(staged) != hostBytes { + t.Fatalf("staged Windows core runtime changed bytes: got %q", staged) + } + if !fileExistsForCLITest(filepath.Join(out, "layered-assets.json")) || + !fileExistsForCLITest(filepath.Join(out, "connection-entry-release.zip")) { + t.Fatalf("expected layered candidate plus archive fallback in %s", out) + } +} + func TestReleaseVerifyCandidateChecksPreparedCandidate(t *testing.T) { dir := t.TempDir() artifactsDir := filepath.Join(dir, "artifacts") @@ -9823,6 +10264,7 @@ func TestReleaseVerifyCandidateChecksPreparedCandidate(t *testing.T) { } rdev := writeCLIArtifactForTest(t, artifactsDir, "rdev", "cli-binary") host := writeCLIArtifactForTest(t, artifactsDir, "rdev-host.exe", "host-binary") + bootstrap := writeCLIArtifactForTest(t, artifactsDir, "rdev-bootstrap.exe", "bootstrap-binary") verifier := writeCLIArtifactForTest(t, artifactsDir, "rdev-verify.exe", "verify-binary") out := filepath.Join(dir, "candidate") keyPath := filepath.Join(dir, "release-root.json") @@ -9833,7 +10275,7 @@ func TestReleaseVerifyCandidateChecksPreparedCandidate(t *testing.T) { "--out", out, "--version", "v0.1.0", "--gateway-url", "https://api.example.com/v1", - "--artifacts", strings.Join([]string{rdev, host, verifier}, ","), + "--artifacts", strings.Join([]string{rdev, host, bootstrap, verifier}, ","), "--require-artifacts", "rdev-host.exe,rdev-verify.exe", "--key", keyPath, "--key-id", "release-root", @@ -10415,35 +10857,22 @@ func main() { func TestAcceptanceWindowsTemporaryPlan(t *testing.T) { out := filepath.Join(t.TempDir(), "windows-temporary") - script := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } + archivePath := writeLayeredHandoffArchiveForCLITest(t) var stdout bytes.Buffer app := NewApp(&stdout, &bytes.Buffer{}) if err := app.Run(context.Background(), []string{ "acceptance", "windows-temporary", "--out", out, - "--gateway", "https://api.example.com/v1", - "--ticket-code", "ABCD-1234", - "--download-url", "https://agent.example.com/rdev-host.exe", - "--expected-sha256", strings.Repeat("a", 64), - "--bootstrap-script", script, - "--manifest-url", "https://agent.example.com/j/ABCD-1234/manifest", - "--manifest-root-public-key", "manifest-root:abc", - "--release-manifest-url", "https://agent.example.com/rdev-host.exe.rdev-release.json", - "--release-root-public-key", "release-root:abc", - "--verifier-download-url", "https://agent.example.com/rdev-verify.exe", - "--verifier-sha256", strings.Repeat("b", 64), + "--handoff-archive", archivePath, }); err != nil { t.Fatal(err) } var payload struct { - OK bool `json:"ok"` - Schema string `json:"schema"` - Plan string `json:"plan"` - Launcher string `json:"launcher"` - Commands []struct { + OK bool `json:"ok"` + Schema string `json:"schema"` + Plan string `json:"plan"` + HandoffArchive string `json:"handoff_archive"` + Commands []struct { Name string `json:"name"` Shell string `json:"shell"` } `json:"commands"` @@ -10461,7 +10890,7 @@ func TestAcceptanceWindowsTemporaryPlan(t *testing.T) { if payload.Schema != "rdev.acceptance.windows-temporary-plan.v1" { t.Fatalf("unexpected schema %q", payload.Schema) } - for _, path := range []string{payload.Plan, payload.Launcher} { + for _, path := range []string{filepath.Join(out, payload.Plan), filepath.Join(out, payload.HandoffArchive)} { if _, err := os.Stat(path); err != nil { t.Fatalf("expected generated path %s: %v", path, err) } @@ -10470,40 +10899,28 @@ func TestAcceptanceWindowsTemporaryPlan(t *testing.T) { if !strings.Contains(output, "run_foreground_temporary_host") || !strings.Contains(output, "Get-ScheduledTask") { t.Fatalf("expected foreground and no-persistence commands, got %s", output) } - launcher := readFileForTest(t, payload.Launcher) - if strings.Contains(launcher, "-ReleaseBundleRequiredArtifacts") { - t.Fatalf("manifest-only launcher should not contain bundle args: %s", launcher) + if strings.Contains(strings.ToLower(stdout.String()), "rdev-host.exe") || !strings.Contains(stdout.String(), "rdev-bootstrap layered-run") { + t.Fatalf("acceptance output must remain bootstrap-only: %s", stdout.String()) } } func TestAcceptanceWindowsTemporaryBundlePlan(t *testing.T) { out := filepath.Join(t.TempDir(), "windows-temporary") - script := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } + archivePath := writeLayeredHandoffArchiveForCLITest(t) var stdout bytes.Buffer app := NewApp(&stdout, &bytes.Buffer{}) if err := app.Run(context.Background(), []string{ "acceptance", "windows-temporary", "--out", out, - "--gateway", "https://api.example.com/v1", - "--ticket-code", "ABCD-1234", - "--download-url", "https://agent.example.com/rdev-host.exe", - "--expected-sha256", strings.Repeat("a", 64), - "--bootstrap-script", script, - "--release-bundle-url", "https://agent.example.com/release-bundle.json", - "--release-root-public-key", "release-root:abc", - "--verifier-download-url", "https://agent.example.com/rdev-verify.exe", - "--verifier-sha256", strings.Repeat("b", 64), + "--handoff-archive", archivePath, }); err != nil { t.Fatal(err) } var payload struct { - OK bool `json:"ok"` - Schema string `json:"schema"` - Plan string `json:"plan"` - Launcher string `json:"launcher"` + OK bool `json:"ok"` + Schema string `json:"schema"` + Plan string `json:"plan"` + HandoffArchive string `json:"handoff_archive"` } if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { t.Fatalf("invalid json: %v\n%s", err, stdout.String()) @@ -10514,15 +10931,14 @@ func TestAcceptanceWindowsTemporaryBundlePlan(t *testing.T) { if payload.Schema != "rdev.acceptance.windows-temporary-plan.v1" { t.Fatalf("unexpected schema %q", payload.Schema) } - launcher := readFileForTest(t, payload.Launcher) - if !strings.Contains(launcher, "-ReleaseBundleUrl 'https://agent.example.com/release-bundle.json'") { - t.Fatalf("expected release bundle launcher arg, got %s", launcher) + if payload.HandoffArchive != "Windows-ConnectionEntry.zip" { + t.Fatalf("unexpected handoff archive %q", payload.HandoffArchive) } var verifyStdout bytes.Buffer verifyApp := NewApp(&verifyStdout, &bytes.Buffer{}) if err := verifyApp.Run(context.Background(), []string{ "acceptance", "verify-windows-temporary", - "--plan", payload.Plan, + "--plan", filepath.Join(out, payload.Plan), }); err != nil { t.Fatalf("expected bundle verification to pass: %v\n%s", err, verifyStdout.String()) } @@ -10774,30 +11190,19 @@ func TestAcceptanceVerifyLinuxManagedServicePlanRejectsTampering(t *testing.T) { func TestAcceptanceVerifyWindowsTemporaryPlan(t *testing.T) { out := filepath.Join(t.TempDir(), "windows-temporary") - script := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } + archivePath := writeLayeredHandoffArchiveForCLITest(t) var stdout bytes.Buffer app := NewApp(&stdout, &bytes.Buffer{}) if err := app.Run(context.Background(), []string{ "acceptance", "windows-temporary", "--out", out, - "--gateway", "https://api.example.com/v1", - "--ticket-code", "ABCD-1234", - "--download-url", "https://agent.example.com/rdev-host.exe", - "--expected-sha256", strings.Repeat("a", 64), - "--bootstrap-script", script, - "--release-manifest-url", "https://agent.example.com/rdev-host.exe.rdev-release.json", - "--release-root-public-key", "release-root:abc", - "--verifier-download-url", "https://agent.example.com/rdev-verify.exe", - "--verifier-sha256", strings.Repeat("b", 64), + "--handoff-archive", archivePath, }); err != nil { t.Fatal(err) } var payload struct { - Plan string `json:"plan"` - Launcher string `json:"launcher"` + Plan string `json:"plan"` + HandoffArchive string `json:"handoff_archive"` } if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { t.Fatalf("invalid json: %v\n%s", err, stdout.String()) @@ -10807,7 +11212,7 @@ func TestAcceptanceVerifyWindowsTemporaryPlan(t *testing.T) { verifyApp := NewApp(&verifyStdout, &bytes.Buffer{}) if err := verifyApp.Run(context.Background(), []string{ "acceptance", "verify-windows-temporary", - "--plan", payload.Plan, + "--plan", filepath.Join(out, payload.Plan), }); err != nil { t.Fatalf("expected verification to pass: %v\n%s", err, verifyStdout.String()) } @@ -10815,19 +11220,19 @@ func TestAcceptanceVerifyWindowsTemporaryPlan(t *testing.T) { t.Fatalf("expected ok verification, got %s", verifyStdout.String()) } - if err := os.WriteFile(payload.Launcher, []byte("Set-ExecutionPolicy Bypass\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(out, payload.HandoffArchive), []byte("tampered\n"), 0o600); err != nil { t.Fatal(err) } var tamperedStdout bytes.Buffer tamperedApp := NewApp(&tamperedStdout, &bytes.Buffer{}) err := tamperedApp.Run(context.Background(), []string{ "acceptance", "verify-windows-temporary", - "--plan", payload.Plan, + "--plan", filepath.Join(out, payload.Plan), }) if err == nil { t.Fatalf("expected tampered verification to fail: %s", tamperedStdout.String()) } - if !strings.Contains(tamperedStdout.String(), `"ok": false`) || !strings.Contains(tamperedStdout.String(), "launcher_has_no_forbidden_side_effects") { + if !strings.Contains(tamperedStdout.String(), `"ok": false`) || !strings.Contains(tamperedStdout.String(), "handoff_archive") { t.Fatalf("expected structured tampered failure, got %s", tamperedStdout.String()) } } @@ -10835,24 +11240,13 @@ func TestAcceptanceVerifyWindowsTemporaryPlan(t *testing.T) { func TestAcceptancePackageWindowsTemporary(t *testing.T) { root := t.TempDir() planOut := filepath.Join(root, "windows-temporary") - script := filepath.Join(root, "windows-temporary.ps1") - if err := os.WriteFile(script, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) - } + archivePath := writeLayeredHandoffArchiveForCLITest(t) var planStdout bytes.Buffer planApp := NewApp(&planStdout, &bytes.Buffer{}) if err := planApp.Run(context.Background(), []string{ "acceptance", "windows-temporary", "--out", planOut, - "--gateway", "https://api.example.com/v1", - "--ticket-code", "ABCD-1234", - "--download-url", "https://agent.example.com/rdev-host.exe", - "--expected-sha256", strings.Repeat("a", 64), - "--bootstrap-script", script, - "--release-bundle-url", "https://agent.example.com/release-bundle.json", - "--release-root-public-key", "release-root:abc", - "--verifier-download-url", "https://agent.example.com/rdev-verify.exe", - "--verifier-sha256", strings.Repeat("b", 64), + "--handoff-archive", archivePath, }); err != nil { t.Fatal(err) } @@ -10862,47 +11256,111 @@ func TestAcceptancePackageWindowsTemporary(t *testing.T) { if err := json.Unmarshal(planStdout.Bytes(), &planPayload); err != nil { t.Fatalf("invalid plan json: %v\n%s", err, planStdout.String()) } + planPayload.Plan = filepath.Join(planOut, planPayload.Plan) fakeGitHubToken := "ghp_" + "abcdefghijklmnopqrstuvwx" transcriptPath, releaseVerificationPath, auditPath, noPersistenceDir, denialProbesDir := writeWindowsPackageEvidenceForCLITest(t, root, `{"ok": true, "token": "`+fakeGitHubToken+`"}`) + coldLayeredRunPath := writeLayeredRunReportForCLITest(t, root, "cold-layered-run.json", false) + warmLayeredRunPath := writeLayeredRunReportForCLITest(t, root, "warm-layered-run.json", true) + layeredEntryEvidencePath := writeLayeredEntryEvidenceForCLITest(t, root, planPayload.Plan) + packageArgs := func(out, coldLayeredRun, warmLayeredRun string) []string { + args := []string{ + "acceptance", "package-windows-temporary", + "--plan", planPayload.Plan, + "--out", out, + "--transcript", transcriptPath, + "--release-verification", releaseVerificationPath, + "--audit", auditPath, + "--no-persistence-dir", noPersistenceDir, + "--denial-probes-dir", denialProbesDir, + "--layered-entry-evidence", layeredEntryEvidencePath, + } + if coldLayeredRun != "" { + args = append(args, "--cold-layered-run", coldLayeredRun) + } + if warmLayeredRun != "" { + args = append(args, "--warm-layered-run", warmLayeredRun) + } + return args + } + + t.Run("packages cold and warm layered run evidence", func(t *testing.T) { + out := filepath.Join(root, "windows-evidence") + var stdout bytes.Buffer + app := NewApp(&stdout, &bytes.Buffer{}) + if err := app.Run(context.Background(), packageArgs(out, coldLayeredRunPath, warmLayeredRunPath)); err != nil { + t.Fatalf("expected package command to pass: %v\n%s", err, stdout.String()) + } + var payload struct { + OK bool `json:"ok"` + Schema string `json:"schema"` + Package string `json:"package"` + Files []struct { + Path string `json:"path"` + Kind string `json:"kind"` + } `json:"files"` + RedactionRuleCounts map[string]int `json:"redaction_rule_counts"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("invalid package json: %v\n%s", err, stdout.String()) + } + if !payload.OK { + t.Fatalf("expected package ok, got %s", stdout.String()) + } + if payload.Schema != "rdev.acceptance-package.windows-temporary.v1" { + t.Fatalf("unexpected schema %q", payload.Schema) + } + if filepath.IsAbs(payload.Package) { + t.Fatalf("public package output leaked an absolute path: %q", payload.Package) + } + if _, err := os.Stat(filepath.Join(out, payload.Package)); err != nil { + t.Fatalf("expected package manifest: %v", err) + } + if payload.RedactionRuleCounts["github_token"] != 1 { + t.Fatalf("expected github_token redaction count, got %#v", payload.RedactionRuleCounts) + } + filesByKind := make(map[string]string, len(payload.Files)) + for _, file := range payload.Files { + filesByKind[file.Kind] = file.Path + } + for kind, wantPath := range map[string]string{ + "cold-layered-run": "evidence/cold-layered-run.json", + "warm-layered-run": "evidence/warm-layered-run.json", + } { + if got := filesByKind[kind]; got != wantPath { + t.Fatalf("packaged %s path = %q, want %q; files = %#v", kind, got, wantPath, payload.Files) + } + if _, err := os.Stat(filepath.Join(out, filepath.FromSlash(wantPath))); err != nil { + t.Fatalf("expected packaged %s evidence: %v", kind, err) + } + } + }) - var stdout bytes.Buffer - app := NewApp(&stdout, &bytes.Buffer{}) - if err := app.Run(context.Background(), []string{ - "acceptance", "package-windows-temporary", - "--plan", planPayload.Plan, - "--out", filepath.Join(root, "windows-evidence"), - "--transcript", transcriptPath, - "--release-verification", releaseVerificationPath, - "--audit", auditPath, - "--no-persistence-dir", noPersistenceDir, - "--denial-probes-dir", denialProbesDir, - }); err != nil { - t.Fatalf("expected package command to pass: %v\n%s", err, stdout.String()) - } - var payload struct { - OK bool `json:"ok"` - Schema string `json:"schema"` - Package string `json:"package"` - Files []struct { - Path string `json:"path"` - Kind string `json:"kind"` - } `json:"files"` - RedactionRuleCounts map[string]int `json:"redaction_rule_counts"` - } - if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { - t.Fatalf("invalid package json: %v\n%s", err, stdout.String()) - } - if !payload.OK { - t.Fatalf("expected package ok, got %s", stdout.String()) - } - if payload.Schema != "rdev.acceptance-package.windows-temporary.v1" { - t.Fatalf("unexpected schema %q", payload.Schema) - } - if _, err := os.Stat(payload.Package); err != nil { - t.Fatalf("expected package manifest: %v", err) - } - if payload.RedactionRuleCounts["github_token"] != 1 { - t.Fatalf("expected github_token redaction count, got %#v", payload.RedactionRuleCounts) + for _, tc := range []struct { + name string + missingEvidence string + coldLayeredRun string + warmLayeredRun string + }{ + {name: "requires cold layered run evidence", missingEvidence: "cold", warmLayeredRun: warmLayeredRunPath}, + {name: "requires warm layered run evidence", missingEvidence: "warm", coldLayeredRun: coldLayeredRunPath}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + app := NewApp(&stdout, &stderr) + err := app.Run(context.Background(), packageArgs( + filepath.Join(root, "windows-evidence-missing-"+tc.missingEvidence), + tc.coldLayeredRun, + tc.warmLayeredRun, + )) + if err == nil { + t.Fatalf("expected package command to reject missing %s layered run evidence", tc.missingEvidence) + } + failure := strings.ToLower(err.Error() + "\n" + stdout.String() + "\n" + stderr.String()) + if !strings.Contains(failure, tc.missingEvidence) { + t.Fatalf("expected missing %s evidence failure, got %s", tc.missingEvidence, failure) + } + }) } } @@ -11651,6 +12109,49 @@ func copyFileForCLITest(t *testing.T, source, dest string) { writeFileForCLITest(t, dest, string(content)) } +func writeLayeredHandoffArchiveForCLITest(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "Windows-ConnectionEntry.zip") + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + writer := zip.NewWriter(file) + files := map[string]string{ + "Start-ConnectionEntry.ps1": "& $PSScriptRoot\\rdev-bootstrap.exe layered-run\n", + "Start-ConnectionEntry.cmd": "@echo off\r\n%~dp0rdev-bootstrap.exe layered-run\r\n", + "rdev-bootstrap.exe": "bootstrap fixture", + "rdev-bootstrap.exe.rdev-release.json": "{}", + "rdev-bootstrap.exe.sha256": strings.Repeat("a", 64), + "windows-layered-verification.json": "{}", + "ARCHIVE-RECOVERY.txt": "rdev-bootstrap.exe layered-run\n", + } + for _, name := range []string{ + "Start-ConnectionEntry.ps1", + "Start-ConnectionEntry.cmd", + "rdev-bootstrap.exe", + "rdev-bootstrap.exe.rdev-release.json", + "rdev-bootstrap.exe.sha256", + "windows-layered-verification.json", + "ARCHIVE-RECOVERY.txt", + } { + entry, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write([]byte(files[name])); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return path +} + func signReleaseArtifactWithCLIForTest(t *testing.T, dir, keyPath, name, content string) string { t.Helper() artifactPath := filepath.Join(dir, name) @@ -11790,6 +12291,85 @@ func writeWindowsPackageEvidenceForCLITest(t *testing.T, root, releaseVerificati return transcriptPath, releaseVerificationPath, auditPath, noPersistenceDir, denialProbesDir } +func writeLayeredRunReportForCLITest(t *testing.T, root, name string, fromCache bool) string { + t.Helper() + report := bootstrapcmd.LayeredRunReport{ + SchemaVersion: bootstrapcmd.LayeredRunReportSchemaVersion, + AssetID: "rdev-host-windows-amd64", + FromCache: fromCache, + Resumed: false, + Bytes: 4096, + Stages: []bootstrapcmd.LayeredRunStage{ + {Name: "manifest-fetch", DurationMS: 12}, + {Name: "signature-verification", DurationMS: 3}, + {Name: "runtime-download", DurationMS: 24}, + {Name: "runtime-launch-preparation", DurationMS: 2}, + }, + } + content, err := json.MarshalIndent(report, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, name) + if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func writeLayeredEntryEvidenceForCLITest(t *testing.T, root, planPath string) string { + t.Helper() + content, err := os.ReadFile(planPath) + if err != nil { + t.Fatal(err) + } + var plan struct { + HandoffZIPSizeBytes int64 `json:"handoff_archive_size_bytes"` + HandoffZIPSHA256 string `json:"handoff_archive_sha256"` + } + if err := json.Unmarshal(content, &plan); err != nil { + t.Fatal(err) + } + evidence := map[string]any{ + "schema_version": "rdev.acceptance.windows-layered-entry-evidence.v1", + "windows_release": "11", + "architecture": "amd64", + "handoff_zip_size_bytes": plan.HandoffZIPSizeBytes, + "handoff_zip_sha256": plan.HandoffZIPSHA256, + "selected_launcher": "powershell", + "fallback_attempts": []string{"powershell"}, + "core_start_count": 1, + "network_bytes": 4096, + "registration_duration_ms": 750, + "cache_hit": false, + "range_interrupted": true, + "range_resumed": true, + "range_bytes": 2048, + "private_acl": true, + "unc_rejected": true, + "reparse_rejected": true, + "defender_lock_verified": true, + "active_route_failed": true, + "route_reselected": true, + "registration_count": 1, + "session_identity_stable": true, + "event_cursor_stable": true, + "archive_recovery_executed": false, + "bootstrap_only": true, + "persistence_residue": []string{}, + "cleanup_complete": true, + } + evidenceContent, err := json.MarshalIndent(evidence, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "layered-entry-evidence.json") + if err := os.WriteFile(path, append(evidenceContent, '\n'), 0o600); err != nil { + t.Fatal(err) + } + return path +} + func writeNamedFilesForTest(t *testing.T, dir string, names []string) { t.Helper() if err := os.MkdirAll(dir, 0o700); err != nil { diff --git a/internal/cli/coverage_surface_test.go b/internal/cli/coverage_surface_test.go index 2c09345..6d87874 100644 --- a/internal/cli/coverage_surface_test.go +++ b/internal/cli/coverage_surface_test.go @@ -440,14 +440,14 @@ func TestCLIGatewayConfigurationCoverage(t *testing.T) { t.Fatal("incomplete TLS configuration was accepted") } assets := gatewayAssetConfig(gatewayServeOptions{ - RdevAssetsDir: "/assets", - RdevWindowsAMD64Path: "/custom/windows.exe", - RdevDarwinARM64Path: "/custom/darwin-arm64", - RdevDarwinAMD64Path: "/custom/darwin-amd64", - RdevLinuxAMD64Path: "/custom/linux-amd64", - RdevLinuxARM64Path: "/custom/linux-arm64", + RdevAssetsDir: "/assets", + RdevBootstrapWindowsAMD64Path: "/custom/windows.exe", + RdevBootstrapDarwinARM64Path: "/custom/darwin-arm64", + RdevBootstrapDarwinAMD64Path: "/custom/darwin-amd64", + RdevBootstrapLinuxAMD64Path: "/custom/linux-amd64", + RdevBootstrapLinuxARM64Path: "/custom/linux-arm64", }) - if assets.RdevWindowsAMD64Path != "/custom/windows.exe" || assets.RdevDarwinARM64Path != "/custom/darwin-arm64" || assets.RdevLinuxARM64Path != "/custom/linux-arm64" { + if assets.RdevBootstrapWindowsAMD64Path != "/custom/windows.exe" || assets.RdevBootstrapDarwinARM64Path != "/custom/darwin-arm64" || assets.RdevBootstrapLinuxARM64Path != "/custom/linux-arm64" { t.Fatalf("gateway asset config = %#v", assets) } ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/connectionentry/plan.go b/internal/connectionentry/plan.go index 4382988..15e7818 100644 --- a/internal/connectionentry/plan.go +++ b/internal/connectionentry/plan.go @@ -3,6 +3,7 @@ package connectionentry import ( "context" "encoding/json" + "errors" "fmt" "net/url" "os" @@ -22,33 +23,31 @@ const ( EntryPackagePlanSchemaVersion = "rdev.connection-entry.package-plan.v1" ) +var connectionEntryMaterializationFailureHook func(phase string) error + type Options struct { - InviteJSON string - InvitePath string - OutDir string - TargetOS string - Ownership string - SessionMode string - ReleaseBundleURL string - ReleaseBundleRequiredArtifacts string - ReleaseBundlePath string - ReleaseRootPublicKey string - ManagedBinaryPath string - ManagedServiceName string - ManagedServiceLabel string - ManagedUnitName string - WindowsHostDownloadURL string - WindowsHostExpectedSHA256 string - WindowsVerifierDownloadURL string - WindowsVerifierExpectedSHA256 string - WindowsBootstrapScriptURL string - WindowsBootstrapScriptSHA256 string - WindowsBootstrapScriptPath string - HostName string - TargetArch string - RdevCommand string - Force bool - Now time.Time + InviteJSON string + InvitePath string + OutDir string + TargetOS string + Ownership string + SessionMode string + ReleaseBundleRequiredArtifacts string + ReleaseBundlePath string + ReleaseRootPublicKey string + ManagedBinaryPath string + ManagedServiceName string + ManagedServiceLabel string + ManagedUnitName string + WindowsBootstrapBinaryPath string + WindowsBootstrapReleaseManifestPath string + LayeredAssetsManifestURL string + LayeredReleaseVersion string + HostName string + TargetArch string + BootstrapCommand string + Force bool + Now time.Time } type Plan struct { @@ -100,6 +99,9 @@ type EntryPackagePlan struct { OK bool `json:"ok"` PlanPath string `json:"plan_path"` LauncherPath string `json:"launcher_path,omitempty"` + ArchivePath string `json:"archive_path,omitempty"` + ArchiveSHA256 string `json:"archive_sha256,omitempty"` + ArchiveSizeBytes int64 `json:"archive_size_bytes,omitempty"` PlatformPlanSchema string `json:"platform_plan_schema,omitempty"` PlatformPlanKind string `json:"platform_plan_kind,omitempty"` HumanEntryPoint string `json:"human_entry_point,omitempty"` @@ -122,7 +124,17 @@ type RunnerPlan struct { Checks []acceptance.Check `json:"checks"` } -func FromInvite(opts Options) (Plan, error) { +func FromInvite(opts Options) (_ Plan, resultErr error) { + var pendingArchive *pendingWindowsLayeredArchive + defer func() { + if pendingArchive == nil { + return + } + if err := pendingArchive.discard(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("discard pending Windows layered archive: %w", err)) + } + }() + invite, err := readInvite(opts) if err != nil { return Plan{}, err @@ -144,6 +156,7 @@ func FromInvite(opts Options) (Plan, error) { return Plan{}, err } outDir := strings.TrimSpace(opts.OutDir) + materializationDir := "" if outDir != "" { abs, err := filepath.Abs(outDir) if err != nil { @@ -153,6 +166,11 @@ func FromInvite(opts Options) (Plan, error) { if err := prepareOutDir(outDir); err != nil { return Plan{}, err } + materializationDir, err = createMaterializationStagingDir(outDir) + if err != nil { + return Plan{}, err + } + defer os.RemoveAll(materializationDir) } entryCommand := commandForOS(invite.ConnectionEntry.OneLineCommands, targetOS) plan := Plan{ @@ -179,15 +197,46 @@ func FromInvite(opts Options) (Plan, error) { RunnerManifestSchema: connectionrunner.ManifestSchemaVersion, } plan.Checks = buildChecks(invite, plan, entryCommand) - addRunnerPlan(&plan, invite, opts, outDir) + layeredHandoff, err := prepareWindowsLayeredHandoff(plan, invite, opts, materializationDir) + if err != nil { + return Plan{}, err + } + addRunnerPlan(&plan, invite, opts, materializationDir) if targetOS == "windows" && sessionMode == string(model.HostModeAttendedTemporary) { - addWindowsEntryPackagePlan(&plan, invite, opts, outDir) + if layeredHandoff == nil { + plan.MissingInputs = append(plan.MissingInputs, windowsLayeredMissingInputs(opts)...) + plan.Checks = append(plan.Checks, Check{Name: "windows_layered_handoff", Passed: false, Detail: "missing_inputs"}) + } else { + pendingArchive, err = materializeWindowsLayeredHandoff(&plan, layeredHandoff, materializationDir) + if err != nil { + return Plan{}, err + } + } } if sessionMode == string(model.HostModeManaged) { - addManagedEntryPackagePlan(&plan, invite, opts, outDir) + addManagedEntryPackagePlan(&plan, invite, opts, materializationDir) } if outDir != "" { - if err := writeMaterializedFiles(&plan); err != nil { + if err := rewriteMaterializedPaths(&plan, materializationDir, outDir); err != nil { + return Plan{}, err + } + if err := writeMaterializedFiles(&plan, materializationDir); err != nil { + return Plan{}, err + } + var finalize func() error + if pendingArchive != nil { + finalize = func() error { + if err := runConnectionEntryMaterializationFailureHook("before_windows_layered_archive_publish"); err != nil { + return pendingArchive.publicationFailure(fmt.Errorf("before Windows layered archive publication: %w", err)) + } + if _, err := pendingArchive.publish(filepath.Join(outDir, windowsLayeredArchiveName)); err != nil { + return err + } + pendingArchive = nil + return nil + } + } + if err := publishMaterializedFiles(materializationDir, outDir, finalize); err != nil { return Plan{}, err } } @@ -352,11 +401,13 @@ func agentSteps(invite agentinvite.Invite, sessionMode string) []string { } func buildChecks(invite agentinvite.Invite, plan Plan, entryCommand string) []Check { + entryURLValid := validURL(invite.ConnectionEntry.EntryURL) + manifestURLValid := validURL(invite.ManifestURL) checks := []Check{ - {Name: "entry_url", Passed: validURL(invite.ConnectionEntry.EntryURL), Detail: invite.ConnectionEntry.EntryURL}, - {Name: "manifest_url", Passed: validURL(invite.ManifestURL), Detail: invite.ManifestURL}, - {Name: "manifest_root_public_key", Passed: strings.TrimSpace(invite.ManifestRootPublicKey) != "", Detail: invite.ManifestRootPublicKey}, - {Name: "ticket_code", Passed: strings.TrimSpace(invite.Ticket.Code) != "", Detail: invite.Ticket.Code}, + {Name: "entry_url", Passed: entryURLValid, Detail: validationStatusDetail(invite.ConnectionEntry.EntryURL, entryURLValid)}, + {Name: "manifest_url", Passed: manifestURLValid, Detail: validationStatusDetail(invite.ManifestURL, manifestURLValid)}, + {Name: "manifest_root_public_key", Passed: strings.TrimSpace(invite.ManifestRootPublicKey) != "", Detail: presenceStatusDetail(invite.ManifestRootPublicKey)}, + {Name: "ticket_code", Passed: strings.TrimSpace(invite.Ticket.Code) != "", Detail: presenceStatusDetail(invite.Ticket.Code)}, {Name: "session_mode_selected", Passed: plan.SessionMode != "", Detail: plan.SessionMode}, {Name: "entry_command_available", Passed: entryCommand != "", Detail: plan.TargetOS}, {Name: "human_steps_no_raw_flag_assembly", Passed: true, Detail: "connection entry carries ticket/root/gateway/transport metadata for the Agent and package; humans receive a link, script, or package"}, @@ -367,6 +418,23 @@ func buildChecks(invite agentinvite.Invite, plan Plan, entryCommand string) []Ch return checks } +func validationStatusDetail(value string, valid bool) string { + if strings.TrimSpace(value) == "" { + return "missing" + } + if valid { + return "valid" + } + return "invalid" +} + +func presenceStatusDetail(value string) string { + if strings.TrimSpace(value) == "" { + return "missing" + } + return "present" +} + func validURL(value string) bool { parsed, err := url.ParseRequestURI(strings.TrimSpace(value)) return err == nil && parsed.Scheme != "" && parsed.Host != "" @@ -374,19 +442,22 @@ func validURL(value string) bool { func addRunnerPlan(plan *Plan, invite agentinvite.Invite, opts Options, outDir string) { pkg, err := connectionrunner.Build(connectionrunner.Options{ - Invite: invite, - OutDir: runnerOutDir(outDir), - TargetOS: plan.TargetOS, - TargetArch: opts.TargetArch, - SessionMode: plan.SessionMode, - RdevCommand: opts.RdevCommand, - HostName: opts.HostName, - GeneratedAt: plan.GeneratedAt, - WritePackage: outDir != "", + Invite: invite, + OutDir: runnerOutDir(outDir), + TargetOS: plan.TargetOS, + TargetArch: opts.TargetArch, + SessionMode: plan.SessionMode, + BootstrapCommand: opts.BootstrapCommand, + LayeredAssetsManifestURL: opts.LayeredAssetsManifestURL, + LayeredReleaseRootPublicKey: opts.ReleaseRootPublicKey, + LayeredReleaseVersion: opts.LayeredReleaseVersion, + HostName: opts.HostName, + GeneratedAt: plan.GeneratedAt, + WritePackage: outDir != "", }) if err != nil { plan.MissingInputs = append(plan.MissingInputs, "connection_entry_runner_error: "+err.Error()) - plan.Checks = append(plan.Checks, Check{Name: "connection_entry_runner", Passed: false, Detail: err.Error()}) + plan.Checks = append(plan.Checks, Check{Name: "connection_entry_runner", Passed: false, Detail: "failed"}) return } checks := runnerChecksToAcceptance(pkg.Checks) @@ -411,7 +482,7 @@ func addRunnerPlan(plan *Plan, invite agentinvite.Invite, opts Options, outDir s GeneratedFile{Path: pkg.PlanPath, Purpose: "reviewable Connection Entry runner package plan and helper policy"}, ) } - plan.Checks = append(plan.Checks, Check{Name: "connection_entry_runner", Passed: plan.RunnerPlan.OK, Detail: pkg.ManifestPath}) + plan.Checks = append(plan.Checks, Check{Name: "connection_entry_runner", Passed: plan.RunnerPlan.OK, Detail: planArtifactStatus(plan.RunnerPlan.OK)}) if plan.EntryPackagePlan == nil && outDir != "" { plan.EntryPackagePlan = &EntryPackagePlan{ SchemaVersion: EntryPackagePlanSchemaVersion, @@ -448,76 +519,11 @@ func runnerOutDir(outDir string) string { func runnerChecksToAcceptance(checks []connectionrunner.Check) []acceptance.Check { values := make([]acceptance.Check, 0, len(checks)) for _, check := range checks { - values = append(values, acceptance.Check{Name: check.Name, Passed: check.Passed, Detail: check.Detail}) + values = append(values, acceptance.Check{Name: check.Name, Passed: check.Passed, Detail: planArtifactStatus(check.Passed)}) } return values } -func addWindowsEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts Options, outDir string) { - missing := windowsMissingInputs(opts) - plan.MissingInputs = append(plan.MissingInputs, missing...) - if outDir == "" || len(missing) > 0 { - plan.Checks = append(plan.Checks, Check{ - Name: "windows_temporary_acceptance_plan", - Passed: false, - Detail: "requires out_dir and release artifact inputs before generating the Windows launcher", - }) - return - } - winPlan, err := acceptance.RunWindowsTemporaryPlan(acceptance.WindowsTemporaryOptions{ - OutDir: filepath.Join(outDir, "windows-temporary"), - GatewayURL: invite.GatewayURL, - TicketCode: invite.Ticket.Code, - DownloadURL: strings.TrimSpace(opts.WindowsHostDownloadURL), - ExpectedSHA256: strings.TrimSpace(opts.WindowsHostExpectedSHA256), - BootstrapScriptPath: strings.TrimSpace(opts.WindowsBootstrapScriptPath), - BootstrapScriptURL: strings.TrimSpace(opts.WindowsBootstrapScriptURL), - BootstrapScriptExpectedSHA256: strings.TrimSpace(opts.WindowsBootstrapScriptSHA256), - ManifestURL: invite.ManifestURL, - ManifestRootPublicKey: invite.ManifestRootPublicKey, - ReleaseBundleURL: strings.TrimSpace(opts.ReleaseBundleURL), - ReleaseBundleRequiredArtifacts: firstNonEmpty(strings.TrimSpace(opts.ReleaseBundleRequiredArtifacts), "rdev-host.exe,rdev-verify.exe"), - ReleaseRootPublicKey: strings.TrimSpace(opts.ReleaseRootPublicKey), - VerifierDownloadURL: strings.TrimSpace(opts.WindowsVerifierDownloadURL), - VerifierExpectedSHA256: strings.TrimSpace(opts.WindowsVerifierExpectedSHA256), - HostName: strings.TrimSpace(opts.HostName), - Force: opts.Force, - Now: plan.GeneratedAt, - }) - if err != nil { - plan.MissingInputs = append(plan.MissingInputs, "entry_package_plan_error: "+err.Error()) - plan.Checks = append(plan.Checks, Check{Name: "windows_temporary_acceptance_plan", Passed: false, Detail: err.Error()}) - return - } - plan.EntryPackagePlan = &EntryPackagePlan{ - SchemaVersion: EntryPackagePlanSchemaVersion, - TargetOS: plan.TargetOS, - SessionMode: plan.SessionMode, - PackageMode: "visible-self-contained-connection-entry", - OK: allAcceptanceChecksPassed(winPlan.Checks), - PlanPath: filepath.Join(winPlan.OutDir, "windows-temporary-plan.json"), - LauncherPath: winPlan.LauncherPath, - PlatformPlanSchema: winPlan.SchemaVersion, - PlatformPlanKind: "windows-temporary-acceptance-plan", - HumanEntryPoint: "run the visible PowerShell launcher from this package plan", - AgentOnlyParameters: []string{ - "manifest_url", - "manifest_root_public_key", - "gateway_url", - "ticket_code", - "transport", - "release_bundle_url", - "release_root_public_key", - }, - Checks: winPlan.Checks, - } - plan.GeneratedFiles = append(plan.GeneratedFiles, - GeneratedFile{Path: plan.EntryPackagePlan.PlanPath, Purpose: "reviewable Windows temporary acceptance plan inside the generic connection entry package plan"}, - GeneratedFile{Path: plan.EntryPackagePlan.LauncherPath, Purpose: "visible PowerShell connection entry launcher"}, - ) - plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: plan.EntryPackagePlan.PlanPath}) -} - func addManagedEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts Options, outDir string) { missing := managedMissingInputs(plan.TargetOS, opts) plan.MissingInputs = append(plan.MissingInputs, missing...) @@ -525,7 +531,7 @@ func addManagedEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts Opti plan.Checks = append(plan.Checks, Check{ Name: "managed_service_package_plan", Passed: false, - Detail: "requires out_dir, managed binary path, release bundle path, and release root before generating the managed-service package plan", + Detail: "missing_inputs", }) return } @@ -538,7 +544,7 @@ func addManagedEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts Opti addWindowsManagedEntryPackagePlan(plan, invite, opts, outDir) default: plan.MissingInputs = append(plan.MissingInputs, "managed_service_unsupported_target_os: "+plan.TargetOS) - plan.Checks = append(plan.Checks, Check{Name: "managed_service_package_plan", Passed: false, Detail: "unsupported target OS for managed service package plan: " + plan.TargetOS}) + plan.Checks = append(plan.Checks, Check{Name: "managed_service_package_plan", Passed: false, Detail: "unsupported"}) } } @@ -578,7 +584,7 @@ func addManagedMacEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts O GeneratedFile{Path: plan.EntryPackagePlan.PlanPath, Purpose: "reviewable macOS managed-service plan inside the generic connection entry package plan"}, GeneratedFile{Path: plan.EntryPackagePlan.LauncherPath, Purpose: "reviewable LaunchAgent plist for owned managed host enrollment"}, ) - plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: plan.EntryPackagePlan.PlanPath}) + plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: planArtifactStatus(plan.EntryPackagePlan.OK)}) } func addLinuxManagedEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts Options, outDir string) { @@ -617,7 +623,7 @@ func addLinuxManagedEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts GeneratedFile{Path: plan.EntryPackagePlan.PlanPath, Purpose: "reviewable Linux managed-service plan inside the generic connection entry package plan"}, GeneratedFile{Path: plan.EntryPackagePlan.LauncherPath, Purpose: "reviewable systemd user unit for owned managed host enrollment"}, ) - plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: plan.EntryPackagePlan.PlanPath}) + plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: planArtifactStatus(plan.EntryPackagePlan.OK)}) } func addWindowsManagedEntryPackagePlan(plan *Plan, invite agentinvite.Invite, opts Options, outDir string) { @@ -654,27 +660,36 @@ func addWindowsManagedEntryPackagePlan(plan *Plan, invite agentinvite.Invite, op plan.GeneratedFiles = append(plan.GeneratedFiles, GeneratedFile{Path: plan.EntryPackagePlan.PlanPath, Purpose: "reviewable Windows managed-service plan inside the generic connection entry package plan"}, ) - plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: plan.EntryPackagePlan.PlanPath}) + plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: planArtifactStatus(plan.EntryPackagePlan.OK)}) } func addManagedEntryPackagePlanError(plan *Plan, err error) { plan.MissingInputs = append(plan.MissingInputs, "managed_service_package_plan_error: "+err.Error()) - plan.Checks = append(plan.Checks, Check{Name: "managed_service_package_plan", Passed: false, Detail: err.Error()}) + plan.Checks = append(plan.Checks, Check{Name: "managed_service_package_plan", Passed: false, Detail: "failed"}) } -func windowsMissingInputs(opts Options) []string { - var missing []string - required := map[string]string{ - "windows_host_download_url": opts.WindowsHostDownloadURL, - "windows_host_expected_sha256": opts.WindowsHostExpectedSHA256, - "release_bundle_url": opts.ReleaseBundleURL, - "release_root_public_key": opts.ReleaseRootPublicKey, - "windows_verifier_download_url": opts.WindowsVerifierDownloadURL, - "windows_verifier_expected_sha256": opts.WindowsVerifierExpectedSHA256, +func planArtifactStatus(passed bool) string { + if passed { + return "ready" } - for name, value := range required { - if strings.TrimSpace(value) == "" { - missing = append(missing, name) + return "failed" +} + +func windowsLayeredMissingInputs(opts Options) []string { + required := []struct { + name string + value string + }{ + {name: "windows_bootstrap_binary", value: opts.WindowsBootstrapBinaryPath}, + {name: "windows_bootstrap_release_manifest", value: opts.WindowsBootstrapReleaseManifestPath}, + {name: "layered_assets_manifest_url", value: opts.LayeredAssetsManifestURL}, + {name: "layered_release_version", value: opts.LayeredReleaseVersion}, + {name: "release_root_public_key", value: opts.ReleaseRootPublicKey}, + } + missing := make([]string, 0, len(required)) + for _, input := range required { + if strings.TrimSpace(input.value) == "" { + missing = append(missing, input.name) } } return missing @@ -731,9 +746,12 @@ func splitCSV(value string) []string { return values } -func writeMaterializedFiles(plan *Plan) error { +func writeMaterializedFiles(plan *Plan, materializationDir string) error { + if err := runConnectionEntryMaterializationFailureHook("before_top_level_files"); err != nil { + return err + } humanPath := filepath.Join(plan.OutDir, "CONNECTION_ENTRY.md") - if err := os.WriteFile(humanPath, []byte(renderHumanMessage(*plan)), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(materializationDir, "CONNECTION_ENTRY.md"), []byte(renderHumanMessage(*plan)), 0o600); err != nil { return err } plan.HumanMessagePath = humanPath @@ -743,7 +761,10 @@ func writeMaterializedFiles(plan *Plan) error { return err } content = append(content, '\n') - return os.WriteFile(filepath.Join(plan.OutDir, "connection-entry-plan.json"), content, 0o600) + if err := os.WriteFile(filepath.Join(materializationDir, "connection-entry-plan.json"), content, 0o600); err != nil { + return err + } + return runConnectionEntryMaterializationFailureHook("after_top_level_files") } func renderHumanMessage(plan Plan) string { @@ -762,10 +783,10 @@ func renderHumanMessage(plan Plan) string { } func prepareOutDir(outDir string) error { - if err := os.MkdirAll(outDir, 0o700); err != nil { - return err - } entries, err := os.ReadDir(outDir) + if os.IsNotExist(err) { + return nil + } if err != nil { return err } @@ -775,6 +796,199 @@ func prepareOutDir(outDir string) error { return nil } +func createMaterializationStagingDir(outDir string) (string, error) { + parentDir := filepath.Dir(outDir) + if err := os.MkdirAll(parentDir, 0o700); err != nil { + return "", err + } + stagingDir, err := os.MkdirTemp(parentDir, "."+filepath.Base(outDir)+".staging-") + if err != nil { + return "", err + } + if err := os.Chmod(stagingDir, 0o700); err != nil { + os.RemoveAll(stagingDir) + return "", err + } + return stagingDir, nil +} + +func rewriteMaterializedPaths(plan *Plan, stagingDir, outDir string) error { + if err := filepath.WalkDir(stagingDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.EqualFold(filepath.Ext(path), ".json") { + return nil + } + return rewriteMaterializedJSONFile(path, stagingDir, outDir) + }); err != nil { + return err + } + + plan.OutDir = outDir + plan.HumanMessagePath = rewriteMaterializedPath(plan.HumanMessagePath, stagingDir, outDir) + for index := range plan.GeneratedFiles { + plan.GeneratedFiles[index].Path = rewriteMaterializedPath(plan.GeneratedFiles[index].Path, stagingDir, outDir) + } + for index := range plan.Checks { + plan.Checks[index].Detail = rewriteMaterializedPath(plan.Checks[index].Detail, stagingDir, outDir) + } + for index := range plan.MissingInputs { + plan.MissingInputs[index] = rewriteMaterializedPath(plan.MissingInputs[index], stagingDir, outDir) + } + if plan.RunnerPlan != nil { + plan.RunnerPlan.ManifestPath = rewriteMaterializedPath(plan.RunnerPlan.ManifestPath, stagingDir, outDir) + plan.RunnerPlan.LauncherPath = rewriteMaterializedPath(plan.RunnerPlan.LauncherPath, stagingDir, outDir) + plan.RunnerPlan.PlanPath = rewriteMaterializedPath(plan.RunnerPlan.PlanPath, stagingDir, outDir) + for index := range plan.RunnerPlan.Checks { + plan.RunnerPlan.Checks[index].Detail = rewriteMaterializedPath(plan.RunnerPlan.Checks[index].Detail, stagingDir, outDir) + } + } + if plan.EntryPackagePlan != nil { + plan.EntryPackagePlan.PlanPath = rewriteMaterializedPath(plan.EntryPackagePlan.PlanPath, stagingDir, outDir) + plan.EntryPackagePlan.LauncherPath = rewriteMaterializedPath(plan.EntryPackagePlan.LauncherPath, stagingDir, outDir) + plan.EntryPackagePlan.ArchivePath = rewriteMaterializedPath(plan.EntryPackagePlan.ArchivePath, stagingDir, outDir) + for index := range plan.EntryPackagePlan.Checks { + plan.EntryPackagePlan.Checks[index].Detail = rewriteMaterializedPath(plan.EntryPackagePlan.Checks[index].Detail, stagingDir, outDir) + } + } + return nil +} + +func rewriteMaterializedJSONFile(path, stagingDir, outDir string) error { + if filepath.Ext(path) != ".json" { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + decoder := json.NewDecoder(strings.NewReader(string(content))) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return err + } + rewritten, changed := rewriteMaterializedJSONValue(value, stagingDir, outDir) + if !changed { + return nil + } + content, err = json.MarshalIndent(rewritten, "", " ") + if err != nil { + return err + } + content = append(content, '\n') + return os.WriteFile(path, content, 0o600) +} + +func rewriteMaterializedJSONValue(value any, stagingDir, outDir string) (any, bool) { + switch typed := value.(type) { + case string: + rewritten := rewriteMaterializedPath(typed, stagingDir, outDir) + return rewritten, rewritten != typed + case []any: + rewritten := make([]any, len(typed)) + changed := false + for index, child := range typed { + var childChanged bool + rewritten[index], childChanged = rewriteMaterializedJSONValue(child, stagingDir, outDir) + changed = changed || childChanged + } + return rewritten, changed + case map[string]any: + rewritten := make(map[string]any, len(typed)) + changed := false + for key, child := range typed { + var childChanged bool + rewritten[key], childChanged = rewriteMaterializedJSONValue(child, stagingDir, outDir) + changed = changed || childChanged + } + return rewritten, changed + default: + return value, false + } +} + +func rewriteMaterializedPath(value, stagingDir, outDir string) string { + rewritten := strings.ReplaceAll(value, stagingDir, outDir) + stagingSlash := filepath.ToSlash(stagingDir) + if stagingSlash != stagingDir { + rewritten = strings.ReplaceAll(rewritten, stagingSlash, filepath.ToSlash(outDir)) + } + return rewritten +} + +func publishMaterializedFiles(stagingDir, outDir string, finalize func() error) error { + originalOutDirExists := false + placeholder := "" + entries, err := os.ReadDir(outDir) + if os.IsNotExist(err) { + if err := os.Rename(stagingDir, outDir); err != nil { + return err + } + } else if err != nil { + return err + } else if len(entries) != 0 { + return fmt.Errorf("out directory must be empty: %s", outDir) + } else { + originalOutDirExists = true + parentDir := filepath.Dir(outDir) + placeholder, err = os.MkdirTemp(parentDir, "."+filepath.Base(outDir)+".empty-") + if err != nil { + return err + } + if err := os.Remove(placeholder); err != nil { + _ = os.RemoveAll(placeholder) + return err + } + if err := os.Rename(outDir, placeholder); err != nil { + return err + } + if err := os.Rename(stagingDir, outDir); err != nil { + if rollbackErr := os.Rename(placeholder, outDir); rollbackErr != nil { + return errors.Join( + fmt.Errorf("publish materialized output: %w", err), + fmt.Errorf("restore empty output: %w", rollbackErr), + ) + } + return err + } + } + + if finalize != nil { + if err := finalize(); err != nil { + rollbackErr := rollbackPublishedMaterializedFiles(outDir, placeholder, originalOutDirExists) + return errors.Join(err, rollbackErr) + } + } + if placeholder != "" { + if err := os.RemoveAll(placeholder); err != nil { + return fmt.Errorf("remove replaced empty output directory: %w", err) + } + } + return nil +} + +func rollbackPublishedMaterializedFiles(outDir, placeholder string, restoreOriginal bool) error { + if err := os.RemoveAll(outDir); err != nil { + return fmt.Errorf("remove failed materialized output: %w", err) + } + if !restoreOriginal { + return nil + } + if err := os.Rename(placeholder, outDir); err != nil { + return fmt.Errorf("restore empty output: %w", err) + } + return nil +} + +func runConnectionEntryMaterializationFailureHook(phase string) error { + if connectionEntryMaterializationFailureHook == nil { + return nil + } + return connectionEntryMaterializationFailureHook(phase) +} + func allAcceptanceChecksPassed(checks []acceptance.Check) bool { if len(checks) == 0 { return false diff --git a/internal/connectionentry/plan_test.go b/internal/connectionentry/plan_test.go index 0ac3ff7..d684d17 100644 --- a/internal/connectionentry/plan_test.go +++ b/internal/connectionentry/plan_test.go @@ -2,17 +2,43 @@ package connectionentry import ( "encoding/json" + "errors" "os" "path/filepath" + "reflect" "slices" "strings" "testing" "time" "github.com/EitanWong/remote-dev-skillkit/internal/agentinvite" + "github.com/EitanWong/remote-dev-skillkit/internal/connectionrunner" "github.com/EitanWong/remote-dev-skillkit/internal/model" ) +func TestConnectionEntryExecutableSelectorsAreBootstrapOnly(t *testing.T) { + allOptions := map[string]reflect.Type{ + "agent invite options": reflect.TypeOf(agentinvite.Options{}), + "connection entry plan options": reflect.TypeOf(Options{}), + "runner build options": reflect.TypeOf(connectionrunner.Options{}), + "runner run options": reflect.TypeOf(connectionrunner.RunOptions{}), + } + for name, typ := range allOptions { + if _, ok := typ.FieldByName("RdevCommand"); ok { + t.Fatalf("%s retains legacy RdevCommand selector", name) + } + } + for name, typ := range map[string]reflect.Type{ + "connection entry plan options": allOptions["connection entry plan options"], + "runner build options": allOptions["runner build options"], + "runner run options": allOptions["runner run options"], + } { + if _, ok := typ.FieldByName("BootstrapCommand"); !ok { + t.Fatalf("%s must expose BootstrapCommand", name) + } + } +} + func TestFromInviteReportsMissingWindowsReleaseInputs(t *testing.T) { invite := testInvite(t, model.HostModeAttendedTemporary) content := mustJSON(t, invite) @@ -40,8 +66,10 @@ func TestFromInviteReportsMissingWindowsReleaseInputs(t *testing.T) { if !strings.Contains(plan.EntryCommand, "powershell") || !strings.Contains(plan.EntryCommand, "bootstrap.ps1") { t.Fatalf("expected Windows entry command, got %q", plan.EntryCommand) } - if len(plan.MissingInputs) == 0 || !slices.Contains(plan.MissingInputs, "release_bundle_url") { - t.Fatalf("expected release input gaps, got %#v", plan.MissingInputs) + if len(plan.MissingInputs) == 0 || + !slices.Contains(plan.MissingInputs, "windows_bootstrap_binary") || + !slices.Contains(plan.MissingInputs, "layered_assets_manifest_url") { + t.Fatalf("expected layered bootstrap input gaps, got %#v", plan.MissingInputs) } if plan.ModeDecision == "" || !strings.Contains(plan.ModeDecision, "attended-temporary") { t.Fatalf("expected attended temporary mode decision, got %#v", plan) @@ -63,26 +91,104 @@ func TestFromInviteReportsMissingWindowsReleaseInputs(t *testing.T) { } } -func TestFromInviteGeneratesWindowsTemporaryMaterialization(t *testing.T) { - bootstrap := filepath.Join(t.TempDir(), "windows-temporary.ps1") - if err := os.WriteFile(bootstrap, []byte("Write-Host 'bootstrap'\n"), 0o600); err != nil { - t.Fatal(err) +func TestBuildChecksRedactsPrivateInviteDetails(t *testing.T) { + invite := testInvite(t, model.HostModeAttendedTemporary) + plan := Plan{ + TargetOS: "windows", + SessionMode: string(model.HostModeAttendedTemporary), + Ownership: "third-party", } - out := filepath.Join(t.TempDir(), "entry") - plan, err := FromInvite(Options{ - InviteJSON: mustJSON(t, testInvite(t, model.HostModeAttendedTemporary)), - OutDir: out, - TargetOS: "windows", - Ownership: "third-party", - WindowsBootstrapScriptPath: bootstrap, - WindowsHostDownloadURL: "https://agent.example.com/rdev-host.exe", - WindowsHostExpectedSHA256: strings.Repeat("a", 64), - ReleaseBundleURL: "https://agent.example.com/release-bundle.json", - ReleaseRootPublicKey: "release-root:" + strings.Repeat("b", 43), - WindowsVerifierDownloadURL: "https://agent.example.com/rdev-verify.exe", - WindowsVerifierExpectedSHA256: strings.Repeat("c", 64), - Now: time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC), - }) + checks := buildChecks(invite, plan, "powershell.exe -File entry.ps1") + sensitive := []string{ + invite.ConnectionEntry.EntryURL, + invite.ManifestURL, + invite.ManifestRootPublicKey, + invite.Ticket.Code, + invite.GatewayURL, + } + for _, check := range checks { + for _, value := range sensitive { + if value != "" && strings.Contains(check.Detail, value) { + t.Fatalf("top-level check %q leaked private value %q in detail %q", check.Name, value, check.Detail) + } + } + } + for _, name := range []string{"entry_url", "manifest_url", "manifest_root_public_key", "ticket_code"} { + var found bool + for _, check := range checks { + if check.Name != name { + continue + } + found = true + if !slices.Contains([]string{"missing", "invalid", "present", "valid"}, check.Detail) { + t.Fatalf("check %q must expose only a status detail, got %q", name, check.Detail) + } + } + if !found { + t.Fatalf("expected check %q", name) + } + } +} + +func TestPrivateCheckStatusDetails(t *testing.T) { + for _, test := range []struct { + name string + value string + valid bool + want string + }{ + {name: "missing validation value", want: "missing"}, + {name: "invalid validation value", value: "not-a-url", want: "invalid"}, + {name: "valid validation value", value: "https://example.com", valid: true, want: "valid"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := validationStatusDetail(test.value, test.valid); got != test.want { + t.Fatalf("validationStatusDetail(%q, %t) = %q, want %q", test.value, test.valid, got, test.want) + } + }) + } + if got := presenceStatusDetail(" \t"); got != "missing" { + t.Fatalf("presenceStatusDetail(blank) = %q, want missing", got) + } + if got := presenceStatusDetail("private-value"); got != "present" { + t.Fatalf("presenceStatusDetail(value) = %q, want present", got) + } + if got := planArtifactStatus(false); got != "failed" { + t.Fatalf("planArtifactStatus(false) = %q, want failed", got) + } +} + +func TestConnectionEntryPlanSelectionEdges(t *testing.T) { + if got := normalizeTargetOS("Solaris"); got != "solaris" { + t.Fatalf("normalizeTargetOS(Solaris) = %q", got) + } + if got := normalizeOwnership("inventory"); got != "inventory" { + t.Fatalf("normalizeOwnership(inventory) = %q", got) + } + if got := inferOwnership(testInvite(t, model.HostModeManaged)); got != "owned" { + t.Fatalf("managed invite ownership = %q", got) + } + if got := inferOwnership(testInvite(t, model.HostModeAttendedTemporary)); got != "third-party" { + t.Fatalf("temporary invite ownership = %q", got) + } + if got := commandForOS(nil, "windows"); got != "" { + t.Fatalf("nil command map = %q", got) + } + if _, err := selectSessionMode(testInvite(t, model.HostModeAttendedTemporary), "third-party", "invalid-mode"); err == nil { + t.Fatal("invalid requested session mode must be rejected") + } + if allAcceptanceChecksPassed(nil) { + t.Fatal("empty acceptance checks must not pass") + } + if got := firstNonEmpty("", ""); got != "" { + t.Fatalf("firstNonEmpty blanks = %q", got) + } +} + +func TestFromInviteGeneratesWindowsLayeredMaterialization(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + out := fixture.options.OutDir + plan, err := FromInvite(fixture.options) if err != nil { t.Fatal(err) } @@ -92,7 +198,7 @@ func TestFromInviteGeneratesWindowsTemporaryMaterialization(t *testing.T) { if plan.HumanMessagePath == "" || !fileExists(plan.HumanMessagePath) { t.Fatalf("expected human message file: %#v", plan) } - if plan.EntryPackagePlan == nil || !plan.EntryPackagePlan.OK || !fileExists(plan.EntryPackagePlan.LauncherPath) { + if plan.EntryPackagePlan == nil || !plan.EntryPackagePlan.OK { t.Fatalf("expected generated entry package plan: %#v", plan.EntryPackagePlan) } if plan.RunnerPlan == nil || @@ -108,19 +214,25 @@ func TestFromInviteGeneratesWindowsTemporaryMaterialization(t *testing.T) { } if plan.EntryPackagePlan.SchemaVersion != EntryPackagePlanSchemaVersion || plan.EntryPackagePlan.TargetOS != "windows" || - plan.EntryPackagePlan.PlatformPlanKind != "windows-temporary-acceptance-plan" { - t.Fatalf("expected generic entry package wrapper around Windows plan: %#v", plan.EntryPackagePlan) + plan.EntryPackagePlan.PlatformPlanKind != "windows-layered-handoff" { + t.Fatalf("expected generic entry package wrapper around Windows layered handoff: %#v", plan.EntryPackagePlan) } if !slices.Contains(plan.EntryPackagePlan.AgentOnlyParameters, "manifest_root_public_key") || - !slices.Contains(plan.EntryPackagePlan.AgentOnlyParameters, "ticket_code") { + !slices.Contains(plan.EntryPackagePlan.AgentOnlyParameters, "layered_assets_manifest_url") { t.Fatalf("expected raw connection parameters to stay agent-only: %#v", plan.EntryPackagePlan.AgentOnlyParameters) } - launcher, err := os.ReadFile(plan.EntryPackagePlan.LauncherPath) + launcherPath := plan.EntryPackagePlan.LauncherPath + if !filepath.IsAbs(launcherPath) { + launcherPath = filepath.Join(out, filepath.FromSlash(launcherPath)) + } + launcher, err := os.ReadFile(launcherPath) if err != nil { t.Fatal(err) } - if !strings.Contains(string(launcher), "-ManifestRootPublicKey") || strings.Contains(string(launcher), "ticket, root, gateway") { - t.Fatalf("launcher should carry manifest root and avoid human flag assembly text:\n%s", string(launcher)) + if !strings.Contains(string(launcher), "--manifest-root-public-key") || + !strings.Contains(string(launcher), "rdev-bootstrap") || + strings.Contains(string(launcher), "ticket, root, gateway") { + t.Fatalf("launcher should use bootstrap with pinned manifest metadata and avoid human flag assembly text:\n%s", string(launcher)) } if !fileExists(filepath.Join(out, "connection-entry-plan.json")) { t.Fatalf("expected materialization plan JSON in out dir") @@ -257,6 +369,261 @@ func TestFromInviteRejectsManagedEntryForThirdParty(t *testing.T) { } } +func TestFromInviteMaterializationFailureLeavesNoPrivateOutput(t *testing.T) { + tests := []struct { + name string + failurePhase string + createEmptyOutDir bool + }{ + {name: "before top-level files", failurePhase: "before_top_level_files"}, + {name: "after top-level files", failurePhase: "after_top_level_files", createEmptyOutDir: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + parentDir := t.TempDir() + outDir := filepath.Join(parentDir, "entry") + if test.createEmptyOutDir { + if err := os.Mkdir(outDir, 0o700); err != nil { + t.Fatal(err) + } + } + fixture.options.OutDir = outDir + + sentinel := errors.New("injected materialization failure") + previousHook := connectionEntryMaterializationFailureHook + connectionEntryMaterializationFailureHook = func(phase string) error { + if phase == test.failurePhase { + return sentinel + } + return nil + } + t.Cleanup(func() { connectionEntryMaterializationFailureHook = previousHook }) + + if _, err := FromInvite(fixture.options); !errors.Is(err, sentinel) { + t.Fatalf("expected injected failure, got %v", err) + } + assertConnectionEntryOutputAbsentOrEmpty(t, outDir) + assertNoConnectionEntryStagingDirs(t, parentDir, filepath.Base(outDir)) + }) + } +} + +func TestFromInviteRejectsPendingArchiveReplacementAndRestoresEmptyOutput(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + parentDir := t.TempDir() + outDir := filepath.Join(parentDir, "entry") + if err := os.Mkdir(outDir, 0o700); err != nil { + t.Fatal(err) + } + originalOutDir, err := os.Stat(outDir) + if err != nil { + t.Fatal(err) + } + fixture.options.OutDir = outDir + + var movedArchivePath string + previousHook := connectionEntryMaterializationFailureHook + connectionEntryMaterializationFailureHook = func(phase string) error { + if phase != "before_windows_layered_archive_publish" { + return nil + } + if !fileExists(filepath.Join(outDir, "connection-entry-plan.json")) { + return errors.New("final materialization directory was not published before archive publication") + } + if fileExists(filepath.Join(outDir, windowsLayeredArchiveName)) { + return errors.New("archive was published before the prepublication hook") + } + matches, err := filepath.Glob(filepath.Join(parentDir, "."+windowsLayeredArchiveName+".tmp-*")) + if err != nil { + return err + } + if len(matches) != 1 { + return errors.New("expected one validated sibling archive temporary file") + } + movedArchivePath = matches[0] + ".moved" + if err := os.Rename(matches[0], movedArchivePath); err != nil { + return err + } + return os.WriteFile(matches[0], []byte("replacement archive must not publish\n"), 0o600) + } + t.Cleanup(func() { connectionEntryMaterializationFailureHook = previousHook }) + + if _, err := FromInvite(fixture.options); err == nil { + t.Fatal("expected pending archive identity replacement to fail materialization") + } + restoredOutDir, err := os.Stat(outDir) + if err != nil { + t.Fatalf("preexisting empty output directory was not restored: %v", err) + } + if !os.SameFile(originalOutDir, restoredOutDir) { + t.Fatal("materialization rollback did not restore the preexisting output directory") + } + assertConnectionEntryOutputAbsentOrEmpty(t, outDir) + assertNoConnectionEntryStagingDirs(t, parentDir, filepath.Base(outDir)) + if movedArchivePath == "" { + t.Fatal("pending archive prepublication hook did not run") + } + if content, err := os.ReadFile(movedArchivePath); err == nil && len(content) != 0 { + t.Fatalf("pending archive replacement failure left %d sensitive bytes on the original handle", len(content)) + } else if err != nil && !os.IsNotExist(err) { + t.Fatalf("inspect moved pending archive: %v", err) + } +} + +func TestFromInviteMaterializationPublishesOnlyFinalPaths(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + parentDir := t.TempDir() + outDir := filepath.Join(parentDir, "entry") + if err := os.Mkdir(outDir, 0o700); err != nil { + t.Fatal(err) + } + fixture.options.OutDir = outDir + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + assertConnectionEntryPlanUsesFinalPaths(t, plan, outDir) + assertNoConnectionEntryStagingDirs(t, parentDir, filepath.Base(outDir)) + + planJSONPath := filepath.Join(outDir, "connection-entry-plan.json") + content, err := os.ReadFile(planJSONPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(content), ".entry.staging-") { + t.Fatalf("persisted materialization plan leaked a staging path:\n%s", content) + } + var persisted Plan + if err := json.Unmarshal(content, &persisted); err != nil { + t.Fatal(err) + } + assertConnectionEntryPlanUsesFinalPaths(t, persisted, outDir) + + err = filepath.WalkDir(outDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.EqualFold(filepath.Ext(path), ".json") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(content), ".entry.staging-") { + t.Errorf("persisted JSON %s leaked a staging path:\n%s", path, content) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestFromInviteRejectsNonEmptyOutputWithoutCreatingStaging(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + parentDir := t.TempDir() + outDir := filepath.Join(parentDir, "entry") + if err := os.Mkdir(outDir, 0o700); err != nil { + t.Fatal(err) + } + markerPath := filepath.Join(outDir, "keep.txt") + if err := os.WriteFile(markerPath, []byte("keep\n"), 0o600); err != nil { + t.Fatal(err) + } + fixture.options.OutDir = outDir + + if _, err := FromInvite(fixture.options); err == nil || !strings.Contains(err.Error(), "out directory must be empty") { + t.Fatalf("expected non-empty output rejection, got %v", err) + } + if !fileExists(markerPath) { + t.Fatal("non-empty output rejection removed the existing file") + } + assertNoConnectionEntryStagingDirs(t, parentDir, filepath.Base(outDir)) +} + +func assertConnectionEntryOutputAbsentOrEmpty(t *testing.T, outDir string) { + t.Helper() + entries, err := os.ReadDir(outDir) + if os.IsNotExist(err) { + return + } + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("failed materialization left private output in %s: %#v", outDir, entries) + } +} + +func assertNoConnectionEntryStagingDirs(t *testing.T, parentDir, outBase string) { + t.Helper() + entries, err := os.ReadDir(parentDir) + if err != nil { + t.Fatal(err) + } + prefixes := []string{"." + outBase + ".staging-", "." + outBase + ".empty-"} + for _, entry := range entries { + for _, prefix := range prefixes { + if strings.HasPrefix(entry.Name(), prefix) { + t.Fatalf("materialization left temporary directory %s", filepath.Join(parentDir, entry.Name())) + } + } + } +} + +func assertConnectionEntryPlanUsesFinalPaths(t *testing.T, plan Plan, outDir string) { + t.Helper() + finalDir, err := filepath.Abs(outDir) + if err != nil { + t.Fatal(err) + } + if plan.OutDir != finalDir { + t.Fatalf("plan out_dir = %q, want %q", plan.OutDir, finalDir) + } + paths := map[string]string{ + "human_message": plan.HumanMessagePath, + } + if plan.RunnerPlan == nil { + t.Fatal("expected runner plan") + } + paths["runner_manifest"] = plan.RunnerPlan.ManifestPath + paths["runner_launcher"] = plan.RunnerPlan.LauncherPath + paths["runner_plan"] = plan.RunnerPlan.PlanPath + if plan.EntryPackagePlan == nil { + t.Fatal("expected entry package plan") + } + paths["entry_plan"] = filepath.Join(finalDir, filepath.FromSlash(plan.EntryPackagePlan.PlanPath)) + paths["entry_launcher"] = filepath.Join(finalDir, filepath.FromSlash(plan.EntryPackagePlan.LauncherPath)) + if plan.EntryPackagePlan.ArchivePath != "" { + paths["entry_archive"] = filepath.Join(finalDir, filepath.FromSlash(plan.EntryPackagePlan.ArchivePath)) + } + for _, file := range plan.GeneratedFiles { + paths["generated:"+file.Path] = file.Path + } + for name, path := range paths { + if path == "" { + t.Fatalf("%s path is empty", name) + } + relative, err := filepath.Rel(finalDir, path) + if err != nil { + t.Fatal(err) + } + if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + t.Fatalf("%s path %q is outside final out dir %q", name, path, finalDir) + } + if strings.Contains(path, ".entry.staging-") { + t.Fatalf("%s path leaked staging directory: %q", name, path) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("%s final path is not published: %v", name, err) + } + } +} + func testInvite(t *testing.T, mode model.HostMode) agentinvite.Invite { t.Helper() ticket := model.Ticket{ diff --git a/internal/connectionentry/windows_layered.go b/internal/connectionentry/windows_layered.go new file mode 100644 index 0000000..75cd060 --- /dev/null +++ b/internal/connectionentry/windows_layered.go @@ -0,0 +1,732 @@ +package connectionentry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/acceptance" + "github.com/EitanWong/remote-dev-skillkit/internal/agentinvite" + "github.com/EitanWong/remote-dev-skillkit/internal/model" + "github.com/EitanWong/remote-dev-skillkit/internal/release" + "github.com/EitanWong/remote-dev-skillkit/internal/trustref" +) + +const windowsLayeredHandoffSchemaVersion = "rdev.connection-entry.windows-layered-handoff.v1" + +const ( + windowsLayeredDirName = "windows-layered" + windowsLayeredBootstrapName = "rdev-bootstrap.exe" + windowsLayeredReleaseManifestName = "rdev-bootstrap.exe.rdev-release.json" + windowsLayeredVerificationPlanName = "windows-layered-verification.json" + windowsLayeredChecksumName = "rdev-bootstrap.exe.sha256" + windowsLayeredLauncherName = "Start-ConnectionEntry.ps1" + windowsLayeredCommandLauncherName = "Start-ConnectionEntry.cmd" + windowsLayeredArchiveName = "Windows-ConnectionEntry.zip" + windowsLayeredArchiveRecoveryName = "ARCHIVE-RECOVERY.txt" +) + +type windowsLayeredHandoff struct { + bootstrap []byte + manifest release.Manifest + layeredAssetsManifestURL string + releaseVersion string + releaseRootPublicKey string + joinManifestURL string + joinManifestRoot string + generatedAt time.Time +} + +type windowsLayeredVerification struct { + SchemaVersion string `json:"schema_version"` + Platform string `json:"platform"` + Verification string `json:"verification"` +} + +func prepareWindowsLayeredHandoff(plan Plan, invite agentinvite.Invite, opts Options, outDir string) (*windowsLayeredHandoff, error) { + bootstrapPath := strings.TrimSpace(opts.WindowsBootstrapBinaryPath) + manifestPath := strings.TrimSpace(opts.WindowsBootstrapReleaseManifestPath) + layeredManifestURL := strings.TrimSpace(opts.LayeredAssetsManifestURL) + releaseVersion := strings.TrimSpace(opts.LayeredReleaseVersion) + provided := 0 + for _, value := range []string{bootstrapPath, manifestPath, layeredManifestURL, releaseVersion} { + if value != "" { + provided++ + } + } + if provided < 4 { + return nil, nil + } + + rootPublicKey := strings.TrimSpace(opts.ReleaseRootPublicKey) + if rootPublicKey == "" { + return nil, fmt.Errorf("Windows layered handoff requires a release root public key") + } + if plan.TargetOS != "windows" || plan.SessionMode != string(model.HostModeAttendedTemporary) || normalizeWindowsLayeredArch(opts.TargetArch) != "amd64" { + return nil, fmt.Errorf("Windows layered handoff requires windows/amd64 attended-temporary") + } + if outDir == "" { + return nil, fmt.Errorf("Windows layered handoff requires out_dir") + } + if err := validateLayeredAssetsManifestURL(layeredManifestURL); err != nil { + return nil, err + } + for _, argument := range []struct { + name string + value string + }{ + {name: "layered assets manifest URL", value: layeredManifestURL}, + {name: "layered release version", value: releaseVersion}, + {name: "release root public key", value: rootPublicKey}, + {name: "join manifest URL", value: strings.TrimSpace(invite.ManifestURL)}, + {name: "join manifest root public key", value: strings.TrimSpace(invite.ManifestRootPublicKey)}, + } { + if err := validateWindowsLayeredCommandArgument(argument.name, argument.value); err != nil { + return nil, err + } + } + + root, err := trustref.Parse(rootPublicKey) + if err != nil { + return nil, fmt.Errorf("parse Windows layered release root: %w", err) + } + manifest, err := release.ReadManifest(manifestPath) + if err != nil { + return nil, fmt.Errorf("read Windows bootstrap release manifest: %w", err) + } + if manifest.ArtifactName != windowsLayeredBootstrapName { + return nil, fmt.Errorf("Windows bootstrap release manifest artifact must be %q", windowsLayeredBootstrapName) + } + if manifest.ReleaseVersion != releaseVersion { + return nil, fmt.Errorf("Windows bootstrap release version %q does not match expected %q", manifest.ReleaseVersion, releaseVersion) + } + if manifest.TargetPlatform != "windows/amd64" { + return nil, fmt.Errorf("Windows bootstrap target platform must be windows/amd64") + } + if err := manifest.VerifyArtifact(bootstrapPath, root); err != nil { + return nil, fmt.Errorf("verify Windows bootstrap release artifact: %w", err) + } + + bootstrap, err := os.ReadFile(bootstrapPath) + if err != nil { + return nil, fmt.Errorf("read verified Windows bootstrap: %w", err) + } + digest := sha256.Sum256(bootstrap) + if hex.EncodeToString(digest[:]) != manifest.ArtifactSHA256 || int64(len(bootstrap)) != manifest.ArtifactSize { + return nil, fmt.Errorf("verified Windows bootstrap changed before packaging") + } + + return &windowsLayeredHandoff{ + bootstrap: bootstrap, + manifest: manifest, + layeredAssetsManifestURL: layeredManifestURL, + releaseVersion: releaseVersion, + releaseRootPublicKey: rootPublicKey, + joinManifestURL: strings.TrimSpace(invite.ManifestURL), + joinManifestRoot: strings.TrimSpace(invite.ManifestRootPublicKey), + generatedAt: plan.GeneratedAt, + }, nil +} + +func normalizeWindowsLayeredArch(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "x64", "x86_64": + return "amd64" + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func validateLayeredAssetsManifestURL(value string) error { + if strings.ContainsAny(value, "?#") { + return fmt.Errorf("layered assets manifest URL must not contain a query or fragment") + } + parsed, err := url.ParseRequestURI(value) + if err != nil { + return fmt.Errorf("parse layered assets manifest URL: %w", err) + } + if !strings.EqualFold(parsed.Scheme, "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.Opaque != "" { + return fmt.Errorf("layered assets manifest URL must be an HTTPS URL without credentials") + } + if parsed.Path != "/layered-assets.json" || parsed.EscapedPath() != "/layered-assets.json" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.ForceQuery { + return fmt.Errorf("layered assets manifest URL path must be exactly /layered-assets.json") + } + return nil +} + +func validateWindowsLayeredCommandArgument(name, value string) error { + if strings.ContainsAny(value, "\r\n\"%&|<>()^!") { + return fmt.Errorf("%s contains unsupported Windows command syntax", name) + } + return nil +} + +func materializeWindowsLayeredHandoff(plan *Plan, handoff *windowsLayeredHandoff, outDir string) (*pendingWindowsLayeredArchive, error) { + handoffDir := filepath.Join(outDir, windowsLayeredDirName) + stagingDir, err := os.MkdirTemp(outDir, ".windows-layered-") + if err != nil { + return nil, fmt.Errorf("create Windows layered handoff staging directory: %w", err) + } + defer os.RemoveAll(stagingDir) + if err := os.Chmod(stagingDir, 0o700); err != nil { + return nil, fmt.Errorf("secure Windows layered handoff staging directory: %w", err) + } + + manifestJSON, err := json.Marshal(handoff.manifest) + if err != nil { + return nil, fmt.Errorf("encode Windows bootstrap release manifest: %w", err) + } + verification := windowsLayeredVerification{ + SchemaVersion: windowsLayeredHandoffSchemaVersion, + Platform: "windows/amd64", + Verification: "verified", + } + verificationJSON, err := json.Marshal(verification) + if err != nil { + return nil, fmt.Errorf("encode Windows layered verification plan: %w", err) + } + checksum := []byte(handoff.manifest.ArtifactSHA256 + " " + windowsLayeredBootstrapName) + launcher := []byte(renderWindowsLayeredLauncher(handoff)) + launcherDigest := sha256.Sum256(launcher) + commandLauncher := []byte(renderWindowsLayeredCommandLauncher( + handoff, + hex.EncodeToString(launcherDigest[:]), + len(launcher), + )) + + files := []windowsLayeredArchiveFile{ + {name: windowsLayeredBootstrapName, content: handoff.bootstrap}, + {name: windowsLayeredReleaseManifestName, content: manifestJSON}, + {name: windowsLayeredVerificationPlanName, content: verificationJSON}, + {name: windowsLayeredChecksumName, content: checksum}, + {name: windowsLayeredLauncherName, content: launcher}, + {name: windowsLayeredCommandLauncherName, content: commandLauncher}, + } + for _, file := range files { + if err := writePrivateWindowsLayeredFile(filepath.Join(stagingDir, file.name), file.content); err != nil { + return nil, err + } + } + + if _, err := os.Lstat(handoffDir); err == nil { + return nil, fmt.Errorf("Windows layered handoff directory already exists: %s", handoffDir) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("inspect Windows layered handoff destination: %w", err) + } + archiveFiles := append([]windowsLayeredArchiveFile(nil), files...) + archiveFiles = append(archiveFiles, windowsLayeredArchiveFile{ + name: windowsLayeredArchiveRecoveryName, + content: []byte(renderWindowsLayeredArchiveRecovery()), + }) + pendingArchive, archiveReport, err := prepareWindowsLayeredArchive(filepath.Dir(plan.OutDir), windowsLayeredArchiveName, handoff.generatedAt, archiveFiles) + if err != nil { + return nil, err + } + archiveReport.Path = filepath.Join(plan.OutDir, windowsLayeredArchiveName) + if err := os.Rename(stagingDir, handoffDir); err != nil { + publishErr := fmt.Errorf("publish Windows layered handoff: %w", err) + cleanupErr := pendingArchive.discard() + if cleanupErr != nil { + cleanupErr = fmt.Errorf("clean up archive after handoff publication failure: %w", cleanupErr) + } + return nil, errors.Join(publishErr, cleanupErr) + } + + verificationPath := filepath.Join(handoffDir, windowsLayeredVerificationPlanName) + launcherPath := filepath.Join(handoffDir, windowsLayeredLauncherName) + verificationArtifactPath := windowsLayeredDirName + "/" + windowsLayeredVerificationPlanName + launcherArtifactPath := windowsLayeredDirName + "/" + windowsLayeredLauncherName + checks := []acceptance.Check{ + {Name: "windows_layered_platform", Passed: true, Detail: "windows/amd64 attended-temporary"}, + {Name: "windows_layered_release_version", Passed: true, Detail: handoff.releaseVersion}, + {Name: "windows_layered_manifest_url", Passed: true, Detail: "valid"}, + {Name: "windows_bootstrap_release_verification", Passed: true, Detail: handoff.manifest.ArtifactSHA256}, + {Name: "windows_layered_archive_private", Passed: archiveReport.Private, Detail: archiveReport.PrivacyDetail}, + {Name: "windows_layered_archive_sha256", Passed: archiveReport.SHA256 != "", Detail: archiveReport.SHA256}, + {Name: "windows_layered_archive_size", Passed: archiveReport.SizeBytes <= maxWindowsLayeredHandoffBytes, Detail: strconv.FormatInt(archiveReport.SizeBytes, 10)}, + } + plan.EntryPackagePlan = &EntryPackagePlan{ + SchemaVersion: EntryPackagePlanSchemaVersion, + TargetOS: plan.TargetOS, + SessionMode: plan.SessionMode, + PackageMode: "private-windows-layered-handoff", + OK: allAcceptanceChecksPassed(checks), + PlanPath: verificationArtifactPath, + LauncherPath: launcherArtifactPath, + ArchivePath: windowsLayeredArchiveName, + ArchiveSHA256: archiveReport.SHA256, + ArchiveSizeBytes: archiveReport.SizeBytes, + PlatformPlanSchema: windowsLayeredHandoffSchemaVersion, + PlatformPlanKind: "windows-layered-handoff", + HumanEntryPoint: "run the visible PowerShell launcher from the private Windows layered handoff; use the visible Command Prompt broker when needed", + AgentOnlyParameters: []string{ + "layered_assets_manifest_url", + "expected_release_version", + "release_root_public_key", + "manifest_url", + "manifest_root_public_key", + }, + Checks: checks, + } + plan.GeneratedFiles = append(plan.GeneratedFiles, + GeneratedFile{Path: filepath.Join(handoffDir, windowsLayeredBootstrapName), Purpose: "controller-verified Windows bootstrap trust anchor"}, + GeneratedFile{Path: filepath.Join(handoffDir, windowsLayeredReleaseManifestName), Purpose: "signed non-sensitive bootstrap release manifest"}, + GeneratedFile{Path: verificationPath, Purpose: "non-sensitive Windows layered handoff verification record"}, + GeneratedFile{Path: filepath.Join(handoffDir, windowsLayeredChecksumName), Purpose: "bootstrap SHA-256 pin checked again by the launcher"}, + GeneratedFile{Path: launcherPath, Purpose: "preferred visible foreground-only PowerShell connection entry launcher"}, + GeneratedFile{Path: filepath.Join(handoffDir, windowsLayeredCommandLauncherName), Purpose: "visible Command Prompt broker and native fallback launcher"}, + GeneratedFile{Path: archiveReport.Path, Purpose: "deterministic private Windows Connection Entry delivery archive"}, + ) + plan.Checks = append(plan.Checks, Check{Name: "entry_package_plan", Passed: plan.EntryPackagePlan.OK, Detail: "ready"}) + return pendingArchive, nil +} + +func renderWindowsLayeredArchiveRecovery() string { + return "Run rdev-bootstrap.exe layered-run: signed archive recovery profile only.\n" +} + +func writePrivateWindowsLayeredFile(path string, content []byte) error { + if err := os.WriteFile(path, content, 0o600); err != nil { + return fmt.Errorf("write private Windows layered handoff file %s: %w", filepath.Base(path), err) + } + if err := os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("secure Windows layered handoff file %s: %w", filepath.Base(path), err) + } + return nil +} + +func renderWindowsLayeredLauncher(handoff *windowsLayeredHandoff) string { + return fmt.Sprintf(`param( + [string] $AttemptDir = '', + [string] $Launcher = 'powershell', + [switch] $Brokered +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-NoReparsePoint([string] $Path) { + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { throw 1 } + $cursor = [IO.Path]::GetFullPath($Path) + if ($cursor.StartsWith('\\') -or $cursor.StartsWith('//')) { throw 'UNC paths are not allowed.' } + $root = [IO.Path]::GetPathRoot($cursor) + if (([IO.DriveInfo]::new($root)).DriveType -ne [IO.DriveType]::Fixed) { throw 1 } + while ($true) { + $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 1 } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Protect-PrivatePath([string] $Path) { + Assert-NoReparsePoint $Path + $user = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $sys = 'S-1-5-18' + $admin = 'S-1-5-32-544' + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + $perm = if ($item.PSIsContainer) { '(OI)(CI)F' } else { 'F' } + $aclTool = "$env:SystemRoot\System32\icacls.exe" + & $aclTool $Path '/reset' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 1 } + & $aclTool $Path '/inheritance:r' '/grant:r' "*${user}:$perm" "*${sys}:$perm" "*${admin}:$perm" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 1 } + $trusted = @($user, $sys, $admin) + $acl = Get-Acl -LiteralPath $Path + $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + if ($trusted -notcontains $owner) { throw 1 } + foreach ($rule in $acl.Access) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { continue } + try { $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value } catch { throw 1 } + if ($trusted -notcontains $sid) { throw 'ACL grants access to an untrusted identity.' } + } +} + +function Invoke-AttemptCheck([string] $Path, [string] $Launch, [switch] $Create) { + $check = @('layered-run', 'attempt-check', '--attempt-dir', $Path, '--launcher', $Launch) + if ($Create) { $check += '--create' } + & $bootstrapPath @check + $LASTEXITCODE +} + +function Assert-PrivatePath([string] $Path, [string] $Kind) { + & $bootstrapPath 'layered-run' 'private-path-check' '--path' $Path '--kind' $Kind + if ($LASTEXITCODE -ne 0) { throw 1 } +} + +function Invoke-Layered([string] $Launch) { + $run = @('layered-run', '--manifest-url', %s, '--root-public-key', %s, '--expected-release-version', %s, '--platform', 'windows/amd64', '--cache-dir', $cacheDir, '--attempt-dir', $AttemptDir, '--launcher', $Launch, '--mode', 'temporary', '--', 'serve', '--mode', 'temporary', '--manifest-url', %s, '--manifest-root-public-key', %s, '--transport', 'auto', '--once=false', '--max-tasks', '0') + & $bootstrapPath @run + $script:layeredExitCode = $LASTEXITCODE +} + +$bootstrapPath = "$PSScriptRoot\%s" +$commandLauncher = "$PSScriptRoot\%s" +$powerShellLauncher = $PSCommandPath +$attemptRoot = [IO.Path]::GetFullPath("$env:LOCALAPPDATA\RemoteDevSkillkit\attempts") +$cacheDir = [IO.Path]::GetFullPath("$env:LOCALAPPDATA\RemoteDevSkillkit\cache") +$layeredExitCode = 1 +try { + if (-not (Test-Path -LiteralPath $bootstrapPath -PathType Leaf) -or -not (Test-Path -LiteralPath $commandLauncher -PathType Leaf)) { throw 1 } + Protect-PrivatePath $PSScriptRoot + Protect-PrivatePath $bootstrapPath + Protect-PrivatePath $commandLauncher + Protect-PrivatePath $powerShellLauncher + if ([string]::IsNullOrWhiteSpace($AttemptDir)) { + $createAttempt = $true + } else { + $AttemptDir = [IO.Path]::GetFullPath($AttemptDir) + $createAttempt = $false + } + $bootstrapLock = [IO.File]::Open($bootstrapPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $sha = [Security.Cryptography.SHA256]::Create() + try { $actualSHA256 = ([BitConverter]::ToString($sha.ComputeHash($bootstrapLock)) -replace '-', '').ToLowerInvariant() } finally { $sha.Dispose() } + if ($actualSHA256 -ne '%s' -or $bootstrapLock.Length -ne %d) { throw 1 } + Assert-PrivatePath $PSScriptRoot 'directory' + Assert-PrivatePath $bootstrapPath 'file' + Assert-PrivatePath $commandLauncher 'file' + Assert-PrivatePath $powerShellLauncher 'file' + if ($createAttempt) { + $created = $false + for ($tries = 0; $tries -lt 32; $tries++) { + $AttemptDir = [IO.Path]::Combine($attemptRoot, [IO.Path]::GetRandomFileName()) + $checkExit = Invoke-AttemptCheck $AttemptDir $Launcher -Create + if ($checkExit -eq 0) { $created = $true; break } + if ($checkExit -ne 2) { throw 1 } + } + if (-not $created) { throw 1 } + } elseif ((Invoke-AttemptCheck $AttemptDir $Launcher) -ne 0) { throw 1 } + Invoke-Layered $Launcher + $layeredExitCode = $script:layeredExitCode + if ($layeredExitCode -eq 0) { exit 0 } + if (-not $Brokered) { + if ((Invoke-AttemptCheck $AttemptDir 'cmd') -eq 0) { + & $commandLauncher '--native' '--attempt-dir' $AttemptDir + exit $LASTEXITCODE + } + } + } finally { $bootstrapLock.Dispose() } + Write-Warning 'Run the signed archive recovery profile: ARCHIVE-RECOVERY.txt.' + exit $layeredExitCode +} catch { + Write-Warning 'Layered bootstrap preparation failed; refusing automatic archive fallback.' + Write-Warning 'Run the signed archive recovery profile: ARCHIVE-RECOVERY.txt.' + exit 1 +} +`, powerShellSingleQuoted(handoff.layeredAssetsManifestURL), + powerShellSingleQuoted(handoff.releaseRootPublicKey), + powerShellSingleQuoted(handoff.releaseVersion), + powerShellSingleQuoted(handoff.joinManifestURL), + powerShellSingleQuoted(handoff.joinManifestRoot), + windowsLayeredBootstrapName, + windowsLayeredCommandLauncherName, + handoff.manifest.ArtifactSHA256, + handoff.manifest.ArtifactSize, + ) +} + +func renderWindowsLayeredCommandLauncher(handoff *windowsLayeredHandoff, expectedPowerShellSHA256 string, expectedPowerShellSize int) string { + launcher := `@echo off +setlocal EnableExtensions DisableDelayedExpansion + +set "HANDOFF_DIR=%~dp0" +set "HANDOFF_DIR=%HANDOFF_DIR:~0,-1%" +set "SOURCE_BOOTSTRAP=%HANDOFF_DIR%\__BOOTSTRAP__" +set "BOOTSTRAP=" +set "STAGED_BOOTSTRAP=" +set "SOURCE_PS_LAUNCHER=%HANDOFF_DIR%\__POWERSHELL_LAUNCHER__" +set "PS_LAUNCHER=" +set "STAGED_PS_LAUNCHER=" +set "POWERSHELL=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" +set "EXPECTED_SHA256=__SHA256__" +set "EXPECTED_SIZE=__SIZE__" +set "EXPECTED_PS_SHA256=__PS_SHA256__" +set "EXPECTED_PS_SIZE=__PS_SIZE__" +set "COMMAND_LAUNCHER=%HANDOFF_DIR%\__COMMAND_LAUNCHER__" +if not defined LOCALAPPDATA goto failure +set "CACHE_DIR=%LOCALAPPDATA%\RemoteDevSkillkit\cache" +set "ATTEMPT_ROOT=%LOCALAPPDATA%\RemoteDevSkillkit\attempts" +call :load_sid || goto failure + +if /I "%~1"=="--native" goto native_arguments +if not "%~1"=="" goto failure +goto broker + +:native_arguments +if /I not "%~2"=="--attempt-dir" goto failure +set "ATTEMPT_DIR=%~3" +if not defined ATTEMPT_DIR goto failure +if not "%~4"=="" goto failure +goto native + +:broker +call :prepare_bootstrap || goto failure +set "TARGET=%COMMAND_LAUNCHER%" +set "TARGET_KIND=file" +call :reject_unsafe_path || goto failure +call :protect_file || goto failure +call :verify_private_target || goto failure +set "ATTEMPT_TRIES=0" + +:create_attempt +set /a ATTEMPT_TRIES+=1 >nul +if %ATTEMPT_TRIES% GTR 32 goto failure +set "ATTEMPT_DIR=%ATTEMPT_ROOT%\%RANDOM%-%RANDOM%-%RANDOM%" +"%BOOTSTRAP%" layered-run attempt-check --attempt-dir "%ATTEMPT_DIR%" --launcher powershell --create >nul 2>&1 +if errorlevel 2 goto create_attempt +if errorlevel 1 goto failure +set "BROKER_EXIT=1" +set "SKIP_PS=" +call :prepare_powershell +if errorlevel 1 set "SKIP_PS=1" +if not exist "%POWERSHELL%" set "SKIP_PS=1" +if not defined SKIP_PS call :verify_powershell +if not defined SKIP_PS if errorlevel 1 set "SKIP_PS=1" + +if not defined SKIP_PS "%POWERSHELL%" -NoLogo -NoProfile -File "%PS_LAUNCHER%" -AttemptDir "%ATTEMPT_DIR%" -Launcher "powershell" -Brokered +if not defined SKIP_PS set "BROKER_EXIT=%ERRORLEVEL%" +if not defined SKIP_PS if "%BROKER_EXIT%"=="0" goto success +if not defined SKIP_PS call :verify_bootstrap +if not defined SKIP_PS if errorlevel 1 goto broker_exhausted +if not defined SKIP_PS "%BOOTSTRAP%" layered-run attempt-check --attempt-dir "%ATTEMPT_DIR%" --launcher powershell-bypass >nul 2>&1 +if not defined SKIP_PS if errorlevel 1 goto broker_exhausted + +if not defined SKIP_PS call :verify_powershell +if not defined SKIP_PS if errorlevel 1 set "SKIP_PS=1" +if not defined SKIP_PS "%POWERSHELL%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PS_LAUNCHER%" -AttemptDir "%ATTEMPT_DIR%" -Launcher "powershell-bypass" -Brokered +if not defined SKIP_PS set "BROKER_EXIT=%ERRORLEVEL%" +if not defined SKIP_PS if "%BROKER_EXIT%"=="0" goto success +call :verify_bootstrap || goto broker_exhausted +goto native + +:broker_exhausted +call :cleanup_bootstrap >nul 2>&1 +if errorlevel 1 set "BROKER_EXIT=1" +echo [rdev] core_started core_exited; no second core. +echo [rdev] Run the signed archive recovery profile: ARCHIVE-RECOVERY.txt. +exit /b %BROKER_EXIT% + +:native +call :prepare_bootstrap || goto failure +call :verify_bootstrap || goto failure +"%BOOTSTRAP%" layered-run attempt-check --attempt-dir "%ATTEMPT_DIR%" --launcher cmd >nul 2>&1 || goto failure +set "TARGET=%HANDOFF_DIR%" +set "TARGET_KIND=directory" +call :reject_unsafe_path || goto failure +call :protect_directory || goto failure +call :verify_bootstrap || goto failure +set "TARGET=%COMMAND_LAUNCHER%" +set "TARGET_KIND=file" +call :reject_unsafe_path || goto failure +call :protect_file || goto failure +call :verify_private_target || goto failure +call :verify_bootstrap || goto failure +"%BOOTSTRAP%" layered-run attempt-check --attempt-dir "%ATTEMPT_DIR%" --launcher cmd >nul 2>&1 || goto failure +"%BOOTSTRAP%" layered-run --manifest-url __LAYERED_MANIFEST_URL__ --root-public-key __RELEASE_ROOT__ --expected-release-version __RELEASE_VERSION__ --platform windows/amd64 --cache-dir "%CACHE_DIR%" --attempt-dir "%ATTEMPT_DIR%" --launcher cmd --mode temporary -- serve --mode temporary --manifest-url __JOIN_MANIFEST_URL__ --manifest-root-public-key __JOIN_MANIFEST_ROOT__ --transport auto --once=false --max-tasks 0 +set "LAYERED_EXIT=%ERRORLEVEL%" +call :cleanup_bootstrap >nul 2>&1 +if errorlevel 1 set "LAYERED_EXIT=1" +if not "%LAYERED_EXIT%"=="0" echo [rdev] Run the signed archive recovery profile: ARCHIVE-RECOVERY.txt. +exit /b %LAYERED_EXIT% + +:success +call :cleanup_bootstrap >nul 2>&1 || goto failure +exit /b 0 + +:failure +call :cleanup_bootstrap >nul 2>&1 +echo [rdev] Layered bootstrap preparation failed; refusing automatic archive fallback. +echo [rdev] Run the signed archive recovery profile: ARCHIVE-RECOVERY.txt. +exit /b 1 + +:prepare_bootstrap +if defined BOOTSTRAP exit /b 0 +set "TARGET=%HANDOFF_DIR%" +set "TARGET_KIND=directory" +call :protect_directory || exit /b 1 +set "TARGET=%SOURCE_BOOTSTRAP%" +set "EXPECTED_FILE_SHA=%EXPECTED_SHA256%" +set "EXPECTED_FILE_SIZE=%EXPECTED_SIZE%" +call :verify_digest || exit /b 1 +set "STAGE_TRIES=0" +:stage_bootstrap +set /a STAGE_TRIES+=1 >nul +if %STAGE_TRIES% GTR 32 exit /b 1 +set "STAGED_BOOTSTRAP=%HANDOFF_DIR%\.rdev-bootstrap-%RANDOM%-%RANDOM%.exe" +if exist "%STAGED_BOOTSTRAP%" goto stage_bootstrap +copy /b "%SOURCE_BOOTSTRAP%" "%STAGED_BOOTSTRAP%" >nul 2>&1 || goto prepare_bootstrap_failure +set "TARGET=%STAGED_BOOTSTRAP%" +set "EXPECTED_FILE_SHA=%EXPECTED_SHA256%" +set "EXPECTED_FILE_SIZE=%EXPECTED_SIZE%" +call :verify_digest || goto prepare_bootstrap_failure +set "BOOTSTRAP=%STAGED_BOOTSTRAP%" +set "TARGET=%BOOTSTRAP%" +call :verify_private_target || goto prepare_bootstrap_failure +exit /b 0 + +:prepare_bootstrap_failure +call :cleanup_bootstrap >nul 2>&1 +exit /b 1 + +:cleanup_bootstrap +set "CLEANUP_EXIT=0" +call :cleanup_powershell >nul 2>&1 +if errorlevel 1 set "CLEANUP_EXIT=1" +if not defined STAGED_BOOTSTRAP exit /b %CLEANUP_EXIT% +del /f /q "%STAGED_BOOTSTRAP%" >nul 2>&1 +if exist "%STAGED_BOOTSTRAP%" exit /b 1 +set "BOOTSTRAP=" +set "STAGED_BOOTSTRAP=" +exit /b %CLEANUP_EXIT% + +:prepare_powershell +if defined PS_LAUNCHER exit /b 0 +set "TARGET=%SOURCE_PS_LAUNCHER%" +set "EXPECTED_FILE_SHA=%EXPECTED_PS_SHA256%" +set "EXPECTED_FILE_SIZE=%EXPECTED_PS_SIZE%" +call :verify_digest || exit /b 1 +set "PS_STAGE_TRIES=0" +:stage_powershell +set /a PS_STAGE_TRIES+=1 >nul +if %PS_STAGE_TRIES% GTR 32 exit /b 1 +set "STAGED_PS_LAUNCHER=%HANDOFF_DIR%\.Start-ConnectionEntry-%RANDOM%-%RANDOM%.ps1" +if exist "%STAGED_PS_LAUNCHER%" goto stage_powershell +copy /b "%SOURCE_PS_LAUNCHER%" "%STAGED_PS_LAUNCHER%" >nul 2>&1 || goto prepare_powershell_failure +set "TARGET=%STAGED_PS_LAUNCHER%" +set "EXPECTED_FILE_SHA=%EXPECTED_PS_SHA256%" +set "EXPECTED_FILE_SIZE=%EXPECTED_PS_SIZE%" +call :verify_digest || goto prepare_powershell_failure +set "PS_LAUNCHER=%STAGED_PS_LAUNCHER%" +exit /b 0 + +:prepare_powershell_failure +call :cleanup_powershell >nul 2>&1 +exit /b 1 + +:cleanup_powershell +if not defined STAGED_PS_LAUNCHER exit /b 0 +del /f /q "%STAGED_PS_LAUNCHER%" >nul 2>&1 +if exist "%STAGED_PS_LAUNCHER%" exit /b 1 +set "PS_LAUNCHER=" +set "STAGED_PS_LAUNCHER=" +exit /b 0 + +:verify_bootstrap +set "TARGET=%BOOTSTRAP%" +set "EXPECTED_FILE_SHA=%EXPECTED_SHA256%" +set "EXPECTED_FILE_SIZE=%EXPECTED_SIZE%" +call :verify_file +exit /b %ERRORLEVEL% + +:verify_powershell +set "TARGET=%PS_LAUNCHER%" +set "EXPECTED_FILE_SHA=%EXPECTED_PS_SHA256%" +set "EXPECTED_FILE_SIZE=%EXPECTED_PS_SIZE%" +call :verify_file +exit /b %ERRORLEVEL% + +:verify_file +call :verify_digest || exit /b 1 +call :verify_private_target || exit /b 1 +exit /b 0 + +:verify_digest +set "TARGET_KIND=file" +call :reject_unsafe_path || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /reset >nul || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /inheritance:r /grant:r "*%CURRENT_SID%:F" "*S-1-5-18:F" "*S-1-5-32-544:F" >nul || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /setowner "*%CURRENT_SID%" >nul || exit /b 1 +set "ACTUAL_SIZE=" +for %%I in ("%TARGET%") do set "ACTUAL_SIZE=%%~zI" +if not "%ACTUAL_SIZE%"=="%EXPECTED_FILE_SIZE%" exit /b 1 +"%SystemRoot%\System32\certutil.exe" -hashfile "%TARGET%" SHA256 2>nul | "%SystemRoot%\System32\findstr.exe" /i /x /c:"%EXPECTED_FILE_SHA%" >nul +if errorlevel 1 exit /b 1 +exit /b 0 + +:protect_directory +set "TARGET_KIND=directory" +call :reject_unsafe_path || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /reset >nul || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /inheritance:r /grant:r "*%CURRENT_SID%:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" >nul || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /setowner "*%CURRENT_SID%" >nul || exit /b 1 +exit /b 0 + +:protect_file +set "TARGET_KIND=file" +call :reject_unsafe_path || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /reset >nul || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /inheritance:r /grant:r "*%CURRENT_SID%:F" "*S-1-5-18:F" "*S-1-5-32-544:F" >nul || exit /b 1 +"%SystemRoot%\System32\icacls.exe" "%TARGET%" /setowner "*%CURRENT_SID%" >nul || exit /b 1 +exit /b 0 + +:verify_private_target +"%BOOTSTRAP%" layered-run private-path-check --path "%TARGET%" --kind file >nul 2>&1 || exit /b 1 +if /I not "%TARGET%"=="%BOOTSTRAP%" exit /b 0 +"%BOOTSTRAP%" layered-run private-path-check --path "%HANDOFF_DIR%" --kind directory >nul 2>&1 || exit /b 1 +exit /b 0 + +:reject_unsafe_path +if "%TARGET:~0,2%"=="\\" exit /b 1 +if /I not "%TARGET_KIND%"=="file" if /I not "%TARGET_KIND%"=="directory" exit /b 1 +if not exist "%TARGET%" exit /b 1 +set "CURSOR=" +set "ATTRS=" +for %%I in ("%TARGET%") do set "CURSOR=%%~fI"& set "ATTRS=%%~aI" +if not defined CURSOR exit /b 1 +if not defined ATTRS exit /b 1 +if not "%ATTRS:l=%"=="%ATTRS%" exit /b 1 +if /I "%TARGET_KIND%"=="directory" if /I not "%ATTRS:~0,1%"=="d" exit /b 1 +if /I "%TARGET_KIND%"=="file" if /I "%ATTRS:~0,1%"=="d" exit /b 1 +:reject_unsafe_path_loop +set "PARENT=" +for %%I in ("%CURSOR%\..") do set "PARENT=%%~fI" +if not defined PARENT exit /b 1 +if /I "%PARENT%"=="%CURSOR%" exit /b 0 +set "CURSOR=%PARENT%" +set "ATTRS=" +for %%I in ("%CURSOR%") do set "ATTRS=%%~aI" +if not defined ATTRS exit /b 1 +if not "%ATTRS:l=%"=="%ATTRS%" exit /b 1 +goto :reject_unsafe_path_loop + +:load_sid +set "CURRENT_SID=" +for /f "tokens=2 delims=," %%S in ('"%SystemRoot%\System32\whoami.exe" /user /fo csv /nh') do if not defined CURRENT_SID set "CURRENT_SID=%%~S" +if not defined CURRENT_SID exit /b 1 +if not "%CURRENT_SID:~0,4%"=="S-1-" exit /b 1 +exit /b 0 +` + rendered := strings.NewReplacer( + "__BOOTSTRAP__", windowsLayeredBootstrapName, + "__POWERSHELL_LAUNCHER__", windowsLayeredLauncherName, + "__COMMAND_LAUNCHER__", windowsLayeredCommandLauncherName, + "__SHA256__", handoff.manifest.ArtifactSHA256, + "__SIZE__", strconv.FormatInt(handoff.manifest.ArtifactSize, 10), + "__PS_SHA256__", expectedPowerShellSHA256, + "__PS_SIZE__", strconv.Itoa(expectedPowerShellSize), + "__LAYERED_MANIFEST_URL__", quoteValidatedWindowsCommandArgument(handoff.layeredAssetsManifestURL), + "__RELEASE_ROOT__", quoteValidatedWindowsCommandArgument(handoff.releaseRootPublicKey), + "__RELEASE_VERSION__", quoteValidatedWindowsCommandArgument(handoff.releaseVersion), + "__JOIN_MANIFEST_URL__", quoteValidatedWindowsCommandArgument(handoff.joinManifestURL), + "__JOIN_MANIFEST_ROOT__", quoteValidatedWindowsCommandArgument(handoff.joinManifestRoot), + ).Replace(launcher) + return strings.ReplaceAll(rendered, "\n", "\r\n") +} + +func quoteValidatedWindowsCommandArgument(value string) string { + return `"` + value + `"` +} + +func powerShellSingleQuoted(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} diff --git a/internal/connectionentry/windows_layered_archive.go b/internal/connectionentry/windows_layered_archive.go new file mode 100644 index 0000000..3df918c --- /dev/null +++ b/internal/connectionentry/windows_layered_archive.go @@ -0,0 +1,329 @@ +package connectionentry + +import ( + "archive/zip" + "compress/flate" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const maxWindowsLayeredHandoffBytes int64 = 1 << 20 + +type windowsLayeredArchiveFile struct { + name string + content []byte +} + +type windowsLayeredArchiveReport struct { + Path string + SizeBytes int64 + SHA256 string + Private bool + PrivacyDetail string +} + +type windowsLayeredArchiveExpectation struct { + identity windowsLayeredArchiveFileIdentity + sizeBytes int64 + sha256 string +} + +type pendingWindowsLayeredArchive struct { + temporaryPath string + publishedPath string + temporary *os.File + expectation windowsLayeredArchiveExpectation + report windowsLayeredArchiveReport +} + +var ( + windowsLayeredArchivePublishedCleanup = cleanupPublishedWindowsLayeredArchive + windowsLayeredArchivePostPublishHook func(string) error +) + +func writeWindowsLayeredArchive(path string, generatedAt time.Time, files []windowsLayeredArchiveFile) (windowsLayeredArchiveReport, error) { + pending, _, err := prepareWindowsLayeredArchive(filepath.Dir(path), filepath.Base(path), generatedAt, files) + if err != nil { + return windowsLayeredArchiveReport{}, err + } + return pending.publish(path) +} + +func prepareWindowsLayeredArchive(temporaryDir, temporaryBase string, generatedAt time.Time, files []windowsLayeredArchiveFile) (_ *pendingWindowsLayeredArchive, _ windowsLayeredArchiveReport, resultErr error) { + entries := append([]windowsLayeredArchiveFile(nil), files...) + sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name }) + seen := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + normalizedName, ok := windowsSafeArchiveBasename(entry.name) + if !ok { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("unsafe Windows layered archive entry name %q", entry.name) + } + if _, ok := seen[normalizedName]; ok { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("duplicate Windows layered archive entry name %q", entry.name) + } + seen[normalizedName] = struct{}{} + } + + temporary, err := createWindowsLayeredArchiveTempFile(temporaryDir, temporaryBase) + if err != nil { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("create Windows layered archive: %w", err) + } + temporaryPath := temporary.Name() + retainTemporary := false + defer func() { + if !retainTemporary { + if err := temporary.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close Windows layered archive during cleanup: %w", err)) + } + if err := os.Remove(temporaryPath); err != nil && !os.IsNotExist(err) { + resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary Windows layered archive: %w", err)) + } + } + }() + + writer := zip.NewWriter(temporary) + writer.RegisterCompressor(zip.Deflate, func(destination io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(destination, flate.BestCompression) + }) + for _, entry := range entries { + header := &zip.FileHeader{Name: entry.name, Method: zip.Deflate} + header.SetModTime(generatedAt.UTC()) + header.SetMode(0o600) + destination, err := writer.CreateHeader(header) + if err != nil { + closeErr := writer.Close() + return nil, windowsLayeredArchiveReport{}, errors.Join( + fmt.Errorf("create Windows layered archive entry %q: %w", entry.name, err), + wrapWindowsLayeredArchiveCloseError(closeErr), + ) + } + if _, err := destination.Write(entry.content); err != nil { + closeErr := writer.Close() + return nil, windowsLayeredArchiveReport{}, errors.Join( + fmt.Errorf("write Windows layered archive entry %q: %w", entry.name, err), + wrapWindowsLayeredArchiveCloseError(closeErr), + ) + } + } + if err := writer.Close(); err != nil { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("close Windows layered ZIP: %w", err) + } + if err := temporary.Sync(); err != nil { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("sync Windows layered archive: %w", err) + } + info, err := temporary.Stat() + if err != nil { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("measure Windows layered archive: %w", err) + } + if !info.Mode().IsRegular() { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("Windows layered archive is not a regular file") + } + if info.Size() > maxWindowsLayeredHandoffBytes { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("Windows layered archive size %d exceeds %d bytes", info.Size(), maxWindowsLayeredHandoffBytes) + } + digest, err := hashWindowsLayeredArchiveHandle(temporary) + if err != nil { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("hash Windows layered archive: %w", err) + } + identity, privacyDetail, err := validateWindowsLayeredArchiveHandle(temporary) + if err != nil { + return nil, windowsLayeredArchiveReport{}, fmt.Errorf("validate Windows layered archive before publication: %w", err) + } + report := windowsLayeredArchiveReport{ + SizeBytes: info.Size(), + SHA256: digest, + Private: true, + PrivacyDetail: privacyDetail, + } + retainTemporary = true + return &pendingWindowsLayeredArchive{ + temporaryPath: temporaryPath, + temporary: temporary, + expectation: windowsLayeredArchiveExpectation{ + identity: identity, + sizeBytes: info.Size(), + sha256: digest, + }, + report: report, + }, report, nil +} + +func (pending *pendingWindowsLayeredArchive) publish(path string) (windowsLayeredArchiveReport, error) { + if pending == nil || pending.temporary == nil { + return windowsLayeredArchiveReport{}, fmt.Errorf("Windows layered archive publication has no guarded temporary file") + } + temporary := pending.temporary + expectation := pending.expectation + if _, err := os.Lstat(path); err == nil { + return windowsLayeredArchiveReport{}, pending.publicationFailure(fmt.Errorf("Windows layered archive destination already exists: %s", path)) + } else if !os.IsNotExist(err) { + return windowsLayeredArchiveReport{}, pending.publicationFailure(fmt.Errorf("inspect Windows layered archive destination: %w", err)) + } + published, err := publishWindowsLayeredArchiveHandle(temporary, path) + if published { + pending.temporaryPath = "" + pending.publishedPath = path + } + if err != nil { + return windowsLayeredArchiveReport{}, pending.publicationFailure(fmt.Errorf("publish Windows layered archive: %w", err)) + } + if windowsLayeredArchivePostPublishHook != nil { + if err := windowsLayeredArchivePostPublishHook(path); err != nil { + return windowsLayeredArchiveReport{}, pending.publicationFailure(fmt.Errorf("post-publication Windows layered archive check: %w", err)) + } + } + privacyDetail, err := validatePublishedWindowsLayeredArchive(path, expectation) + if err != nil { + return windowsLayeredArchiveReport{}, pending.publicationFailure(fmt.Errorf("validate published Windows layered archive: %w", err)) + } + if err := temporary.Close(); err != nil { + return windowsLayeredArchiveReport{}, pending.publicationFailure(fmt.Errorf("close validated Windows layered archive creation handle: %w", err)) + } + pending.temporary = nil + pending.temporaryPath = "" + pending.publishedPath = "" + report := pending.report + report.Path = path + report.PrivacyDetail = privacyDetail + return report, nil +} + +func (pending *pendingWindowsLayeredArchive) publicationFailure(validationErr error) error { + cleanupErr := pending.discard() + if cleanupErr != nil { + cleanupErr = fmt.Errorf("clean up invalid published Windows layered archive: %w", cleanupErr) + } + return errors.Join(validationErr, cleanupErr) +} + +func (pending *pendingWindowsLayeredArchive) discard() error { + if pending == nil { + return nil + } + var cleanupErrs []error + if pending.temporary != nil { + if err := pending.temporary.Truncate(0); err != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("truncate invalid Windows layered archive creation handle: %w", err)) + } + if err := pending.temporary.Close(); err != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("close invalid Windows layered archive creation handle: %w", err)) + } + pending.temporary = nil + } + cleanupPath := pending.publishedPath + if cleanupPath == "" { + cleanupPath = pending.temporaryPath + } + pending.publishedPath = "" + pending.temporaryPath = "" + if cleanupPath != "" { + if err := windowsLayeredArchivePublishedCleanup(cleanupPath); err != nil { + cleanupErrs = append(cleanupErrs, err) + } + } + return errors.Join(cleanupErrs...) +} + +func hashWindowsLayeredArchiveHandle(file *os.File) (string, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", err + } + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func validatePublishedWindowsLayeredArchive(path string, expected windowsLayeredArchiveExpectation) (_ string, resultErr error) { + file, err := openPublishedWindowsLayeredArchive(path) + if err != nil { + return "", fmt.Errorf("open published Windows layered archive: %w", err) + } + defer func() { + if err := file.Close(); err != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("close published Windows layered archive validation handle: %w", err)) + } + }() + identity, privacyDetail, err := validateWindowsLayeredArchiveHandle(file) + if err != nil { + return "", err + } + if !sameWindowsLayeredArchiveFileIdentity(identity, expected.identity) { + return "", fmt.Errorf("published Windows layered archive file identity changed") + } + info, err := file.Stat() + if err != nil { + return "", fmt.Errorf("measure published Windows layered archive: %w", err) + } + if !info.Mode().IsRegular() || info.Size() != expected.sizeBytes { + return "", fmt.Errorf("published Windows layered archive size or type changed") + } + digest, err := hashWindowsLayeredArchiveHandle(file) + if err != nil { + return "", fmt.Errorf("hash published Windows layered archive: %w", err) + } + if digest != expected.sha256 { + return "", fmt.Errorf("published Windows layered archive digest changed") + } + return privacyDetail, nil +} + +func wrapWindowsLayeredArchiveCloseError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("close Windows layered ZIP after entry failure: %w", err) +} + +func cleanupPublishedWindowsLayeredArchive(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove published Windows layered archive: %w", err) + } + return nil +} + +func windowsSafeArchiveBasename(name string) (string, bool) { + if name == "" || name == "." || name == ".." || filepath.IsAbs(name) || filepath.Base(name) != name || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") { + return "", false + } + for _, character := range name { + if character < 0x20 || character == 0x7f || strings.ContainsRune(`/\:<>"|?*`, character) { + return "", false + } + } + + normalizedName := strings.ToUpper(strings.TrimRight(name, ". ")) + deviceName := normalizedName + if extension := strings.IndexByte(deviceName, '.'); extension >= 0 { + deviceName = deviceName[:extension] + } + deviceName = strings.TrimRight(deviceName, " ") + deviceName = strings.Map(func(character rune) rune { + switch character { + case '¹': + return '1' + case '²': + return '2' + case '³': + return '3' + default: + return character + } + }, deviceName) + if deviceName == "CON" || deviceName == "PRN" || deviceName == "AUX" || deviceName == "NUL" || deviceName == "CLOCK$" { + return "", false + } + if len(deviceName) == 4 && (strings.HasPrefix(deviceName, "COM") || strings.HasPrefix(deviceName, "LPT")) && deviceName[3] >= '1' && deviceName[3] <= '9' { + return "", false + } + return normalizedName, true +} diff --git a/internal/connectionentry/windows_layered_archive_permissions_other.go b/internal/connectionentry/windows_layered_archive_permissions_other.go new file mode 100644 index 0000000..87bbdda --- /dev/null +++ b/internal/connectionentry/windows_layered_archive_permissions_other.go @@ -0,0 +1,79 @@ +//go:build !windows + +package connectionentry + +import ( + "errors" + "fmt" + "os" +) + +type windowsLayeredArchiveFileIdentity struct { + info os.FileInfo +} + +func createWindowsLayeredArchiveTempFile(dir, base string) (*os.File, error) { + file, err := os.CreateTemp(dir, "."+base+".tmp-*") + if err != nil { + return nil, err + } + if err := file.Chmod(0o600); err != nil { + closeErr := file.Close() + removeErr := os.Remove(file.Name()) + return nil, errors.Join( + fmt.Errorf("set archive temp file mode 0600: %w", err), + wrapArchiveTempCleanupError("close", closeErr), + wrapArchiveTempCleanupError("remove", removeErr), + ) + } + return file, nil +} + +func wrapArchiveTempCleanupError(operation string, err error) error { + if err == nil || os.IsNotExist(err) { + return nil + } + return fmt.Errorf("%s archive temp file after protection failure: %w", operation, err) +} + +func validateWindowsLayeredArchiveHandle(file *os.File) (windowsLayeredArchiveFileIdentity, string, error) { + info, err := file.Stat() + if err != nil { + return windowsLayeredArchiveFileIdentity{}, "", err + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return windowsLayeredArchiveFileIdentity{}, "", fmt.Errorf("archive mode is %o, want regular mode 0600", info.Mode().Perm()) + } + return windowsLayeredArchiveFileIdentity{info: info}, "mode 0600", nil +} + +func sameWindowsLayeredArchiveFileIdentity(first, second windowsLayeredArchiveFileIdentity) bool { + return first.info != nil && second.info != nil && os.SameFile(first.info, second.info) +} + +func publishWindowsLayeredArchiveHandle(file *os.File, path string) (bool, error) { + err := os.Rename(file.Name(), path) + return err == nil, err +} + +func openPublishedWindowsLayeredArchive(path string) (*os.File, error) { + return os.Open(path) +} + +func protectWindowsLayeredArchive(path string) (string, error) { + if err := os.Chmod(path, 0o600); err != nil { + return "", err + } + return validateWindowsLayeredArchiveProtection(path) +} + +func validateWindowsLayeredArchiveProtection(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + if info.Mode().Perm() != 0o600 { + return "", fmt.Errorf("archive mode is %o, want 0600", info.Mode().Perm()) + } + return "mode 0600", nil +} diff --git a/internal/connectionentry/windows_layered_archive_permissions_other_test.go b/internal/connectionentry/windows_layered_archive_permissions_other_test.go new file mode 100644 index 0000000..e3968d8 --- /dev/null +++ b/internal/connectionentry/windows_layered_archive_permissions_other_test.go @@ -0,0 +1,74 @@ +//go:build !windows + +package connectionentry + +import ( + "errors" + "os" + "testing" +) + +func assertWindowsLayeredArchivePrivate(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("Windows handoff archive must have mode 0600, got %o", info.Mode().Perm()) + } +} + +func TestProtectWindowsLayeredArchiveUsesPrivateMode(t *testing.T) { + path := t.TempDir() + "/handoff.zip" + if err := os.WriteFile(path, []byte("archive"), 0o666); err != nil { + t.Fatal(err) + } + if _, err := protectWindowsLayeredArchive(path); err != nil { + t.Fatal(err) + } + assertWindowsLayeredArchivePrivate(t, path) +} + +func TestCreateWindowsLayeredArchiveTempFileUsesPrivateMode(t *testing.T) { + file, err := createWindowsLayeredArchiveTempFile(t.TempDir(), "Windows-ConnectionEntry.zip") + if err != nil { + t.Fatal(err) + } + if info, statErr := file.Stat(); statErr != nil { + t.Fatal(statErr) + } else if info.Mode().Perm() != 0o600 { + t.Fatalf("new archive temp file must have mode 0600, got %o", info.Mode().Perm()) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func TestWrapArchiveTempCleanupError(t *testing.T) { + if err := wrapArchiveTempCleanupError("remove", nil); err != nil { + t.Fatalf("nil cleanup error must remain nil: %v", err) + } + sentinel := errors.New("cleanup failed") + if err := wrapArchiveTempCleanupError("remove", sentinel); !errors.Is(err, sentinel) { + t.Fatalf("archive temp cleanup error must be wrapped, got %v", err) + } +} + +func TestValidateWindowsLayeredArchiveHandleRejectsNonPrivateMode(t *testing.T) { + path := t.TempDir() + "/handoff.zip" + if err := os.WriteFile(path, []byte("archive"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if _, _, err := validateWindowsLayeredArchiveHandle(file); err == nil { + t.Fatal("archive handle validation must reject a non-private mode") + } +} diff --git a/internal/connectionentry/windows_layered_archive_permissions_windows.go b/internal/connectionentry/windows_layered_archive_permissions_windows.go new file mode 100644 index 0000000..72b5687 --- /dev/null +++ b/internal/connectionentry/windows_layered_archive_permissions_windows.go @@ -0,0 +1,455 @@ +//go:build windows + +package connectionentry + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "unsafe" + + "golang.org/x/sys/windows" +) + +const windowsLayeredArchivePrivacyDetail = "protected DACL: current user, SYSTEM, Administrators" + +const windowsLayeredArchiveTempCreateAttempts = 128 + +const windowsLayeredArchiveFullControlMask uint32 = windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1ff + +type windowsLayeredArchiveACE struct { + typeID uint8 + flags uint8 + mask uint32 + sid string +} + +type windowsLayeredArchiveProtection struct { + ownerSID string + daclPresent bool + daclProtected bool + daclNull bool + aces []windowsLayeredArchiveACE +} + +type windowsLayeredArchiveFileIdentity struct { + volumeSerialNumber uint32 + fileIndexHigh uint32 + fileIndexLow uint32 +} + +type windowsLayeredArchiveRenameInformation struct { + replaceIfExists uint8 + rootDirectory windows.Handle + fileNameLength uint32 + fileName [1]uint16 +} + +func createWindowsLayeredArchiveTempFile(dir, base string) (*os.File, error) { + if base == "" || filepath.Base(base) != base { + return nil, fmt.Errorf("invalid Windows layered archive temp basename %q", base) + } + currentUser, _, _, err := windowsLayeredArchiveTrustees() + if err != nil { + return nil, err + } + currentUserSID := currentUser.String() + descriptor, err := windows.SecurityDescriptorFromString(fmt.Sprintf( + "O:%sD:P(A;;FA;;;%s)(A;;FA;;;SY)(A;;FA;;;BA)", + currentUserSID, + currentUserSID, + )) + if err != nil { + return nil, fmt.Errorf("create Windows layered archive security descriptor: %w", err) + } + attributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + } + + for attempt := 0; attempt < windowsLayeredArchiveTempCreateAttempts; attempt++ { + var randomSuffix [16]byte + if _, err := rand.Read(randomSuffix[:]); err != nil { + return nil, fmt.Errorf("generate Windows layered archive temp basename: %w", err) + } + path := filepath.Join(dir, "."+base+".tmp-"+hex.EncodeToString(randomSuffix[:])) + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, fmt.Errorf("encode Windows layered archive temp path: %w", err) + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.GENERIC_WRITE|windows.DELETE, + windows.FILE_SHARE_READ, + &attributes, + windows.CREATE_NEW, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + runtime.KeepAlive(descriptor) + if errors.Is(err, windows.ERROR_FILE_EXISTS) || errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + continue + } + if err != nil { + return nil, fmt.Errorf("atomically create protected Windows layered archive temp file: %w", err) + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + closeErr := windows.CloseHandle(handle) + removeErr := os.Remove(path) + return nil, errors.Join( + fmt.Errorf("wrap protected Windows layered archive temp handle"), + wrapWindowsLayeredArchiveHandleCloseError(closeErr), + wrapWindowsLayeredArchiveCreateCleanupError("remove", removeErr), + ) + } + if _, _, err := validateWindowsLayeredArchiveHandle(file); err != nil { + closeErr := file.Close() + removeErr := os.Remove(path) + return nil, errors.Join( + fmt.Errorf("validate new Windows layered archive temp protection: %w", err), + wrapWindowsLayeredArchiveCreateCleanupError("close", closeErr), + wrapWindowsLayeredArchiveCreateCleanupError("remove", removeErr), + ) + } + return file, nil + } + return nil, fmt.Errorf("create unique Windows layered archive temp file after %d attempts", windowsLayeredArchiveTempCreateAttempts) +} + +func validateWindowsLayeredArchiveHandle(file *os.File) (windowsLayeredArchiveFileIdentity, string, error) { + handle := windows.Handle(file.Fd()) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return windowsLayeredArchiveFileIdentity{}, "", fmt.Errorf("inspect Windows layered archive file identity: %w", err) + } + descriptor, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return windowsLayeredArchiveFileIdentity{}, "", fmt.Errorf("inspect Windows layered archive handle security descriptor: %w", err) + } + protection, err := windowsLayeredArchiveProtectionFromDescriptor(descriptor) + if err != nil { + return windowsLayeredArchiveFileIdentity{}, "", err + } + if err := validateWindowsLayeredArchiveProtectionState(protection); err != nil { + return windowsLayeredArchiveFileIdentity{}, "", err + } + runtime.KeepAlive(file) + return windowsLayeredArchiveFileIdentity{ + volumeSerialNumber: info.VolumeSerialNumber, + fileIndexHigh: info.FileIndexHigh, + fileIndexLow: info.FileIndexLow, + }, windowsLayeredArchivePrivacyDetail, nil +} + +func sameWindowsLayeredArchiveFileIdentity(first, second windowsLayeredArchiveFileIdentity) bool { + return first == second +} + +func publishWindowsLayeredArchiveHandle(file *os.File, path string) (bool, error) { + directory := filepath.Clean(filepath.Dir(path)) + directoryPointer, err := windows.UTF16PtrFromString(directory) + if err != nil { + return false, fmt.Errorf("encode Windows layered archive publication directory: %w", err) + } + directoryHandle, err := windows.CreateFile( + directoryPointer, + windows.FILE_TRAVERSE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return false, fmt.Errorf("open Windows layered archive publication directory: %w", err) + } + name, err := windows.UTF16FromString(filepath.Base(path)) + if err != nil { + closeErr := windows.CloseHandle(directoryHandle) + return false, errors.Join( + fmt.Errorf("encode Windows layered archive publication name: %w", err), + wrapWindowsLayeredArchiveDirectoryCloseError(closeErr), + ) + } + fileNameLength := (len(name) - 1) * 2 + var layout windowsLayeredArchiveRenameInformation + bufferSize := int(unsafe.Offsetof(layout.fileName)) + fileNameLength + buffer := make([]byte, bufferSize) + rename := (*windowsLayeredArchiveRenameInformation)(unsafe.Pointer(&buffer[0])) + rename.rootDirectory = directoryHandle + rename.fileNameLength = uint32(fileNameLength) + copy(unsafe.Slice(&rename.fileName[0], fileNameLength/2), name[:len(name)-1]) + var status windows.IO_STATUS_BLOCK + err = windows.NtSetInformationFile( + windows.Handle(file.Fd()), + &status, + &buffer[0], + uint32(len(buffer)), + windows.FileRenameInformation, + ) + runtime.KeepAlive(file) + closeErr := windows.CloseHandle(directoryHandle) + if err != nil { + return false, errors.Join( + fmt.Errorf("rename Windows layered archive by handle: %w", err), + wrapWindowsLayeredArchiveDirectoryCloseError(closeErr), + ) + } + return true, wrapWindowsLayeredArchiveDirectoryCloseError(closeErr) +} + +func wrapWindowsLayeredArchiveDirectoryCloseError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("close Windows layered archive publication directory: %w", err) +} + +func openPublishedWindowsLayeredArchive(path string) (*os.File, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, fmt.Errorf("encode published Windows layered archive path: %w", err) + } + handle, err := windows.CreateFile( + pointer, + windows.GENERIC_READ|windows.READ_CONTROL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(handle), path) + if file == nil { + closeErr := windows.CloseHandle(handle) + return nil, errors.Join(fmt.Errorf("wrap published Windows layered archive validation handle"), wrapWindowsLayeredArchiveHandleCloseError(closeErr)) + } + return file, nil +} + +func wrapWindowsLayeredArchiveHandleCloseError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("close protected Windows layered archive temp handle: %w", err) +} + +func wrapWindowsLayeredArchiveCreateCleanupError(operation string, err error) error { + if err == nil || os.IsNotExist(err) { + return nil + } + return fmt.Errorf("%s new Windows layered archive temp file: %w", operation, err) +} + +func protectWindowsLayeredArchive(path string) (string, error) { + currentUser, administrators, system, err := windowsLayeredArchiveTrustees() + if err != nil { + return "", err + } + var pinner runtime.Pinner + defer pinner.Unpin() + for _, sid := range []*windows.SID{currentUser, administrators, system} { + pinner.Pin(sid) + } + dacl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ + windowsLayeredArchiveAccess(currentUser, windows.TRUSTEE_IS_USER), + windowsLayeredArchiveAccess(system, windows.TRUSTEE_IS_USER), + windowsLayeredArchiveAccess(administrators, windows.TRUSTEE_IS_GROUP), + }, nil) + if err != nil { + return "", fmt.Errorf("create Windows layered archive DACL: %w", err) + } + + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", fmt.Errorf("encode Windows layered archive path: %w", err) + } + handle, err := windows.CreateFile( + pointer, + windows.READ_CONTROL|windows.WRITE_DAC, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return "", fmt.Errorf("open Windows layered archive security descriptor: %w", err) + } + defer windows.CloseHandle(handle) + if err := windows.SetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + dacl, + nil, + ); err != nil { + return "", fmt.Errorf("set Windows layered archive DACL: %w", err) + } + return validateWindowsLayeredArchiveProtection(path) +} + +func windowsLayeredArchiveAccess(sid *windows.SID, trusteeType windows.TRUSTEE_TYPE) windows.EXPLICIT_ACCESS { + return windows.EXPLICIT_ACCESS{ + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.GRANT_ACCESS, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: trusteeType, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + } +} + +func windowsLayeredArchiveTrustees() (currentUser, administrators, system *windows.SID, err error) { + token, err := windows.OpenCurrentProcessToken() + if err != nil { + return nil, nil, nil, fmt.Errorf("open current Windows process token: %w", err) + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return nil, nil, nil, fmt.Errorf("read current Windows process user: %w", err) + } + if user == nil || user.User.Sid == nil || !user.User.Sid.IsValid() { + return nil, nil, nil, fmt.Errorf("read current Windows process user: invalid SID") + } + currentUser, err = user.User.Sid.Copy() + if err != nil { + return nil, nil, nil, fmt.Errorf("copy current Windows process user SID: %w", err) + } + administrators, err = windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return nil, nil, nil, fmt.Errorf("create Windows Administrators SID: %w", err) + } + system, err = windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return nil, nil, nil, fmt.Errorf("create Windows SYSTEM SID: %w", err) + } + return currentUser, administrators, system, nil +} + +func validateWindowsLayeredArchiveProtection(path string) (string, error) { + protection, err := inspectWindowsLayeredArchiveProtection(path) + if err != nil { + return "", err + } + if err := validateWindowsLayeredArchiveProtectionState(protection); err != nil { + return "", err + } + return windowsLayeredArchivePrivacyDetail, nil +} + +func inspectWindowsLayeredArchiveProtection(path string) (windowsLayeredArchiveProtection, error) { + descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return windowsLayeredArchiveProtection{}, fmt.Errorf("inspect Windows layered archive security descriptor: %w", err) + } + return windowsLayeredArchiveProtectionFromDescriptor(descriptor) +} + +func windowsLayeredArchiveProtectionFromDescriptor(descriptor *windows.SECURITY_DESCRIPTOR) (windowsLayeredArchiveProtection, error) { + if descriptor == nil || !descriptor.IsValid() { + return windowsLayeredArchiveProtection{}, fmt.Errorf("inspect Windows layered archive security descriptor: invalid descriptor") + } + control, _, err := descriptor.Control() + if err != nil { + return windowsLayeredArchiveProtection{}, fmt.Errorf("inspect Windows layered archive DACL control: %w", err) + } + owner, _, err := descriptor.Owner() + if err != nil || owner == nil || !owner.IsValid() { + return windowsLayeredArchiveProtection{}, fmt.Errorf("inspect Windows layered archive owner: %w", err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + return windowsLayeredArchiveProtection{}, fmt.Errorf("inspect Windows layered archive DACL: %w", err) + } + protection := windowsLayeredArchiveProtection{ + ownerSID: owner.String(), + daclPresent: control&windows.SE_DACL_PRESENT != 0, + daclProtected: control&windows.SE_DACL_PROTECTED != 0, + daclNull: dacl == nil, + } + if dacl == nil { + return protection, nil + } + protection.aces = make([]windowsLayeredArchiveACE, 0, dacl.AceCount) + for index := uint16(0); index < dacl.AceCount; index++ { + ace, err := readWindowsLayeredArchiveACE(dacl, uint32(index)) + if err != nil { + return windowsLayeredArchiveProtection{}, err + } + protection.aces = append(protection.aces, ace) + } + return protection, nil +} + +func readWindowsLayeredArchiveACE(dacl *windows.ACL, index uint32) (windowsLayeredArchiveACE, error) { + var raw *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &raw); err != nil { + return windowsLayeredArchiveACE{}, fmt.Errorf("read Windows layered archive ACE %d: %w", index, err) + } + if raw == nil { + return windowsLayeredArchiveACE{}, fmt.Errorf("read Windows layered archive ACE %d: nil ACE", index) + } + sidOffset := unsafe.Offsetof(raw.SidStart) + aceSize := uintptr(raw.Header.AceSize) + if aceSize < sidOffset+8 { + return windowsLayeredArchiveACE{}, fmt.Errorf("read Windows layered archive ACE %d: malformed SID", index) + } + sid := (*windows.SID)(unsafe.Pointer(&raw.SidStart)) + if !sid.IsValid() || uintptr(sid.Len()) > aceSize-sidOffset { + return windowsLayeredArchiveACE{}, fmt.Errorf("read Windows layered archive ACE %d: invalid SID", index) + } + return windowsLayeredArchiveACE{ + typeID: raw.Header.AceType, + flags: raw.Header.AceFlags, + mask: uint32(raw.Mask), + sid: sid.String(), + }, nil +} + +func validateWindowsLayeredArchiveProtectionState(protection windowsLayeredArchiveProtection) error { + if !protection.daclPresent || protection.daclNull || !protection.daclProtected { + return fmt.Errorf("Windows layered archive DACL must be present, non-null, and protected") + } + currentUser, administrators, system, err := windowsLayeredArchiveTrustees() + if err != nil { + return err + } + trusted := map[string]struct{}{ + currentUser.String(): {}, + administrators.String(): {}, + system.String(): {}, + } + if _, ok := trusted[protection.ownerSID]; !ok { + return fmt.Errorf("Windows layered archive owner is not trusted") + } + if len(protection.aces) != len(trusted) { + return fmt.Errorf("Windows layered archive DACL must contain exactly three ACEs") + } + seen := make(map[string]struct{}, len(protection.aces)) + for _, ace := range protection.aces { + if ace.typeID != windows.ACCESS_ALLOWED_ACE_TYPE || ace.flags != 0 || ace.mask != windowsLayeredArchiveFullControlMask { + return fmt.Errorf("Windows layered archive DACL contains an unsupported ACE") + } + if _, ok := trusted[ace.sid]; !ok { + return fmt.Errorf("Windows layered archive DACL grants an untrusted SID") + } + if _, ok := seen[ace.sid]; ok { + return fmt.Errorf("Windows layered archive DACL repeats a trusted SID") + } + seen[ace.sid] = struct{}{} + } + return nil +} diff --git a/internal/connectionentry/windows_layered_archive_permissions_windows_test.go b/internal/connectionentry/windows_layered_archive_permissions_windows_test.go new file mode 100644 index 0000000..4d846a4 --- /dev/null +++ b/internal/connectionentry/windows_layered_archive_permissions_windows_test.go @@ -0,0 +1,168 @@ +//go:build windows + +package connectionentry + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +func assertWindowsLayeredArchivePrivate(t *testing.T, path string) { + t.Helper() + protection, err := inspectWindowsLayeredArchiveProtection(path) + if err != nil { + t.Fatal(err) + } + if err := validateWindowsLayeredArchiveProtectionState(protection); err != nil { + t.Fatalf("Windows handoff archive protection is not private: %v", err) + } +} + +func TestWriteWindowsLayeredArchiveInstallsProtectedDACL(t *testing.T) { + path := filepath.Join(t.TempDir(), "handoff.zip") + report, err := writeWindowsLayeredArchive(path, time.Unix(0, 0), []windowsLayeredArchiveFile{{ + name: "entry.txt", content: []byte("entry\n"), + }}) + if err != nil { + t.Fatal(err) + } + if !report.Private || report.PrivacyDetail == "" { + t.Fatalf("archive report must record verified Windows DACL protection: %#v", report) + } + assertWindowsLayeredArchivePrivate(t, path) +} + +func TestCreateWindowsLayeredArchiveTempFileStartsProtected(t *testing.T) { + file, err := createWindowsLayeredArchiveTempFile(t.TempDir(), "Windows-ConnectionEntry.zip") + if err != nil { + t.Fatal(err) + } + path := file.Name() + if info, statErr := file.Stat(); statErr != nil { + t.Fatal(statErr) + } else if info.Size() != 0 { + t.Fatalf("new protected archive temp file must be empty, got %d bytes", info.Size()) + } + if _, err := validateWindowsLayeredArchiveProtection(path); err != nil { + t.Fatalf("archive temp file was not protected at creation time: %v", err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func TestCreateWindowsLayeredArchiveTempFileDeniesWriteAndDeleteSharing(t *testing.T) { + file, err := createWindowsLayeredArchiveTempFile(t.TempDir(), "Windows-ConnectionEntry.zip") + if err != nil { + t.Fatal(err) + } + defer file.Close() + pointer, err := windows.UTF16PtrFromString(file.Name()) + if err != nil { + t.Fatal(err) + } + for name, access := range map[string]uint32{ + "write": windows.GENERIC_WRITE, + "delete": windows.DELETE, + } { + t.Run(name, func(t *testing.T) { + handle, openErr := windows.CreateFile( + pointer, + access, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if openErr == nil { + windows.CloseHandle(handle) + t.Fatalf("protected archive creation handle allowed a concurrent %s open", name) + } + if !errors.Is(openErr, windows.ERROR_SHARING_VIOLATION) { + t.Fatalf("concurrent %s open error = %v, want sharing violation", name, openErr) + } + }) + } +} + +func TestOpenPublishedWindowsLayeredArchiveDeniesWriteAndDeleteSharing(t *testing.T) { + temporary, err := createWindowsLayeredArchiveTempFile(t.TempDir(), "Windows-ConnectionEntry.zip") + if err != nil { + t.Fatal(err) + } + if _, err := temporary.Write([]byte("archive\n")); err != nil { + t.Fatal(err) + } + defer temporary.Close() + path := filepath.Join(filepath.Dir(temporary.Name()), "Windows-ConnectionEntry.zip") + if published, err := publishWindowsLayeredArchiveHandle(temporary, path); err != nil { + t.Fatal(err) + } else if !published { + t.Fatal("archive handle was not published") + } + file, err := openPublishedWindowsLayeredArchive(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + for name, access := range map[string]uint32{ + "write": windows.GENERIC_WRITE, + "delete": windows.DELETE, + } { + t.Run(name, func(t *testing.T) { + handle, openErr := windows.CreateFile( + pointer, + access, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if openErr == nil { + windows.CloseHandle(handle) + t.Fatalf("published archive validation handle allowed a concurrent %s open", name) + } + if !errors.Is(openErr, windows.ERROR_SHARING_VIOLATION) { + t.Fatalf("concurrent %s open error = %v, want sharing violation", name, openErr) + } + }) + } +} + +func TestPublishWindowsLayeredArchiveHandleAcrossDirectories(t *testing.T) { + root := t.TempDir() + temporary, err := createWindowsLayeredArchiveTempFile(root, "Windows-ConnectionEntry.zip") + if err != nil { + t.Fatal(err) + } + defer temporary.Close() + if _, err := temporary.Write([]byte("archive\n")); err != nil { + t.Fatal(err) + } + destinationDir := filepath.Join(root, "entry") + if err := os.Mkdir(destinationDir, 0o700); err != nil { + t.Fatal(err) + } + destinationPath := filepath.Join(destinationDir, "Windows-ConnectionEntry.zip") + published, err := publishWindowsLayeredArchiveHandle(temporary, destinationPath) + if err != nil { + t.Fatal(err) + } + if !published { + t.Fatal("archive handle was not published across directories") + } + if _, err := os.Stat(destinationPath); err != nil { + t.Fatalf("published archive path is unavailable: %v", err) + } +} diff --git a/internal/connectionentry/windows_layered_archive_test.go b/internal/connectionentry/windows_layered_archive_test.go new file mode 100644 index 0000000..22e607f --- /dev/null +++ b/internal/connectionentry/windows_layered_archive_test.go @@ -0,0 +1,453 @@ +package connectionentry + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWriteWindowsLayeredArchiveDeterministicAndPrivate(t *testing.T) { + generatedAt := time.Date(2026, 7, 16, 20, 34, 56, 0, time.FixedZone("UTC+8", 8*60*60)) + firstFiles := []windowsLayeredArchiveFile{ + {name: "zeta.txt", content: []byte("zeta\n")}, + {name: "alpha.txt", content: []byte("alpha\n")}, + } + secondFiles := []windowsLayeredArchiveFile{ + {name: "alpha.txt", content: []byte("alpha\n")}, + {name: "zeta.txt", content: []byte("zeta\n")}, + } + firstPath := filepath.Join(t.TempDir(), "first.zip") + secondPath := filepath.Join(t.TempDir(), "second.zip") + + firstReport, err := writeWindowsLayeredArchive(firstPath, generatedAt, firstFiles) + if err != nil { + t.Fatal(err) + } + secondReport, err := writeWindowsLayeredArchive(secondPath, generatedAt, secondFiles) + if err != nil { + t.Fatal(err) + } + if firstFiles[0].name != "zeta.txt" { + t.Fatalf("archive writer mutated caller order: %#v", firstFiles) + } + + firstArchive := readTestFile(t, firstPath) + secondArchive := readTestFile(t, secondPath) + if !bytes.Equal(firstArchive, secondArchive) { + t.Fatal("same file set must produce byte-identical ZIP output regardless of input order") + } + digest := sha256.Sum256(firstArchive) + wantSHA256 := hex.EncodeToString(digest[:]) + if firstReport.Path != firstPath || firstReport.SizeBytes != int64(len(firstArchive)) || firstReport.SHA256 != wantSHA256 { + t.Fatalf("unexpected first archive report: %#v", firstReport) + } + if secondReport.SizeBytes != firstReport.SizeBytes || secondReport.SHA256 != firstReport.SHA256 { + t.Fatalf("deterministic archives must have the same report: first=%#v second=%#v", firstReport, secondReport) + } + if !firstReport.Private || firstReport.PrivacyDetail == "" { + t.Fatalf("archive report must record verified private protection: %#v", firstReport) + } + assertWindowsLayeredArchivePrivate(t, firstPath) + + reader, err := zip.OpenReader(firstPath) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + if len(reader.File) != 2 || reader.File[0].Name != "alpha.txt" || reader.File[1].Name != "zeta.txt" { + t.Fatalf("archive entries must be sorted by safe basename: %#v", reader.File) + } + for _, file := range reader.File { + if file.Method != zip.Deflate { + t.Errorf("entry %q must use deflate, got method %d", file.Name, file.Method) + } + if file.Mode().Perm() != 0o600 { + t.Errorf("entry %q must have mode 0600, got %o", file.Name, file.Mode().Perm()) + } + if !file.Modified.Equal(generatedAt.UTC()) { + t.Errorf("entry %q timestamp = %s, want %s", file.Name, file.Modified, generatedAt.UTC()) + } + } +} + +func TestWindowsSafeArchiveBasename(t *testing.T) { + for name, wantKey := range map[string]string{ + "entry.txt": "ENTRY.TXT", + "COM10.txt": "COM10.TXT", + "LPT0": "LPT0", + ".hidden": ".HIDDEN", + } { + t.Run("accept "+name, func(t *testing.T) { + key, ok := windowsSafeArchiveBasename(name) + if !ok || key != wantKey { + t.Fatalf("windowsSafeArchiveBasename(%q) = %q, %t; want %q, true", name, key, ok, wantKey) + } + }) + } + + for _, name := range []string{ + "", ".", "..", "/absolute.txt", `nested\file.txt`, "C:entry.txt", + "CON", "con.txt", "CON .txt", "PRN.log", "AUX", "NUL.bin", "CLOCK$.txt", + "COM1", "com9.log", "LPT1", "LPT1 .log", "lpt9.log", + "COM¹", "COM².txt", "COM³ .txt", "LPT¹", "LPT².log", "LPT³ .log", + "entry.", "entry ", "entry\x00.txt", "entry\x1f.txt", "entry\x7f.txt", + "entry<.txt", "entry>.txt", `entry".txt`, "entry|.txt", "entry?.txt", "entry*.txt", + } { + t.Run("reject "+name, func(t *testing.T) { + if key, ok := windowsSafeArchiveBasename(name); ok { + t.Fatalf("windowsSafeArchiveBasename(%q) = %q, true; want rejection", name, key) + } + }) + } +} + +func TestWriteWindowsLayeredArchiveReportsPrivacyCleanupFailure(t *testing.T) { + validationErr := errors.New("injected privacy validation failure") + cleanupErr := errors.New("injected privacy cleanup failure") + previousHook := windowsLayeredArchivePostPublishHook + previousCleanup := windowsLayeredArchivePublishedCleanup + windowsLayeredArchivePostPublishHook = func(string) error { + return validationErr + } + windowsLayeredArchivePublishedCleanup = func(path string) error { + cleanupActualErr := cleanupPublishedWindowsLayeredArchive(path) + return errors.Join(cleanupActualErr, cleanupErr) + } + t.Cleanup(func() { + windowsLayeredArchivePostPublishHook = previousHook + windowsLayeredArchivePublishedCleanup = previousCleanup + }) + + path := filepath.Join(t.TempDir(), "handoff.zip") + _, err := writeWindowsLayeredArchive(path, time.Unix(0, 0), []windowsLayeredArchiveFile{{ + name: "entry.txt", content: []byte("sensitive archive bytes\n"), + }}) + if !errors.Is(err, validationErr) || !errors.Is(err, cleanupErr) { + t.Fatalf("privacy validation and cleanup errors must both be returned, got %v", err) + } + if content, readErr := os.ReadFile(path); readErr == nil && len(content) != 0 { + t.Fatalf("privacy failure left sensitive archive contents behind: %d bytes", len(content)) + } else if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("inspect privacy-failure cleanup result: %v", readErr) + } +} + +func TestWindowsLayeredArchivePrepublishChecksStayHandleBound(t *testing.T) { + content, err := os.ReadFile("windows_layered_archive.go") + if err != nil { + t.Fatal(err) + } + source := string(content) + for _, forbidden := range []string{ + "os.Stat(temporaryPath)", + "os.Open(temporaryPath)", + } { + if strings.Contains(source, forbidden) { + t.Fatalf("archive prepublication validation must stay on the creation handle, found %q", forbidden) + } + } + for _, required := range []string{ + "temporary.Stat()", + "hashWindowsLayeredArchiveHandle(temporary)", + "validateWindowsLayeredArchiveHandle(temporary)", + "publishWindowsLayeredArchiveHandle(temporary, path)", + } { + if !strings.Contains(source, required) { + t.Fatalf("archive prepublication validation is missing handle-bound operation %q", required) + } + } + publishOffset := strings.Index(source, "publishWindowsLayeredArchiveHandle(temporary, path)") + validationOffset := strings.Index(source, "validatePublishedWindowsLayeredArchive(path, expectation)") + if publishOffset < 0 || validationOffset < publishOffset { + t.Fatal("archive publication must precede final handle validation") + } + between := source[publishOffset:validationOffset] + if closeOffset := strings.Index(between, "temporary.Close()"); closeOffset >= 0 { + guardedFailureOffset := strings.Index(between[:closeOffset], "if windowsLayeredArchivePostPublishHook != nil") + if guardedFailureOffset < 0 { + t.Fatal("archive creation guard handle must remain open through normal final target reopen and validation") + } + } +} + +func TestWriteWindowsLayeredArchiveRejectsPublishedPathReplacement(t *testing.T) { + previousHook := windowsLayeredArchivePostPublishHook + windowsLayeredArchivePostPublishHook = func(path string) error { + if err := os.Rename(path, path+".original"); err != nil { + return err + } + if err := os.WriteFile(path, []byte("replacement archive\n"), 0o600); err != nil { + return err + } + return nil + } + t.Cleanup(func() { windowsLayeredArchivePostPublishHook = previousHook }) + + path := filepath.Join(t.TempDir(), "handoff.zip") + _, err := writeWindowsLayeredArchive(path, time.Unix(0, 0), []windowsLayeredArchiveFile{{ + name: "entry.txt", content: []byte("sensitive archive bytes\n"), + }}) + if err == nil { + t.Fatal("archive publication must reject a path replacement with a different file identity") + } + if content, readErr := os.ReadFile(path); readErr == nil && len(content) != 0 { + t.Fatalf("replacement failure left published bytes behind: %d bytes", len(content)) + } else if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("inspect replacement cleanup result: %v", readErr) + } + if content, readErr := os.ReadFile(path + ".original"); readErr == nil && len(content) != 0 { + t.Fatalf("replacement failure left original sensitive bytes behind: %d bytes", len(content)) + } else if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("inspect original replacement cleanup result: %v", readErr) + } +} + +func TestWindowsLayeredArchiveCleanupHelpers(t *testing.T) { + if err := cleanupPublishedWindowsLayeredArchive(filepath.Join(t.TempDir(), "missing.zip")); err != nil { + t.Fatalf("cleanup of an already absent archive must succeed: %v", err) + } + if err := wrapWindowsLayeredArchiveCloseError(nil); err != nil { + t.Fatalf("nil ZIP close error must remain nil: %v", err) + } + sentinel := errors.New("close failed") + if err := wrapWindowsLayeredArchiveCloseError(sentinel); !errors.Is(err, sentinel) { + t.Fatalf("ZIP close error must be wrapped, got %v", err) + } + closed, err := os.CreateTemp(t.TempDir(), "closed-archive-*") + if err != nil { + t.Fatal(err) + } + if err := closed.Close(); err != nil { + t.Fatal(err) + } + if _, err := hashWindowsLayeredArchiveHandle(closed); err == nil { + t.Fatal("hashing a closed archive handle must fail") + } +} + +func TestCleanupPublishedWindowsLayeredArchiveDoesNotFollowSymlink(t *testing.T) { + root := t.TempDir() + targetPath := filepath.Join(root, "unrelated-target.txt") + want := []byte("unrelated target must remain intact\n") + if err := os.WriteFile(targetPath, want, 0o600); err != nil { + t.Fatal(err) + } + archivePath := filepath.Join(root, "handoff.zip") + if err := os.Symlink(targetPath, archivePath); err != nil { + t.Skipf("create symlink fixture: %v", err) + } + + if err := cleanupPublishedWindowsLayeredArchive(archivePath); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(archivePath); !os.IsNotExist(err) { + t.Fatalf("archive directory entry still exists: %v", err) + } + if got, err := os.ReadFile(targetPath); err != nil { + t.Fatal(err) + } else if !bytes.Equal(got, want) { + t.Fatalf("cleanup followed archive symlink: got %q, want %q", got, want) + } +} + +func TestValidatePublishedWindowsLayeredArchiveRejectsSizeAndDigestChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "handoff.zip") + if err := os.WriteFile(path, []byte("archive bytes\n"), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + identity, _, err := validateWindowsLayeredArchiveHandle(file) + if err != nil { + t.Fatal(err) + } + info, err := file.Stat() + if err != nil { + t.Fatal(err) + } + digest, err := hashWindowsLayeredArchiveHandle(file) + if err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + expected windowsLayeredArchiveExpectation + want string + }{ + { + name: "valid", + expected: windowsLayeredArchiveExpectation{identity: identity, sizeBytes: info.Size(), sha256: digest}, + }, + { + name: "size", + expected: windowsLayeredArchiveExpectation{identity: identity, sizeBytes: info.Size() + 1, sha256: digest}, + want: "size or type changed", + }, + { + name: "digest", + expected: windowsLayeredArchiveExpectation{identity: identity, sizeBytes: info.Size(), sha256: strings.Repeat("0", 64)}, + want: "digest changed", + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := validatePublishedWindowsLayeredArchive(path, test.expected) + if test.want == "" { + if err != nil { + t.Fatalf("expected valid published archive, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("expected %s validation failure, got %v", test.name, err) + } + }) + } +} + +func TestValidatePublishedWindowsLayeredArchiveRejectsDifferentIdentity(t *testing.T) { + root := t.TempDir() + firstPath := filepath.Join(root, "first.zip") + secondPath := filepath.Join(root, "second.zip") + for _, path := range []string{firstPath, secondPath} { + if err := os.WriteFile(path, []byte("same archive bytes\n"), 0o600); err != nil { + t.Fatal(err) + } + } + first, err := os.Open(firstPath) + if err != nil { + t.Fatal(err) + } + identity, _, err := validateWindowsLayeredArchiveHandle(first) + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + second, err := os.Open(secondPath) + if err != nil { + t.Fatal(err) + } + info, err := second.Stat() + if err != nil { + t.Fatal(err) + } + digest, err := hashWindowsLayeredArchiveHandle(second) + if err != nil { + t.Fatal(err) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + + expected := windowsLayeredArchiveExpectation{identity: identity, sizeBytes: info.Size(), sha256: digest} + if _, err := validatePublishedWindowsLayeredArchive(secondPath, expected); err == nil || !strings.Contains(err.Error(), "identity changed") { + t.Fatalf("expected published archive identity mismatch, got %v", err) + } +} + +func TestWriteWindowsLayeredArchiveRejectsUnsafeNames(t *testing.T) { + tests := []struct { + name string + files []windowsLayeredArchiveFile + }{ + {name: "duplicate", files: []windowsLayeredArchiveFile{{name: "same.txt"}, {name: "same.txt"}}}, + {name: "absolute", files: []windowsLayeredArchiveFile{{name: "/absolute.txt"}}}, + {name: "traversal", files: []windowsLayeredArchiveFile{{name: "../escape.txt"}}}, + {name: "backslash", files: []windowsLayeredArchiveFile{{name: `nested\file.txt`}}}, + {name: "drive relative", files: []windowsLayeredArchiveFile{{name: "C:entry.txt"}}}, + {name: "case folded duplicate", files: []windowsLayeredArchiveFile{{name: "ENTRY.txt"}, {name: "entry.txt"}}}, + {name: "Windows normalized duplicate", files: []windowsLayeredArchiveFile{{name: "ſafe.txt"}, {name: "SAFE.TXT"}}}, + {name: "reserved device", files: []windowsLayeredArchiveFile{{name: "CON.txt"}}}, + {name: "trailing dot", files: []windowsLayeredArchiveFile{{name: "entry."}}}, + {name: "trailing space", files: []windowsLayeredArchiveFile{{name: "entry "}}}, + {name: "ASCII control", files: []windowsLayeredArchiveFile{{name: "entry\x1f.txt"}}}, + {name: "forbidden punctuation", files: []windowsLayeredArchiveFile{{name: "entry?.txt"}}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "handoff.zip") + if _, err := writeWindowsLayeredArchive(path, time.Unix(0, 0), test.files); err == nil { + t.Fatalf("expected archive name validation to reject %#v", test.files) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("rejected archive must not be published, stat error = %v", err) + } + }) + } +} + +func TestWriteWindowsLayeredArchiveRejectsOversizeOutput(t *testing.T) { + content := make([]byte, maxWindowsLayeredHandoffBytes+64*1024) + if _, err := rand.New(rand.NewSource(1)).Read(content); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "handoff.zip") + + _, err := writeWindowsLayeredArchive(path, time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC), []windowsLayeredArchiveFile{ + {name: "rdev-bootstrap.exe", content: content}, + }) + if err == nil { + t.Fatal("expected final compressed archive larger than 1 MiB to be rejected") + } + if !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("expected final-size gate error, got %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("oversize archive must not be published, stat error = %v", err) + } +} + +func TestWriteWindowsLayeredArchiveRemovesTemporaryFileOnPublishFailure(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "handoff.zip") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + + if _, err := writeWindowsLayeredArchive(path, time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC), []windowsLayeredArchiveFile{ + {name: "entry.txt", content: []byte("entry\n")}, + }); err == nil { + t.Fatal("expected publishing over a directory to fail") + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != filepath.Base(path) || !entries[0].IsDir() { + t.Fatalf("publish failure left a temporary archive behind: %#v", entries) + } +} + +func TestWriteWindowsLayeredArchiveRejectsExistingDestination(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "handoff.zip") + if err := os.WriteFile(path, []byte("existing\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := writeWindowsLayeredArchive(path, time.Unix(0, 0), []windowsLayeredArchiveFile{{ + name: "entry.txt", content: []byte("new archive\n"), + }}) + if err == nil || !strings.Contains(err.Error(), "destination already exists") { + t.Fatalf("expected existing destination rejection, got %v", err) + } + if got := string(readTestFile(t, path)); got != "existing\n" { + t.Fatalf("existing destination changed: %q", got) + } +} diff --git a/internal/connectionentry/windows_layered_command_windows_test.go b/internal/connectionentry/windows_layered_command_windows_test.go new file mode 100644 index 0000000..2aff70a --- /dev/null +++ b/internal/connectionentry/windows_layered_command_windows_test.go @@ -0,0 +1,1020 @@ +//go:build windows + +package connectionentry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestMain(m *testing.M) { + if os.Getenv("RDEV_LAYERED_FILE_LOCK_HELPER") == "1" { + os.Exit(runWindowsFileLockHelper()) + } + if marker := os.Getenv("RDEV_LAYERED_BOOTSTRAP_EXEC_MARKER"); marker != "" { + _ = os.WriteFile(marker, []byte("executed\n"), 0o600) + os.Exit(99) + } + if marker := os.Getenv("RDEV_LAYERED_EXECUTABLES"); marker != "" && os.Getenv("RDEV_LAYERED_BOOTSTRAP_FIXTURE") == "1" && len(os.Args) > 1 && os.Args[1] == "layered-run" { + executable, err := os.Executable() + if err != nil || appendWindowsFixtureLine(marker, executable) != nil { + os.Exit(98) + } + } + if os.Getenv("RDEV_LAYERED_BOOTSTRAP_FIXTURE") == "1" && len(os.Args) > 1 { + if os.Args[1] == "layered-run" { + if len(os.Args) > 2 && os.Args[2] == "attempt-check" { + os.Exit(runAttemptCheckFixture(os.Args[3:])) + } + if len(os.Args) > 2 && os.Args[2] == "private-path-check" { + os.Exit(runPrivatePathCheckFixture(os.Args[3:])) + } + os.Exit(runLayeredBootstrapFixture(os.Args[2:])) + } + if os.Getenv("RDEV_LAYERED_POWERSHELL_FIXTURE") == "1" { + os.Exit(runPowerShellFixture(os.Args[1:])) + } + } + os.Exit(m.Run()) +} + +func runPrivatePathCheckFixture(args []string) int { + path := fixtureArgument(args, "--path") + kind := fixtureArgument(args, "--kind") + if len(args) != 4 || path == "" || kind != "file" && kind != "directory" || fixtureReparseAncestor(path) { + return 90 + } + info, err := os.Lstat(path) + if err != nil || info.IsDir() != (kind == "directory") { + return 91 + } + return 0 +} + +func TestWindowsLayeredBrokerFallbackExecution(t *testing.T) { + tests := []struct { + name string + scenario string + powerShell string + wantExitError bool + wantLaunchers []string + }{ + {name: "preferred PowerShell success", scenario: "success", wantLaunchers: []string{"powershell"}}, + {name: "current policy failure retries process-scoped policy", scenario: "policy", powerShell: "fixture", wantLaunchers: []string{"powershell-bypass"}}, + {name: "PowerShell runtime absence uses native CMD", scenario: "runtime-absence", powerShell: "missing", wantLaunchers: []string{"cmd"}}, + {name: "download failure retries before core", scenario: "download-failure", wantLaunchers: []string{"powershell", "powershell-bypass"}}, + {name: "core exit failure stops fallback", scenario: "core-exit", wantExitError: true, wantLaunchers: []string{"powershell"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + launcherPath := filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredCommandLauncherName) + powerShellScriptsPath := filepath.Join(t.TempDir(), "powershell-scripts.txt") + if test.powerShell != "" { + powerShellPath := filepath.Join(t.TempDir(), "powershell-fixture.exe") + if test.powerShell == "missing" { + powerShellPath = filepath.Join(t.TempDir(), "missing-powershell.exe") + } else { + copyCurrentTestExecutable(t, powerShellPath) + } + replaceLauncherLine(t, launcherPath, `set "POWERSHELL=`, `set "POWERSHELL=`+powerShellPath+`"`) + } + + localAppData := filepath.Join(t.TempDir(), "local-app-data") + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + command := exec.Command("cmd.exe", "/d", "/c", launcherPath) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+localAppData, + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO="+test.scenario, + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + "RDEV_LAYERED_POWERSHELL_SCRIPTS="+powerShellScriptsPath, + ) + if test.powerShell == "fixture" { + command.Env = append(command.Env, "RDEV_LAYERED_POWERSHELL_FIXTURE=1") + } + output, err := command.CombinedOutput() + if (err != nil) != test.wantExitError { + t.Fatalf("broker error = %v, wantExitError=%t\n%s", err, test.wantExitError, output) + } + marker := readOptionalWindowsFixtureLines(t, markerPath) + if len(marker) != 1 { + t.Fatalf("core marker count = %d, want 1; marker=%q\n%s", len(marker), marker, output) + } + launchers := readOptionalWindowsFixtureLines(t, eventsPath) + if strings.Join(launchers, ",") != strings.Join(test.wantLaunchers, ",") { + t.Fatalf("launcher order = %q, want %q\n%s", launchers, test.wantLaunchers, output) + } + assertOneWindowsFixtureAttempt(t, attemptsPath, len(test.wantLaunchers)) + if test.powerShell == "fixture" { + scripts := readOptionalWindowsFixtureLines(t, powerShellScriptsPath) + if len(scripts) != 2 { + t.Fatalf("PowerShell fixture invocation count = %d, want 2: %q\n%s", len(scripts), scripts, output) + } + for _, path := range scripts { + if !strings.HasPrefix(filepath.Base(path), ".Start-ConnectionEntry-") { + t.Fatalf("broker executed a non-staged PowerShell path %q\n%s", path, output) + } + } + if staged, err := filepath.Glob(filepath.Join(fixture.options.OutDir, windowsLayeredDirName, ".Start-ConnectionEntry-*.ps1")); err != nil { + t.Fatal(err) + } else if len(staged) != 0 { + t.Fatalf("broker left staged PowerShell files behind: %q", staged) + } + } + if test.scenario == "core-exit" && !strings.Contains(string(output), "core_started core_exited") { + t.Fatalf("core-exit failure must refuse another path:\n%s", output) + } + }) + } +} + +func TestWindowsLayeredPowerShellDirectFallsBackToNativeCMD(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + launcherPath := filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredLauncherName) + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + powerShell := filepath.Join(os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + command := exec.Command(powerShell, "-NoLogo", "-NoProfile", "-File", launcherPath) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data"), + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=direct-fallback", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("direct PowerShell fallback failed: %v\n%s", err, output) + } + if marker := readOptionalWindowsFixtureLines(t, markerPath); len(marker) != 1 { + t.Fatalf("core marker count = %d, want 1; marker=%q\n%s", len(marker), marker, output) + } + wantLaunchers := []string{"powershell", "cmd"} + if launchers := readOptionalWindowsFixtureLines(t, eventsPath); strings.Join(launchers, ",") != strings.Join(wantLaunchers, ",") { + t.Fatalf("launcher order = %q, want %q\n%s", launchers, wantLaunchers, output) + } + assertOneWindowsFixtureAttempt(t, attemptsPath, len(wantLaunchers)) +} + +func TestWindowsLayeredCommandLauncherRejectsReparseAncestor(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + + junctionTarget := filepath.Join(t.TempDir(), "junction-target") + if err := os.Mkdir(junctionTarget, 0o700); err != nil { + t.Fatal(err) + } + junction := filepath.Join(t.TempDir(), "junction") + createWindowsJunction(t, junction, junctionTarget) + fixture.options.OutDir = filepath.Join(junction, "entry") + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + launcherPath := filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredCommandLauncherName) + if plan.EntryPackagePlan == nil || plan.EntryPackagePlan.LauncherPath != windowsLayeredDirName+"/"+windowsLayeredLauncherName { + t.Fatalf("expected the PowerShell launcher to be primary: %#v", plan.EntryPackagePlan) + } + + command := exec.Command("cmd.exe", "/d", "/c", launcherPath) + command.Env = append(os.Environ(), "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data")) + output, err := command.CombinedOutput() + if err == nil { + t.Fatal("expected a reparse-point ancestor to fail closed") + } + text := string(output) + if !strings.Contains(text, "Layered bootstrap preparation failed; refusing automatic archive fallback.") { + t.Fatalf("expected reparse-point preparation failure, got:\n%s", text) + } + if strings.Contains(text, "Layered bootstrap failed verification or execution") { + t.Fatalf("reparse-point ancestor must stop before bootstrap execution:\n%s", text) + } +} + +func TestWindowsLayeredCommandLauncherHandlesUnicodePath(t *testing.T) { + fixture := newExecutableWindowsLayeredFixtureAt(t, filepath.Join(t.TempDir(), "连接入口", "entry")) + launcherPath := filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredCommandLauncherName) + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + command := exec.Command("cmd.exe", "/d", "/c", launcherPath) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data"), + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=success", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("Unicode launcher path failed: %v\n%s", err, output) + } + if marker := readOptionalWindowsFixtureLines(t, markerPath); len(marker) != 1 { + t.Fatalf("Unicode launcher core marker count = %d, want 1: %q\n%s", len(marker), marker, output) + } + assertOneWindowsFixtureAttempt(t, attemptsPath, 1) +} + +func TestWindowsLayeredBrokerAuthenticatesPowerShellBeforeBypass(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + commandPath := filepath.Join(handoffDir, windowsLayeredCommandLauncherName) + powerShellPath := filepath.Join(handoffDir, windowsLayeredLauncherName) + tamperMarker := filepath.Join(t.TempDir(), "tampered-powershell.txt") + tampered := "Set-Content -LiteralPath '" + strings.ReplaceAll(tamperMarker, "'", "''") + "' -Value tampered\nexit 19\n" + if err := os.WriteFile(powerShellPath, []byte(tampered), 0o600); err != nil { + t.Fatal(err) + } + + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + command := exec.Command("cmd.exe", "/d", "/c", commandPath) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data"), + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=runtime-absence", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("native fallback after PowerShell authentication failure failed: %v\n%s", err, output) + } + if _, err := os.Stat(tamperMarker); !os.IsNotExist(err) { + t.Fatalf("tampered PowerShell executed before native fallback: err=%v\n%s", err, output) + } + if got := readOptionalWindowsFixtureLines(t, eventsPath); strings.Join(got, ",") != "cmd" { + t.Fatalf("launcher events = %q, want authenticated native-only fallback\n%s", got, output) + } + if got := readOptionalWindowsFixtureLines(t, markerPath); len(got) != 1 { + t.Fatalf("core marker count = %d, want 1; marker=%q\n%s", len(got), got, output) + } +} + +func TestWindowsLayeredBrokerRejectsWrongDigestBootstrapBeforeExecution(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + bootstrapPath := filepath.Join(handoffDir, windowsLayeredBootstrapName) + bootstrap, err := os.OpenFile(bootstrapPath, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := bootstrap.Write([]byte{0}); err != nil { + bootstrap.Close() + t.Fatal(err) + } + if err := bootstrap.Close(); err != nil { + t.Fatal(err) + } + + executionMarker := filepath.Join(t.TempDir(), "bootstrap-executed.txt") + coreMarker := filepath.Join(t.TempDir(), "core-marker.txt") + command := exec.Command("cmd.exe", "/d", "/c", filepath.Join(handoffDir, windowsLayeredCommandLauncherName)) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data"), + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_BOOTSTRAP_EXEC_MARKER="+executionMarker, + "RDEV_LAYERED_SCENARIO=success", + "RDEV_LAYERED_MARKER="+coreMarker, + ) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("wrong-digest bootstrap was accepted:\n%s", output) + } + if _, err := os.Stat(executionMarker); !os.IsNotExist(err) { + t.Fatalf("wrong-digest bootstrap executed before verification: %v\n%s", err, output) + } + if got := readOptionalWindowsFixtureLines(t, coreMarker); len(got) != 0 { + t.Fatalf("wrong-digest bootstrap started a core: %q\n%s", got, output) + } +} + +func TestWindowsLayeredBrokerRemovesUntrustedExplicitACEBeforeBootstrap(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + bootstrapPath := filepath.Join(handoffDir, windowsLayeredBootstrapName) + aclTool := filepath.Join(os.Getenv("SystemRoot"), "System32", "icacls.exe") + if output, err := exec.Command(aclTool, bootstrapPath, "/grant", "*S-1-5-32-545:R").CombinedOutput(); err != nil { + t.Fatalf("add untrusted fixture ACE: %v\n%s", err, output) + } + if protection, err := inspectWindowsLayeredArchiveProtection(bootstrapPath); err != nil { + t.Fatal(err) + } else if err := validateWindowsLayeredArchiveProtectionState(protection); err == nil { + t.Fatal("fixture bootstrap unexpectedly retained an exact private DACL after adding an untrusted ACE") + } + + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + command := exec.Command("cmd.exe", "/d", "/c", filepath.Join(handoffDir, windowsLayeredCommandLauncherName)) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data"), + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=success", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("broker did not normalize the untrusted explicit ACE: %v\n%s", err, output) + } + if got := readOptionalWindowsFixtureLines(t, markerPath); len(got) != 1 { + t.Fatalf("core marker count = %d, want 1 after DACL normalization: %q\n%s", len(got), got, output) + } + if protection, err := inspectWindowsLayeredArchiveProtection(bootstrapPath); err != nil { + t.Fatal(err) + } else if err := validateWindowsLayeredArchiveProtectionState(protection); err != nil { + t.Fatalf("broker did not install the exact protected bootstrap DACL: %v", err) + } +} + +func TestWindowsLayeredNativeStagesAndCleansBootstrap(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + sourceBootstrap := filepath.Join(handoffDir, windowsLayeredBootstrapName) + commandPath := filepath.Join(handoffDir, windowsLayeredCommandLauncherName) + replaceLauncherLine(t, commandPath, `set "POWERSHELL=`, `set "POWERSHELL=`+filepath.Join(t.TempDir(), "missing-powershell.exe")+`"`) + + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + executablesPath := filepath.Join(t.TempDir(), "bootstrap-executables.txt") + command := exec.Command("cmd.exe", "/d", "/c", commandPath) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data"), + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=runtime-absence", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + "RDEV_LAYERED_EXECUTABLES="+executablesPath, + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("native staged bootstrap failed: %v\n%s", err, output) + } + paths := readOptionalWindowsFixtureLines(t, executablesPath) + if len(paths) == 0 { + t.Fatalf("native entry did not execute the bootstrap fixture:\n%s", output) + } + for _, path := range paths { + if strings.EqualFold(path, sourceBootstrap) || !strings.HasPrefix(strings.ToLower(filepath.Base(path)), ".rdev-bootstrap-") { + t.Fatalf("native entry executed a non-staged bootstrap path %q; source=%q\n%s", path, sourceBootstrap, output) + } + } + if staged, err := filepath.Glob(filepath.Join(handoffDir, ".rdev-bootstrap-*.exe")); err != nil { + t.Fatal(err) + } else if len(staged) != 0 { + t.Fatalf("native entry left staged bootstrap files behind: %q", staged) + } + if staged, err := filepath.Glob(filepath.Join(handoffDir, ".Start-ConnectionEntry-*.ps1")); err != nil { + t.Fatal(err) + } else if len(staged) != 0 { + t.Fatalf("native entry left staged PowerShell files behind: %q", staged) + } + if got := readOptionalWindowsFixtureLines(t, markerPath); len(got) != 1 { + t.Fatalf("core marker count = %d, want 1: %q\n%s", len(got), got, output) + } +} + +func TestWindowsLayeredCleanupFailureChangesSuccessExit(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + commandPath := filepath.Join(handoffDir, windowsLayeredCommandLauncherName) + replaceLauncherLine(t, commandPath, `set "POWERSHELL=`, `set "POWERSHELL=`+filepath.Join(t.TempDir(), "missing-powershell.exe")+`"`) + + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + readyPath := filepath.Join(t.TempDir(), "lock-ready.txt") + stopPath := filepath.Join(t.TempDir(), "lock-stop.txt") + command := exec.Command("cmd.exe", "/d", "/c", commandPath) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+filepath.Join(t.TempDir(), "local-app-data"), + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=runtime-absence", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + "RDEV_LAYERED_LOCK_STAGED_BOOTSTRAP=1", + "RDEV_LAYERED_LOCK_READY="+readyPath, + "RDEV_LAYERED_LOCK_STOP="+stopPath, + ) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("native entry hid a staged-bootstrap cleanup failure:\n%s", output) + } + if _, err := os.Stat(readyPath); err != nil { + t.Fatalf("staged-bootstrap lock helper did not become ready: %v\n%s", err, output) + } + if got := readOptionalWindowsFixtureLines(t, markerPath); len(got) != 1 { + t.Fatalf("core marker count = %d, want 1 before cleanup failure: %q\n%s", len(got), got, output) + } + if err := os.WriteFile(stopPath, []byte("stop\n"), 0o600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + staged, globErr := filepath.Glob(filepath.Join(handoffDir, ".rdev-bootstrap-*.exe")) + if globErr != nil { + t.Fatal(globErr) + } + if len(staged) == 0 { + break + } + for _, path := range staged { + _ = os.Remove(path) + } + if time.Now().After(deadline) { + t.Fatalf("locked staged bootstrap did not become removable: %q", staged) + } + time.Sleep(25 * time.Millisecond) + } +} + +func TestWindowsLayeredNativeRejectsInvalidAttemptBeforeBootstrap(t *testing.T) { + tests := []struct { + name string + setup func(*testing.T, string, string) string + }{ + { + name: "outside attempts root", + setup: func(t *testing.T, _ string, _ string) string { + path := filepath.Join(t.TempDir(), "outside-attempt") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + return path + }, + }, + { + name: "active lock", + setup: func(t *testing.T, _ string, attempt string) string { + if err := os.WriteFile(filepath.Join(attempt, "attempt.lock"), nil, 0o600); err != nil { + t.Fatal(err) + } + return attempt + }, + }, + { + name: "core started", + setup: func(t *testing.T, _ string, attempt string) string { + if err := writeWindowsFixtureAttemptState(attempt, "powershell", "core_started"); err != nil { + t.Fatal(err) + } + return attempt + }, + }, + { + name: "core exited", + setup: func(t *testing.T, _ string, attempt string) string { + if err := writeWindowsFixtureAttemptState(attempt, "powershell", "core_exited"); err != nil { + t.Fatal(err) + } + return attempt + }, + }, + { + name: "malformed pre core", + setup: func(t *testing.T, _ string, attempt string) string { + content := `{"schema_version":"rdev.windows-layered-attempt.v1","attempt_id":"attempt-test","stage":"pre_core","launcher":"powershell","updated_at":"2026-07-17T00:00:00Z","extra":true}` + if err := os.WriteFile(filepath.Join(attempt, "state.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return attempt + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + localAppData := filepath.Join(t.TempDir(), "local-app-data") + attemptRoot := filepath.Join(localAppData, "RemoteDevSkillkit", "attempts") + attempt := filepath.Join(attemptRoot, "attempt-test") + if err := os.MkdirAll(attempt, 0o700); err != nil { + t.Fatal(err) + } + attempt = test.setup(t, attemptRoot, attempt) + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + launcherPath := filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredCommandLauncherName) + command := exec.Command("cmd.exe", "/d", "/c", launcherPath, "--native", "--attempt-dir", attempt) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+localAppData, + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=runtime-absence", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + ) + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("invalid native attempt was accepted:\n%s", output) + } + if got := readOptionalWindowsFixtureLines(t, markerPath); len(got) != 0 { + t.Fatalf("invalid attempt started a core: %q\n%s", got, output) + } + if got := readOptionalWindowsFixtureLines(t, eventsPath); len(got) != 0 { + t.Fatalf("invalid attempt reached bootstrap: %q\n%s", got, output) + } + }) + } +} + +func TestWindowsLayeredLaunchersRejectReparseBeforeCreatingStateRoots(t *testing.T) { + for _, launcher := range []string{"cmd", "powershell"} { + t.Run(launcher, func(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + target := filepath.Join(t.TempDir(), "local-app-target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + junction := filepath.Join(t.TempDir(), "local-app-junction") + createWindowsJunction(t, junction, target) + stateRoot := filepath.Join(target, "RemoteDevSkillkit") + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + var command *exec.Cmd + if launcher == "cmd" { + command = exec.Command("cmd.exe", "/d", "/c", filepath.Join(handoffDir, windowsLayeredCommandLauncherName)) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+junction, + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + ) + } else { + path := filepath.Join(handoffDir, windowsLayeredLauncherName) + powerShell := filepath.Join(os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + command = exec.Command(powerShell, "-NoLogo", "-NoProfile", "-File", path) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+junction, + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + ) + } + output, err := command.CombinedOutput() + if err == nil { + t.Fatalf("reparse-backed state root was accepted:\n%s", output) + } + if _, err := os.Stat(stateRoot); !os.IsNotExist(err) { + t.Fatalf("launcher mutated a reparse-backed state root before rejection: err=%v\n%s", err, output) + } + }) + } +} + +func TestWindowsLayeredBrokerDoesNotReparsePercentPayload(t *testing.T) { + fixture := newExecutableWindowsLayeredFixture(t) + injectionMarker := filepath.Join(t.TempDir(), "injected.txt") + payload := `x" & echo injected>"` + injectionMarker + `" & rem "` + localAppData := filepath.Join(t.TempDir(), "%RDEV_LAYERED_INJECT%") + markerPath := filepath.Join(t.TempDir(), "core-marker.txt") + eventsPath := filepath.Join(t.TempDir(), "launcher-events.txt") + attemptsPath := filepath.Join(t.TempDir(), "attempt-events.txt") + launcherPath := filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredCommandLauncherName) + command := exec.Command("cmd.exe", "/d", "/c", launcherPath) + command.Env = append(os.Environ(), + "LOCALAPPDATA="+localAppData, + "RDEV_LAYERED_INJECT="+payload, + "RDEV_LAYERED_BOOTSTRAP_FIXTURE=1", + "RDEV_LAYERED_SCENARIO=runtime-absence", + "RDEV_LAYERED_MARKER="+markerPath, + "RDEV_LAYERED_EVENTS="+eventsPath, + "RDEV_LAYERED_ATTEMPTS="+attemptsPath, + ) + output, _ := command.CombinedOutput() + if _, err := os.Stat(injectionMarker); !os.IsNotExist(err) { + t.Fatalf("CALL reparsed a percent payload from a runtime path: err=%v\n%s", err, output) + } + if got := readOptionalWindowsFixtureLines(t, markerPath); len(got) != 1 { + t.Fatalf("percent-bearing runtime path did not preserve one native core start: %q\n%s", got, output) + } + if got := readOptionalWindowsFixtureLines(t, eventsPath); strings.Join(got, ",") != "powershell,powershell-bypass,cmd" { + t.Fatalf("launcher order = %q, want shared-attempt fallback through native CMD\n%s", got, output) + } + assertOneWindowsFixtureAttempt(t, attemptsPath, 3) +} + +func newExecutableWindowsLayeredFixture(t *testing.T) windowsLayeredFixture { + return newExecutableWindowsLayeredFixtureAt(t, "") +} + +func newExecutableWindowsLayeredFixtureAt(t *testing.T, outDir string) windowsLayeredFixture { + t.Helper() + fixture := newWindowsLayeredFixture(t) + if outDir == "" { + shortRoot, err := os.MkdirTemp("", "rdev-entry-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(shortRoot) }) + outDir = filepath.Join(shortRoot, "entry") + } + fixture.options.OutDir = outDir + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + if plan.EntryPackagePlan == nil || plan.EntryPackagePlan.LauncherPath != windowsLayeredDirName+"/"+windowsLayeredLauncherName { + t.Fatalf("expected preferred PowerShell launcher: %#v", plan.EntryPackagePlan) + } + + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + bootstrapPath := filepath.Join(handoffDir, windowsLayeredBootstrapName) + copyCurrentTestExecutable(t, bootstrapPath) + bootstrap, err := os.ReadFile(bootstrapPath) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(bootstrap) + wantDigest := hex.EncodeToString(digest[:]) + wantSize := strconv.Itoa(len(bootstrap)) + oldSize := strconv.Itoa(len(fixture.bootstrap)) + for _, name := range []string{windowsLayeredLauncherName, windowsLayeredCommandLauncherName} { + path := filepath.Join(handoffDir, name) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := strings.ReplaceAll(string(content), fixture.bootstrapSHA256, wantDigest) + if name == windowsLayeredLauncherName { + text = strings.Replace(text, "$bootstrapLock.Length -ne "+oldSize, "$bootstrapLock.Length -ne "+wantSize, 1) + } else { + text = strings.Replace(text, `set "EXPECTED_SIZE=`+oldSize+`"`, `set "EXPECTED_SIZE=`+wantSize+`"`, 1) + } + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + } + powerShellPath := filepath.Join(handoffDir, windowsLayeredLauncherName) + powerShell, err := os.ReadFile(powerShellPath) + if err != nil { + t.Fatal(err) + } + powerShellDigest := sha256.Sum256(powerShell) + commandPath := filepath.Join(handoffDir, windowsLayeredCommandLauncherName) + replaceLauncherLine(t, commandPath, `set "EXPECTED_PS_SHA256=`, `set "EXPECTED_PS_SHA256=`+hex.EncodeToString(powerShellDigest[:])+`"`) + replaceLauncherLine(t, commandPath, `set "EXPECTED_PS_SIZE=`, `set "EXPECTED_PS_SIZE=`+strconv.Itoa(len(powerShell))+`"`) + return fixture +} + +func copyCurrentTestExecutable(t *testing.T, destination string) { + t.Helper() + source, err := os.Executable() + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, content, 0o700); err != nil { + t.Fatal(err) + } +} + +func replaceLauncherLine(t *testing.T, path, prefix, replacement string) { + t.Helper() + if strings.ContainsAny(replacement, "%!^&|<>") { + t.Fatalf("unsafe fixture launcher replacement %q", replacement) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(content), "\n") + replaced := false + for index, line := range lines { + if strings.HasPrefix(line, prefix) { + lines[index] = replacement + replaced = true + break + } + } + if !replaced { + t.Fatalf("launcher line with prefix %q was not found", prefix) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600); err != nil { + t.Fatal(err) + } +} + +func runLayeredBootstrapFixture(args []string) int { + attemptDir := fixtureArgument(args, "--attempt-dir") + launcher := fixtureArgument(args, "--launcher") + if attemptDir == "" || launcher == "" { + return 90 + } + if !validAttemptFixture(attemptDir) { + return 97 + } + if os.Getenv("RDEV_LAYERED_LOCK_STAGED_BOOTSTRAP") == "1" { + if err := startWindowsFileLockHelper(); err != nil { + return 99 + } + } + if err := appendWindowsFixtureLine(os.Getenv("RDEV_LAYERED_EVENTS"), launcher); err != nil { + return 91 + } + if err := appendWindowsFixtureLine(os.Getenv("RDEV_LAYERED_ATTEMPTS"), filepath.Base(attemptDir)); err != nil { + return 96 + } + scenario := os.Getenv("RDEV_LAYERED_SCENARIO") + if (scenario == "download-failure" || scenario == "direct-fallback") && launcher == "powershell" { + if err := writeWindowsFixtureAttemptState(attemptDir, launcher, "pre_core"); err != nil { + return 92 + } + return 17 + } + start := scenario == "success" && launcher == "powershell" || + scenario == "policy" && launcher == "powershell-bypass" || + scenario == "runtime-absence" && launcher == "cmd" || + scenario == "download-failure" && launcher == "powershell-bypass" || + scenario == "direct-fallback" && launcher == "cmd" || + scenario == "core-exit" && launcher == "powershell" + if !start { + _ = writeWindowsFixtureAttemptState(attemptDir, launcher, "pre_core") + return 18 + } + if err := writeWindowsFixtureAttemptState(attemptDir, launcher, "core_started"); err != nil { + return 93 + } + if err := appendWindowsFixtureLine(os.Getenv("RDEV_LAYERED_MARKER"), launcher); err != nil { + return 94 + } + if err := writeWindowsFixtureAttemptState(attemptDir, launcher, "core_exited"); err != nil { + return 95 + } + if scenario == "core-exit" { + return 23 + } + return 0 +} + +func startWindowsFileLockHelper() error { + executable, err := os.Executable() + if err != nil { + return err + } + command := exec.Command(executable) + command.Env = append(os.Environ(), + "RDEV_LAYERED_FILE_LOCK_HELPER=1", + "RDEV_LAYERED_LOCK_PATH="+executable, + ) + if err := command.Start(); err != nil { + return err + } + if err := command.Process.Release(); err != nil { + return err + } + readyPath := os.Getenv("RDEV_LAYERED_LOCK_READY") + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(readyPath); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + if time.Now().After(deadline) { + return fmt.Errorf("file lock helper readiness timeout") + } + time.Sleep(10 * time.Millisecond) + } +} + +func runWindowsFileLockHelper() int { + pointer, err := syscall.UTF16PtrFromString(os.Getenv("RDEV_LAYERED_LOCK_PATH")) + if err != nil { + return 1 + } + handle, err := syscall.CreateFile(pointer, syscall.GENERIC_READ, syscall.FILE_SHARE_READ, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL|syscall.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return 2 + } + defer syscall.CloseHandle(handle) + if err := os.WriteFile(os.Getenv("RDEV_LAYERED_LOCK_READY"), []byte("ready\n"), 0o600); err != nil { + return 3 + } + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(os.Getenv("RDEV_LAYERED_LOCK_STOP")); err == nil { + return 0 + } else if !os.IsNotExist(err) { + return 4 + } + if time.Now().After(deadline) { + return 5 + } + time.Sleep(25 * time.Millisecond) + } +} + +func runAttemptCheckFixture(args []string) int { + attemptDir := fixtureArgument(args, "--attempt-dir") + launcher := fixtureArgument(args, "--launcher") + if attemptDir == "" || launcher == "" || !validFixtureLauncher(launcher) { + return 1 + } + create := false + for _, arg := range args { + if arg == "--create" { + create = true + } + } + root := filepath.Join(os.Getenv("LOCALAPPDATA"), "RemoteDevSkillkit", "attempts") + if parent := filepath.Dir(attemptDir); !strings.EqualFold(parent, root) || filepath.Base(attemptDir) == "." { + return 1 + } + if fixtureReparseAncestor(attemptDir) { + return 1 + } + if create { + if err := os.MkdirAll(root, 0o700); err != nil { + return 1 + } + if err := os.Mkdir(attemptDir, 0o700); err != nil { + if os.IsExist(err) { + return 2 + } + return 1 + } + } else if info, err := os.Stat(attemptDir); err != nil || !info.IsDir() { + return 1 + } + if !validAttemptFixture(attemptDir) { + return 1 + } + statePath := filepath.Join(attemptDir, "state.json") + content, err := os.ReadFile(statePath) + if os.IsNotExist(err) { + if err := writeWindowsFixtureAttemptState(attemptDir, launcher, "pre_core"); err != nil { + return 1 + } + return 0 + } + if err != nil || !validAttemptFixtureState(content, filepath.Base(attemptDir)) { + return 1 + } + return 0 +} + +func fixtureReparseAncestor(path string) bool { + for { + info, err := os.Lstat(path) + if err == nil { + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + if ok && data.FileAttributes&syscall.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true + } + } else if !os.IsNotExist(err) { + return true + } + parent := filepath.Dir(path) + if parent == path { + return false + } + path = parent + } +} + +func validAttemptFixture(attemptDir string) bool { + if _, err := os.Stat(filepath.Join(attemptDir, "attempt.lock")); err == nil || !os.IsNotExist(err) { + return false + } + content, err := os.ReadFile(filepath.Join(attemptDir, "state.json")) + return os.IsNotExist(err) || err == nil && validAttemptFixtureState(content, filepath.Base(attemptDir)) +} + +func validAttemptFixtureState(content []byte, attemptID string) bool { + var state map[string]any + if err := json.Unmarshal(content, &state); err != nil || len(state) != 5 { + return false + } + return state["schema_version"] == "rdev.windows-layered-attempt.v1" && + state["attempt_id"] == attemptID && state["stage"] == "pre_core" && + validFixtureLauncher(fmt.Sprint(state["launcher"])) && strings.HasSuffix(fmt.Sprint(state["updated_at"]), "Z") +} + +func validFixtureLauncher(value string) bool { + return value == "powershell" || value == "powershell-bypass" || value == "cmd" +} + +func fixtureArgument(args []string, name string) string { + for index := 0; index+1 < len(args); index++ { + if args[index] == name { + return args[index+1] + } + } + return "" +} + +func writeWindowsFixtureAttemptState(directory, launcher, stage string) error { + attemptID := filepath.Base(directory) + content := fmt.Sprintf("{\"schema_version\":\"rdev.windows-layered-attempt.v1\",\"attempt_id\":%q,\"stage\":%q,\"launcher\":%q,\"updated_at\":\"2026-07-17T00:00:00Z\"}\n", attemptID, stage, launcher) + return os.WriteFile(filepath.Join(directory, "state.json"), []byte(content), 0o600) +} + +func appendWindowsFixtureLine(path, value string) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + _, writeErr := fmt.Fprintln(file, value) + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + return closeErr +} + +func runPowerShellFixture(args []string) int { + for index := 0; index+1 < len(args); index++ { + if strings.EqualFold(args[index], "-File") { + if marker := os.Getenv("RDEV_LAYERED_POWERSHELL_SCRIPTS"); marker != "" { + if err := appendWindowsFixtureLine(marker, args[index+1]); err != nil { + return 21 + } + } + break + } + } + bypass := false + for index := 0; index+1 < len(args); index++ { + if strings.EqualFold(args[index], "-ExecutionPolicy") && strings.EqualFold(args[index+1], "Bypass") { + bypass = true + break + } + } + if !bypass { + return 19 + } + powerShell := filepath.Join(os.Getenv("SystemRoot"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + command := exec.Command(powerShell, args...) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 20 + } + return 0 +} + +func readOptionalWindowsFixtureLines(t *testing.T, path string) []string { + t.Helper() + content, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + text := strings.TrimSpace(string(content)) + if text == "" { + return nil + } + return strings.Split(text, "\n") +} + +func assertOneWindowsFixtureAttempt(t *testing.T, path string, wantEvents int) { + t.Helper() + attempts := readOptionalWindowsFixtureLines(t, path) + if len(attempts) != wantEvents || len(attempts) == 0 { + t.Fatalf("attempt event count = %d, want %d: %q", len(attempts), wantEvents, attempts) + } + for _, attempt := range attempts[1:] { + if attempt != attempts[0] { + t.Fatalf("launcher paths used different attempts: %q", attempts) + } + } +} + +func createWindowsJunction(t *testing.T, junction, target string) { + t.Helper() + output, err := exec.Command("cmd.exe", "/d", "/c", "mklink", "/J", junction, target).CombinedOutput() + if err != nil { + t.Skipf("junction setup is unavailable on this Windows host: %v\n%s", err, output) + } + t.Cleanup(func() { + _ = exec.Command("cmd.exe", "/d", "/c", "rmdir", junction).Run() + }) +} diff --git a/internal/connectionentry/windows_layered_test.go b/internal/connectionentry/windows_layered_test.go new file mode 100644 index 0000000..f761fdb --- /dev/null +++ b/internal/connectionentry/windows_layered_test.go @@ -0,0 +1,1025 @@ +package connectionentry + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/agentinvite" + "github.com/EitanWong/remote-dev-skillkit/internal/model" + "github.com/EitanWong/remote-dev-skillkit/internal/release" + "github.com/EitanWong/remote-dev-skillkit/internal/signing" +) + +func TestWindowsLayeredHandoffArchive(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + if plan.EntryPackagePlan == nil { + t.Fatalf("expected Windows layered entry package plan: %#v", plan) + } + archivePath := filepath.Join(fixture.options.OutDir, "Windows-ConnectionEntry.zip") + archiveGeneratedFileFound := false + for _, generatedFile := range plan.GeneratedFiles { + if generatedFile.Path == archivePath { + archiveGeneratedFileFound = true + break + } + } + if !archiveGeneratedFileFound { + t.Fatalf("generated files do not include final archive path %q: %#v", archivePath, plan.GeneratedFiles) + } + for name, artifactPath := range map[string]string{ + "plan": plan.EntryPackagePlan.PlanPath, + "launcher": plan.EntryPackagePlan.LauncherPath, + "archive": plan.EntryPackagePlan.ArchivePath, + } { + if filepath.IsAbs(artifactPath) || artifactPath == "" || strings.Contains(artifactPath, `\`) { + t.Fatalf("entry package %s path must be a non-empty artifact-relative slash path, got %q", name, artifactPath) + } + } + archive := readTestFile(t, archivePath) + archiveInfo, err := os.Stat(archivePath) + if err != nil { + t.Fatal(err) + } + assertWindowsLayeredArchivePrivate(t, archivePath) + if archiveInfo.Size() > maxWindowsLayeredHandoffBytes { + t.Fatalf("Windows handoff archive exceeds 1 MiB: %d", archiveInfo.Size()) + } + digest := sha256.Sum256(archive) + wantSHA256 := hex.EncodeToString(digest[:]) + if plan.EntryPackagePlan.ArchiveSHA256 != wantSHA256 || plan.EntryPackagePlan.ArchiveSizeBytes != archiveInfo.Size() { + t.Fatalf("archive report mismatch: %#v", plan.EntryPackagePlan) + } + assertLayeredArchiveCheck(t, plan.EntryPackagePlan, "windows_layered_archive_sha256", wantSHA256) + assertLayeredArchiveCheck(t, plan.EntryPackagePlan, "windows_layered_archive_size", strconv.FormatInt(archiveInfo.Size(), 10)) + assertLayeredArchivePrivateCheck(t, plan.EntryPackagePlan) + + reader, err := zip.OpenReader(archivePath) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + wantNames := []string{ + "ARCHIVE-RECOVERY.txt", + windowsLayeredCommandLauncherName, + windowsLayeredLauncherName, + windowsLayeredBootstrapName, + windowsLayeredReleaseManifestName, + windowsLayeredChecksumName, + windowsLayeredVerificationPlanName, + } + if len(reader.File) != len(wantNames) { + t.Fatalf("archive entries = %d, want %d", len(reader.File), len(wantNames)) + } + for index, file := range reader.File { + if file.Name != wantNames[index] { + t.Errorf("archive entry %d = %q, want %q", index, file.Name, wantNames[index]) + } + if file.Mode().Perm() != 0o600 { + t.Errorf("archive entry %q must be private, got mode %o", file.Name, file.Mode().Perm()) + } + } + recovery := readZipEntryForTest(t, reader, "ARCHIVE-RECOVERY.txt") + for _, want := range []string{"rdev-bootstrap.exe", "layered-run", "signed archive recovery profile"} { + if !strings.Contains(strings.ToLower(string(recovery)), strings.ToLower(want)) { + t.Errorf("archive recovery instruction missing %q:\n%s", want, recovery) + } + } + for _, file := range reader.File { + content := readZipEntryForTest(t, reader, file.Name) + forbidden := []string{ + fixture.controllerDir, + filepath.ToSlash(fixture.controllerDir), + "controller-source-private-token-do-not-publish", + } + if file.Name != windowsLayeredLauncherName && file.Name != windowsLayeredCommandLauncherName { + forbidden = append(forbidden, fixture.invite.Ticket.Code, fixture.invite.GatewayURL) + } + for _, marker := range forbidden { + if bytes.Contains(content, []byte(marker)) { + t.Errorf("archive entry %q leaked %q", file.Name, marker) + } + } + } + + metadata, err := json.Marshal(plan.EntryPackagePlan) + if err != nil { + t.Fatal(err) + } + metadata = append(metadata, recovery...) + for _, forbidden := range []string{ + fixture.invite.Ticket.Code, + fixture.invite.GatewayURL, + fixture.controllerDir, + filepath.ToSlash(fixture.controllerDir), + "controller-source-private-token-do-not-publish", + } { + if bytes.Contains(metadata, []byte(forbidden)) { + t.Errorf("archive report or recovery metadata leaked %q: %s", forbidden, metadata) + } + } + topLevelChecks, err := json.Marshal(plan.Checks) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{ + fixture.invite.ConnectionEntry.EntryURL, + fixture.invite.ManifestURL, + fixture.invite.ManifestRootPublicKey, + fixture.invite.Ticket.Code, + fixture.invite.GatewayURL, + "controller-source-private-token-do-not-publish", + } { + if bytes.Contains(topLevelChecks, []byte(forbidden)) { + t.Errorf("top-level plan checks leaked %q: %s", forbidden, topLevelChecks) + } + } + packageMetadata, err := json.Marshal(struct { + Package *EntryPackagePlan `json:"entry_package_plan"` + Checks []Check `json:"checks"` + }{Package: plan.EntryPackagePlan, Checks: plan.Checks}) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{ + fixture.options.OutDir, + fixture.controllerDir, + filepath.ToSlash(fixture.options.OutDir), + filepath.ToSlash(fixture.controllerDir), + } { + if bytes.Contains(packageMetadata, []byte(forbidden)) { + t.Errorf("serialized entry package metadata leaked controller-local path %q: %s", forbidden, packageMetadata) + } + } + for _, check := range plan.Checks { + if check.Name == "entry_package_plan" && check.Detail != "ready" { + t.Errorf("entry package plan check detail = %q, want status-only detail ready", check.Detail) + } + } +} + +func TestWindowsLayeredCheckTreeRedactsPrivateDetails(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + if plan.RunnerPlan == nil || plan.EntryPackagePlan == nil { + t.Fatalf("expected complete Windows layered plan: %#v", plan) + } + + checkTree, err := json.Marshal(struct { + Checks []Check `json:"checks"` + RunnerPlanChecks any `json:"runner_plan_checks"` + EntryPackageChecks any `json:"entry_package_checks"` + }{ + Checks: plan.Checks, + RunnerPlanChecks: plan.RunnerPlan.Checks, + EntryPackageChecks: plan.EntryPackagePlan.Checks, + }) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{ + fixture.invite.ConnectionEntry.EntryURL, + fixture.invite.ManifestURL, + fixture.invite.ManifestRootPublicKey, + fixture.invite.Ticket.Code, + fixture.invite.GatewayURL, + fixture.options.OutDir, + filepath.ToSlash(fixture.options.OutDir), + fixture.controllerDir, + filepath.ToSlash(fixture.controllerDir), + "controller-source-private-token-do-not-publish", + } { + if bytes.Contains(checkTree, []byte(forbidden)) { + t.Errorf("serialized check tree leaked %q: %s", forbidden, checkTree) + } + } + for _, check := range plan.RunnerPlan.Checks { + want := "failed" + if check.Passed { + want = "ready" + } + if check.Detail != want { + t.Errorf("runner check %q detail = %q, want status-only %q", check.Name, check.Detail, want) + } + } +} + +func assertLayeredArchivePrivateCheck(t *testing.T, plan *EntryPackagePlan) { + t.Helper() + for _, check := range plan.Checks { + if check.Name == "windows_layered_archive_private" { + if !check.Passed || check.Detail == "" { + t.Fatalf("archive private check = %#v, want verified detail", check) + } + return + } + } + t.Fatalf("missing archive private check: %#v", plan.Checks) +} + +func assertLayeredArchiveCheck(t *testing.T, plan *EntryPackagePlan, name, detail string) { + t.Helper() + for _, check := range plan.Checks { + if check.Name == name { + if !check.Passed || check.Detail != detail { + t.Fatalf("archive check %q = %#v, want passed detail %q", name, check, detail) + } + return + } + } + t.Fatalf("missing archive check %q: %#v", name, plan.Checks) +} + +func readZipEntryForTest(t *testing.T, reader *zip.ReadCloser, name string) []byte { + t.Helper() + for _, file := range reader.File { + if file.Name != name { + continue + } + entry, err := file.Open() + if err != nil { + t.Fatal(err) + } + defer entry.Close() + content := new(bytes.Buffer) + if _, err := content.ReadFrom(entry); err != nil { + t.Fatal(err) + } + return content.Bytes() + } + t.Fatalf("missing ZIP entry %q", name) + return nil +} + +func TestWindowsConnectionEntryPrefersLayeredBootstrapAndFailsClosedWithoutIt(t *testing.T) { + t.Run("verified layered bootstrap is the primary handoff", func(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + if plan.EntryPackagePlan == nil { + t.Fatalf("expected a layered entry package plan: %#v", plan) + } + if plan.EntryPackagePlan.PackageMode != "private-windows-layered-handoff" || + plan.EntryPackagePlan.PlatformPlanKind != "windows-layered-handoff" { + t.Fatalf("expected the verified layered handoff to be preferred: %#v", plan.EntryPackagePlan) + } + + launcherPath := filepath.Join(fixture.options.OutDir, "windows-layered", windowsLayeredLauncherName) + launcher := readTestFile(t, launcherPath) + normalizedLauncher := normalizePowerShellForTest(string(launcher)) + assertStringsInOrder(t, normalizedLauncher, + "function Invoke-Layered", + "layered-run", + "--manifest-url", fixture.options.LayeredAssetsManifestURL, + "--root-public-key", fixture.options.ReleaseRootPublicKey, + "--expected-release-version", fixture.options.LayeredReleaseVersion, + "--platform", "windows/amd64", + "--cache-dir", + "--mode", "temporary", + "--", "serve", + "--mode", "temporary", + "--manifest-url", fixture.invite.ManifestURL, + "--manifest-root-public-key", fixture.invite.ManifestRootPublicKey, + "--transport", "auto", + "--once=false", + "--max-tasks", "0", + ) + if !strings.Contains(normalizedLauncher, "LOCALAPPDATA") || + !strings.Contains(normalizedLauncher, "RemoteDevSkillkit") || + !strings.Contains(normalizedLauncher, "cache") { + t.Fatalf("launcher must use the current user's LocalApplicationData cache:\n%s", launcher) + } + if !strings.Contains(normalizedLauncher, "SHA256") || + !strings.Contains(strings.ToLower(normalizedLauncher), fixture.bootstrapSHA256) { + t.Fatalf("launcher must recheck the controller-verified bootstrap SHA-256:\n%s", launcher) + } + for _, want := range []string{ + "FileAttributes]::ReparsePoint", + "FileShare]::Read", + "ComputeHash($bootstrapLock)", + "$bootstrapLock.Length", + "WindowsIdentity]::GetCurrent().User.Value", + "icacls.exe", + "UNC paths are not allowed", + "ACL grants access to an untrusted identity", + } { + if !strings.Contains(normalizedLauncher, want) { + t.Fatalf("launcher must protect the bootstrap path and user cache with %q:\n%s", want, launcher) + } + } + if strings.Contains(normalizedLauncher, "$writeMask") { + t.Fatalf("private handoff ACL validation must reject untrusted read ACEs as well as write ACEs:\n%s", launcher) + } + if strings.Count(normalizedLauncher, "--transport") != 1 || strings.Contains(normalizedLauncher, "--transport long-poll") { + t.Fatalf("layered launcher must preserve the runtime's transport fallback policy:\n%s", launcher) + } + for _, fallback := range []string{"signed archive recovery profile", "ARCHIVE-RECOVERY.txt"} { + if !strings.Contains(normalizedLauncher, fallback) { + t.Fatalf("layered failure must name the separately verified archive recovery command %q:\n%s", fallback, launcher) + } + } + for _, forbidden := range []string{"Start-Process", "Start-Job", "WindowStyle Hidden", "--gateway", "--ticket-code"} { + if strings.Contains(normalizedLauncher, forbidden) { + t.Fatalf("layered launcher must stay foreground and obtain gateway data from the signed join manifest; found %q:\n%s", forbidden, launcher) + } + } + + handoffDir := filepath.Dir(launcherPath) + if filepath.Base(handoffDir) != "windows-layered" { + t.Fatalf("expected a focused windows-layered handoff, got %q", handoffDir) + } + if fileExists(filepath.Join(fixture.options.OutDir, "windows-temporary", "Start-ConnectionEntry.ps1")) { + t.Fatal("layered materialization must not retain a legacy full-helper launcher") + } + assertStringsInOrder(t, normalizedLauncher, + "try {", + "ComputeHash($bootstrapLock)", + "Invoke-Layered $Launcher", + "exit $layeredExitCode", + "} catch {", + "Run the signed archive recovery profile", + "exit 1", + ) + packagedBootstrap := filepath.Join(handoffDir, "rdev-bootstrap.exe") + if got := readTestFile(t, packagedBootstrap); !bytes.Equal(got, fixture.bootstrap) { + t.Fatalf("packaged bootstrap is not an exact copy: got %d bytes, want %d", len(got), len(fixture.bootstrap)) + } + assertPrivateLayeredHandoff(t, handoffDir, fixture) + }) + + t.Run("visible command broker falls back without automatic archive execution", func(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + handoffDir := filepath.Join(fixture.options.OutDir, "windows-layered") + cmdPath := filepath.Join(handoffDir, "Start-ConnectionEntry.cmd") + if plan.EntryPackagePlan == nil || plan.EntryPackagePlan.LauncherPath != windowsLayeredDirName+"/"+windowsLayeredLauncherName { + t.Fatalf("PowerShell launcher must be the primary entry point: %#v", plan.EntryPackagePlan) + } + launcher := strings.ToLower(string(readTestFile(t, cmdPath))) + for _, want := range []string{ + "start-connectionentry.ps1", + "rdev-bootstrap.exe", + "layered-run", + "certutil.exe", + "icacls.exe", + "private-path-check", + "whoami.exe", + "/setowner", + "--manifest-url", + "--root-public-key", + "--expected-release-version", + "--platform", + "windows/amd64", + "--cache-dir", + "--attempt-dir", + "--launcher", + "--mode", + "temporary", + "attempt-check", + "--create", + "powershell-bypass", + "signed archive recovery profile", + } { + if !strings.Contains(launcher, want) { + t.Fatalf("command broker missing %q:\n%s", want, launcher) + } + } + assertStringsInOrder(t, launcher, + ":reject_unsafe_path", + "icacls.exe", + "private-path-check", + ) + normalizedCMD := strings.ReplaceAll(launcher, "\r\n", "\n") + rejectStart := strings.LastIndex(normalizedCMD, "\n:reject_unsafe_path\n") + rejectEnd := strings.Index(normalizedCMD[rejectStart+1:], "\n:load_sid\n") + if rejectStart < 0 || rejectEnd < 0 { + t.Fatalf("command broker is missing the early path-validation block:\n%s", launcher) + } + if strings.Contains(normalizedCMD[rejectStart:rejectStart+1+rejectEnd], "private-path-check") { + t.Fatalf("early CMD path checks must not execute the bootstrap before its digest check:\n%s", launcher) + } + rejectBlock := normalizedCMD[rejectStart : rejectStart+1+rejectEnd] + for _, want := range []string{"%%~aI", "if not defined ATTRS", `if not "%ATTRS:l=%"=="%ATTRS%"`} { + if !strings.Contains(rejectBlock, strings.ToLower(want)) { + t.Fatalf("early CMD path checks must fail closed on unreadable or reparse attributes with %q:\n%s", want, launcher) + } + } + if strings.Contains(rejectBlock, "fsutil") { + t.Fatalf("CMD path checks must not classify every fsutil query error as a non-reparse path:\n%s", launcher) + } + assertStringsInOrder(t, launcher, + "powershell.exe", + "-file", + "powershell", + "-executionpolicy", "bypass", + "-file", + "powershell-bypass", + "attempt-check", "--attempt-dir", + "--launcher", "cmd", + ) + if strings.Count(launcher, `--transport auto`) != 1 { + t.Fatalf("the broker and direct-native paths must share exactly one core transport selection:\n%s", launcher) + } + for _, forbidden := range []string{"start-process", "start-job", "windowstyle hidden"} { + if strings.Contains(launcher, forbidden) { + t.Fatalf("command broker must keep every attempt visible and foreground; found %q:\n%s", forbidden, launcher) + } + } + if reset := strings.Index(launcher, "/reset"); reset < 0 { + t.Fatalf("command broker must clear unrelated explicit ACL entries before granting its exact private trustees:\n%s", launcher) + } else if reject := strings.Index(launcher, ":reject_unsafe_path"); reject < 0 || reset < reject { + t.Fatalf("command broker must reject reparse paths before ACL reset:\n%s", launcher) + } + for _, forbidden := range []string{`call "%fallback_path%"`, `start "" "%fallback_path%"`} { + if strings.Contains(launcher, forbidden) { + t.Fatalf("native command launcher must not automatically execute the archive fallback; found %q:\n%s", forbidden, launcher) + } + } + }) + + t.Run("missing layered prerequisites fail closed", func(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + fixture.options.WindowsBootstrapBinaryPath = "" + fixture.options.WindowsBootstrapReleaseManifestPath = "" + fixture.options.LayeredAssetsManifestURL = "" + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + if plan.EntryPackagePlan == nil || plan.EntryPackagePlan.PlatformPlanKind != "connection-entry-runner" || len(plan.MissingInputs) == 0 { + t.Fatalf("expected only a failed bootstrap runner plan: %#v", plan.EntryPackagePlan) + } + if fileExists(filepath.Join(fixture.options.OutDir, "windows-temporary", "Start-ConnectionEntry.ps1")) { + t.Fatal("missing layered inputs must not generate a legacy helper launcher") + } + assertNoPartialLayeredOutput(t, fixture.options.OutDir) + }) + + partial := []struct { + name string + mutate func(*Options) + }{ + { + name: "only bootstrap binary", + mutate: func(options *Options) { + options.WindowsBootstrapReleaseManifestPath = "" + options.LayeredAssetsManifestURL = "" + }, + }, + { + name: "bootstrap and release manifest without layered URL", + mutate: func(options *Options) { + options.LayeredAssetsManifestURL = "" + }, + }, + { + name: "layered inputs without expected release version", + mutate: func(options *Options) { + options.LayeredReleaseVersion = "" + }, + }, + { + name: "release manifest and layered URL without bootstrap", + mutate: func(options *Options) { + options.WindowsBootstrapBinaryPath = "" + }, + }, + } + for _, test := range partial { + t.Run(test.name+" fails closed", func(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + test.mutate(&fixture.options) + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + if plan.EntryPackagePlan == nil || plan.EntryPackagePlan.PlatformPlanKind != "connection-entry-runner" || len(plan.MissingInputs) == 0 { + t.Fatalf("partial layered inputs must retain only a failed bootstrap runner plan: %#v", plan.EntryPackagePlan) + } + if fileExists(filepath.Join(fixture.options.OutDir, "windows-temporary", "Start-ConnectionEntry.ps1")) { + t.Fatal("partial layered inputs generated a legacy helper launcher") + } + assertNoPartialLayeredOutput(t, fixture.options.OutDir) + }) + } +} + +func TestWindowsConnectionEntryPrefersPowerShell(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + + plan, err := FromInvite(fixture.options) + if err != nil { + t.Fatal(err) + } + if plan.EntryPackagePlan == nil { + t.Fatal("expected Windows layered entry package plan") + } + wantLauncher := windowsLayeredDirName + "/" + windowsLayeredLauncherName + if plan.EntryPackagePlan.LauncherPath != wantLauncher { + t.Fatalf("preferred launcher = %q, want %q", plan.EntryPackagePlan.LauncherPath, wantLauncher) + } + if !strings.Contains(strings.ToLower(plan.EntryPackagePlan.HumanEntryPoint), "powershell") { + t.Fatalf("human entry point must prefer the visible PowerShell launcher: %#v", plan.EntryPackagePlan) + } + generated := make(map[string]bool) + for _, file := range plan.GeneratedFiles { + generated[filepath.Base(file.Path)] = true + } + for _, name := range []string{windowsLayeredLauncherName, windowsLayeredCommandLauncherName} { + if !generated[name] { + t.Fatalf("package plan omitted visible launcher %q: %#v", name, plan.GeneratedFiles) + } + } + + powerShell := normalizePowerShellForTest(string(readTestFile(t, filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredLauncherName)))) + for _, want := range []string{ + "AttemptDir", + "Launcher", + "powershell", + "--attempt-dir", + "--launcher", + "Invoke-AttemptCheck", + "attempt-check", + "--create", + "rdev-bootstrap.exe", + "layered-run", + "signed archive recovery profile", + } { + if !strings.Contains(powerShell, want) { + t.Fatalf("preferred PowerShell launcher missing %q:\n%s", want, powerShell) + } + } + if strings.Count(powerShell, "--transport") != 1 { + t.Fatalf("PowerShell path must preserve exactly one core transport selection:\n%s", powerShell) + } + for _, forbidden := range []string{"Start-Process", "Start-Job", "WindowStyle Hidden", "& $fallbackPath"} { + if strings.Contains(powerShell, forbidden) { + t.Fatalf("preferred launcher must stay foreground and must not execute archive recovery; found %q:\n%s", forbidden, powerShell) + } + } +} + +func TestWindowsLayeredBrokerFallbackContract(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + if _, err := FromInvite(fixture.options); err != nil { + t.Fatal(err) + } + + launcher := strings.ToLower(string(readTestFile(t, filepath.Join(fixture.options.OutDir, windowsLayeredDirName, windowsLayeredCommandLauncherName)))) + for _, want := range []string{ + "attempt_dir", + "attempt-check", + "core_started", + "--native", + "--attempt-dir", + "--launcher", + "powershell-bypass", + "signed archive recovery profile", + } { + if !strings.Contains(launcher, want) { + t.Fatalf("broker fallback contract missing %q:\n%s", want, launcher) + } + } + assertStringsInOrder(t, launcher, + "powershell.exe", + "-file", + "powershell", + "-executionpolicy", "bypass", + "-file", + "powershell-bypass", + ":native", + ) + if strings.Count(launcher, `"%attempt_dir%"`) < 3 { + t.Fatalf("all broker paths must share the one allocated attempt directory:\n%s", launcher) + } + if strings.Contains(launcher, `call "%fallback_path%"`) || strings.Contains(launcher, `start "" "%fallback_path%"`) { + t.Fatalf("broker must never execute archive recovery:\n%s", launcher) + } +} + +func TestWindowsLayeredBrokerAuthenticatesPowerShellAndAvoidsRuntimeCallArguments(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + if _, err := FromInvite(fixture.options); err != nil { + t.Fatal(err) + } + + handoffDir := filepath.Join(fixture.options.OutDir, windowsLayeredDirName) + powerShell := readTestFile(t, filepath.Join(handoffDir, windowsLayeredLauncherName)) + digest := sha256.Sum256(powerShell) + wantDigest := hex.EncodeToString(digest[:]) + command := string(readTestFile(t, filepath.Join(handoffDir, windowsLayeredCommandLauncherName))) + for _, want := range []string{ + `set "EXPECTED_PS_SHA256=` + wantDigest + `"`, + `set "EXPECTED_PS_SIZE=` + strconv.Itoa(len(powerShell)) + `"`, + ":verify_powershell", + `set "SOURCE_BOOTSTRAP=`, + `set "STAGED_BOOTSTRAP=`, + ":prepare_bootstrap", + `copy /b "%SOURCE_BOOTSTRAP%" "%STAGED_BOOTSTRAP%"`, + ":cleanup_bootstrap", + `set "CLEANUP_EXIT=0"`, + `if errorlevel 1 set "CLEANUP_EXIT=1"`, + `if errorlevel 1 set "LAYERED_EXIT=1"`, + } { + if !strings.Contains(command, want) { + t.Fatalf("command broker must authenticate the packaged PowerShell content with %q:\n%s", want, command) + } + } + if strings.Count(command, "call :verify_powershell") < 2 { + t.Fatalf("command broker must authenticate PowerShell before both policy attempts:\n%s", command) + } + normalizedCommand := strings.ReplaceAll(command, "\r\n", "\n") + nativeStart := strings.Index(normalizedCommand, "\n:native\n") + if nativeStart < 0 { + t.Fatalf("command broker is missing the shared native block:\n%s", command) + } + nativeBlock := normalizedCommand[nativeStart:] + assertStringsInOrder(t, nativeBlock, + "call :verify_bootstrap", + `"%BOOTSTRAP%" layered-run attempt-check`, + `set "TARGET=%COMMAND_LAUNCHER%"`, + "call :verify_private_target", + "call :verify_bootstrap", + "layered-run --manifest-url", + ) + if strings.Contains(nativeBlock, `findstr.exe" /l /c:"\"stage\":\"pre_core\""`) { + t.Fatalf("native entry must use the verified bootstrap parser instead of substring-classifying attempt state:\n%s", command) + } + for _, forbidden := range []string{ + `call "%~f0"`, + `call :protect_directory "%`, + `call :protect_file "%`, + `call :reject_unsafe_path "%`, + } { + if strings.Contains(strings.ToLower(command), strings.ToLower(forbidden)) { + t.Fatalf("command broker must not pass runtime paths through CALL reparsing; found %q:\n%s", forbidden, command) + } + } + + powerShellText := string(powerShell) + for _, want := range []string{windowsLayeredCommandLauncherName, "$commandLauncher", "& $commandLauncher", "--native"} { + if !strings.Contains(powerShellText, want) { + t.Fatalf("direct PowerShell fallback must enter the shared native CMD block with %q:\n%s", want, powerShellText) + } + } + assertStringsInOrder(t, powerShellText, + "Invoke-Layered $Launcher", + "if (-not $Brokered) {", + "Invoke-AttemptCheck $AttemptDir 'cmd'", + "& $commandLauncher '--native' '--attempt-dir' $AttemptDir", + ) + if strings.Contains(powerShellText, "'--attempt-dir' $AttemptDir '--launcher' 'cmd'") { + t.Fatalf("private native PowerShell handoff must use the exact --native --attempt-dir grammar:\n%s", powerShellText) + } + if strings.Contains(powerShellText, "$cmdPath") { + t.Fatalf("direct PowerShell fallback must not maintain a second native execution block:\n%s", powerShellText) + } +} + +func TestWindowsLayeredConnectionEntryRejectsUnverifiedControllerHandoff(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *windowsLayeredFixture) + }{ + { + name: "release key ID mismatch", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + manifest := fixture.manifest + manifest.SigningKeyID = "other-release-root" + manifest = signReleaseManifestForTest(t, manifest, fixture.key) + writeReleaseManifestForTest(t, fixture.options.WindowsBootstrapReleaseManifestPath, manifest) + }, + }, + { + name: "invalid release signature", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + manifest := fixture.manifest + manifest.Signature = "not-a-valid-ed25519-signature" + writeReleaseManifestForTest(t, fixture.options.WindowsBootstrapReleaseManifestPath, manifest) + }, + }, + { + name: "bootstrap digest mismatch", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + manifest := fixture.manifest + manifest.ArtifactSHA256 = strings.Repeat("0", 64) + manifest = signReleaseManifestForTest(t, manifest, fixture.key) + writeReleaseManifestForTest(t, fixture.options.WindowsBootstrapReleaseManifestPath, manifest) + }, + }, + { + name: "bootstrap size mismatch", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + manifest := fixture.manifest + manifest.ArtifactSize++ + manifest = signReleaseManifestForTest(t, manifest, fixture.key) + writeReleaseManifestForTest(t, fixture.options.WindowsBootstrapReleaseManifestPath, manifest) + }, + }, + { + name: "wrong artifact name", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + manifest := fixture.manifest + manifest.ArtifactName = "not-rdev-bootstrap.exe" + manifest = signReleaseManifestForTest(t, manifest, fixture.key) + writeReleaseManifestForTest(t, fixture.options.WindowsBootstrapReleaseManifestPath, manifest) + }, + }, + { + name: "signed bootstrap release version mismatch", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + manifest := fixture.manifest + manifest.ReleaseVersion = "v0.1.0" + manifest = signReleaseManifestForTest(t, manifest, fixture.key) + writeReleaseManifestForTest(t, fixture.options.WindowsBootstrapReleaseManifestPath, manifest) + }, + }, + { + name: "signed bootstrap target platform mismatch", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + manifest := fixture.manifest + manifest.TargetPlatform = "windows/arm64" + manifest = signReleaseManifestForTest(t, manifest, fixture.key) + writeReleaseManifestForTest(t, fixture.options.WindowsBootstrapReleaseManifestPath, manifest) + }, + }, + { + name: "HTTP layered manifest URL", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + fixture.options.LayeredAssetsManifestURL = "http://downloads.example.com/layered-assets.json" + }, + }, + { + name: "layered manifest URL with query", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + fixture.options.LayeredAssetsManifestURL = "https://downloads.example.com/layered-assets.json?channel=test" + }, + }, + { + name: "layered manifest URL with fragment", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + fixture.options.LayeredAssetsManifestURL = "https://downloads.example.com/layered-assets.json#latest" + }, + }, + { + name: "layered inputs supplied without release root", + mutate: func(t *testing.T, fixture *windowsLayeredFixture) { + fixture.options.ReleaseRootPublicKey = "" + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + test.mutate(t, &fixture) + + if _, err := FromInvite(fixture.options); err == nil { + t.Fatal("expected controller-side layered handoff verification to fail closed") + } + assertNoPartialLayeredOutput(t, fixture.options.OutDir) + }) + } +} + +func TestWindowsLayeredCommandArgumentRejectsShellSyntax(t *testing.T) { + for _, value := range []string{ + "https://downloads.example.com/layered-assets.json&whoami", + "v0.2.0|whoami", + "release-root:abc%PATH%", + "https://gateway.example.com/manifest\" --unexpected", + "line\r\nbreak", + } { + if err := validateWindowsLayeredCommandArgument("test", value); err == nil { + t.Fatalf("expected Windows command argument validation to reject %q", value) + } + } + if err := validateWindowsLayeredCommandArgument("test", "https://downloads.example.com/layered-assets.json"); err != nil { + t.Fatalf("expected a safe Windows command argument to be accepted: %v", err) + } +} + +func TestWindowsLayeredConnectionEntryRejectsCommandSyntaxBeforeMaterialization(t *testing.T) { + fixture := newWindowsLayeredFixture(t) + fixture.options.ReleaseRootPublicKey += "&whoami" + + _, err := FromInvite(fixture.options) + if err == nil || !strings.Contains(err.Error(), "unsupported Windows command syntax") { + t.Fatalf("expected Windows command argument rejection, got %v", err) + } + assertNoPartialLayeredOutput(t, fixture.options.OutDir) +} + +type windowsLayeredFixture struct { + options Options + invite agentinvite.Invite + key signing.Key + manifest release.Manifest + controllerDir string + bootstrap []byte + bootstrapSHA256 string +} + +func newWindowsLayeredFixture(t *testing.T) windowsLayeredFixture { + t.Helper() + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + controllerDir := filepath.Join(t.TempDir(), "controller-source-private-token-do-not-publish") + if err := os.MkdirAll(controllerDir, 0o700); err != nil { + t.Fatal(err) + } + bootstrap := []byte("fixture rdev bootstrap executable bytes\n") + bootstrapPath := filepath.Join(controllerDir, "rdev-bootstrap.exe") + if err := os.WriteFile(bootstrapPath, bootstrap, 0o700); err != nil { + t.Fatal(err) + } + manifest, err := release.SignArtifactForRelease(bootstrapPath, key, now, "v0.2.0", "windows/amd64") + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(controllerDir, "rdev-bootstrap.exe.rdev-release.json") + writeReleaseManifestForTest(t, manifestPath, manifest) + root := key.ID + ":" + base64.RawURLEncoding.EncodeToString(key.PublicKey) + invite := testInvite(t, model.HostModeAttendedTemporary) + digest := sha256.Sum256(bootstrap) + + return windowsLayeredFixture{ + options: Options{ + InviteJSON: mustJSON(t, invite), + OutDir: filepath.Join(t.TempDir(), "entry"), + TargetOS: "windows", + TargetArch: "amd64", + Ownership: "third-party", + SessionMode: string(model.HostModeAttendedTemporary), + WindowsBootstrapBinaryPath: bootstrapPath, + WindowsBootstrapReleaseManifestPath: manifestPath, + LayeredAssetsManifestURL: "https://downloads.example.com/layered-assets.json", + LayeredReleaseVersion: "v0.2.0", + ReleaseRootPublicKey: root, + Now: now, + }, + invite: invite, + key: key, + manifest: manifest, + controllerDir: controllerDir, + bootstrap: bootstrap, + bootstrapSHA256: hex.EncodeToString(digest[:]), + } +} + +func signReleaseManifestForTest(t *testing.T, manifest release.Manifest, key signing.Key) release.Manifest { + t.Helper() + signed, err := manifest.Sign(key.PrivateKey) + if err != nil { + t.Fatal(err) + } + return signed +} + +func writeReleaseManifestForTest(t *testing.T, path string, manifest release.Manifest) { + t.Helper() + if err := release.WriteManifest(path, manifest); err != nil { + t.Fatal(err) + } +} + +func normalizePowerShellForTest(content string) string { + normalized := strings.NewReplacer("'", "", "\"", "", "`", "").Replace(content) + return strings.Join(strings.Fields(normalized), " ") +} + +func assertStringsInOrder(t *testing.T, content string, expected ...string) { + t.Helper() + offset := 0 + for _, value := range expected { + index := strings.Index(content[offset:], value) + if index < 0 { + t.Fatalf("expected %q after byte %d in launcher:\n%s", value, offset, content) + } + offset += index + len(value) + } +} + +func assertPrivateLayeredHandoff(t *testing.T, handoffDir string, fixture windowsLayeredFixture) { + t.Helper() + metadataFound := false + checksumFound := false + err := filepath.Walk(handoffDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode().Perm()&0o077 != 0 { + t.Errorf("layered handoff path must be private, got mode %o for %s", info.Mode().Perm(), path) + } + if info.IsDir() { + return nil + } + name := strings.ToLower(info.Name()) + if strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".sha256") { + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for _, forbidden := range []string{ + fixture.controllerDir, + filepath.ToSlash(fixture.controllerDir), + fixture.invite.Ticket.Code, + fixture.invite.GatewayURL, + "controller-source-private-token-do-not-publish", + } { + if strings.Contains(string(content), forbidden) { + t.Errorf("public/release-like handoff metadata %s leaked %q", path, forbidden) + } + } + metadataFound = metadataFound || strings.HasSuffix(name, ".json") + if strings.HasSuffix(name, ".sha256") { + checksumFound = true + if !strings.Contains(strings.ToLower(string(content)), fixture.bootstrapSHA256) { + t.Errorf("checksum file %s does not pin the packaged bootstrap digest", path) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if !metadataFound || !checksumFound { + t.Fatalf("layered handoff must include non-sensitive release verification metadata and a checksum file; metadata=%t checksum=%t", metadataFound, checksumFound) + } +} + +func assertNoPartialLayeredOutput(t *testing.T, outDir string) { + t.Helper() + if _, err := os.Stat(filepath.Join(outDir, "windows-layered")); err == nil { + t.Fatalf("verification failure left a partial windows-layered handoff in %s", outDir) + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + if _, err := os.Stat(outDir); os.IsNotExist(err) { + return + } else if err != nil { + t.Fatal(err) + } + err := filepath.Walk(outDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if strings.EqualFold(info.Name(), "rdev-bootstrap.exe") { + t.Errorf("verification failure copied a bootstrap to %s", path) + } + content, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if bytes.Contains(bytes.ToLower(content), []byte("rdev-bootstrap.exe")) && bytes.Contains(content, []byte("layered-run")) { + t.Errorf("verification failure wrote a layered launcher to %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func readTestFile(t *testing.T, path string) []byte { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return content +} diff --git a/internal/connectionrunner/runner.go b/internal/connectionrunner/runner.go index f9f9aaa..d7a6799 100644 --- a/internal/connectionrunner/runner.go +++ b/internal/connectionrunner/runner.go @@ -29,41 +29,47 @@ const ( ) type Options struct { - Invite agentinvite.Invite - OutDir string - TargetOS string - TargetArch string - SessionMode string - RdevCommand string - HostName string - GeneratedAt time.Time - WritePackage bool + Invite agentinvite.Invite + OutDir string + TargetOS string + TargetArch string + SessionMode string + BootstrapCommand string + LayeredAssetsManifestURL string + LayeredReleaseRootPublicKey string + LayeredReleaseVersion string + HostName string + GeneratedAt time.Time + WritePackage bool } type Manifest struct { - SchemaVersion string `json:"schema_version"` - GeneratedAt time.Time `json:"generated_at"` - ConnectionEntryName string `json:"connection_entry_name"` - TargetOS string `json:"target_os"` - TargetArch string `json:"target_arch"` - SessionMode string `json:"session_mode"` - Mode model.HostMode `json:"mode"` - HostName string `json:"host_name,omitempty"` - ManifestURL string `json:"manifest_url"` - ManifestRootPublicKey string `json:"manifest_root_public_key"` - GatewayURL string `json:"gateway_url"` - JoinURL string `json:"join_url"` - TransportPreference string `json:"transport_preference"` - Once bool `json:"once"` - ConnectionPaths []ConnectionPath `json:"connection_paths"` - RuntimeProbes []RuntimeProbe `json:"runtime_probes"` - HelperPolicy HelperPolicy `json:"helper_policy"` - HumanConsent []string `json:"human_consent"` - AgentOnlyParameters []string `json:"agent_only_parameters"` - StopAndCleanup []string `json:"stop_and_cleanup"` - MissingInputs []string `json:"missing_inputs,omitempty"` - NoManualAssembly bool `json:"no_manual_assembly"` - NoPersistenceByDefault bool `json:"no_persistence_by_default"` + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + ConnectionEntryName string `json:"connection_entry_name"` + TargetOS string `json:"target_os"` + TargetArch string `json:"target_arch"` + SessionMode string `json:"session_mode"` + Mode model.HostMode `json:"mode"` + HostName string `json:"host_name,omitempty"` + ManifestURL string `json:"manifest_url"` + ManifestRootPublicKey string `json:"manifest_root_public_key"` + LayeredAssetsManifestURL string `json:"layered_assets_manifest_url"` + LayeredReleaseRootPublicKey string `json:"layered_release_root_public_key"` + LayeredReleaseVersion string `json:"layered_release_version"` + GatewayURL string `json:"gateway_url"` + JoinURL string `json:"join_url"` + TransportPreference string `json:"transport_preference"` + Once bool `json:"once"` + ConnectionPaths []ConnectionPath `json:"connection_paths"` + RuntimeProbes []RuntimeProbe `json:"runtime_probes"` + HelperPolicy HelperPolicy `json:"helper_policy"` + HumanConsent []string `json:"human_consent"` + AgentOnlyParameters []string `json:"agent_only_parameters"` + StopAndCleanup []string `json:"stop_and_cleanup"` + MissingInputs []string `json:"missing_inputs,omitempty"` + NoManualAssembly bool `json:"no_manual_assembly"` + NoPersistenceByDefault bool `json:"no_persistence_by_default"` } type ConnectionPath struct { @@ -77,7 +83,7 @@ type ConnectionPath struct { GatewayEnvVars []string `json:"gateway_env_vars,omitempty"` DependencyInstallActionEnvVars []string `json:"dependency_install_action_env_vars,omitempty"` HelperStartArgvEnvVars []string `json:"helper_start_argv_env_vars,omitempty"` - UsesHostServe bool `json:"uses_host_serve"` + UsesBootstrap bool `json:"uses_bootstrap"` GatewayOverride string `json:"gateway_override,omitempty"` TransportOverride string `json:"transport_override,omitempty"` ExecuteWhen []string `json:"execute_when"` @@ -155,7 +161,7 @@ type Check struct { type RunOptions struct { ManifestPath string - RdevCommand string + BootstrapCommand string DryRun bool ProbeTimeout time.Duration ExtraHostArgs []string @@ -175,7 +181,7 @@ type RunResult struct { SelectedPath string `json:"selected_path,omitempty"` SelectedTransport string `json:"selected_transport,omitempty"` SelectedGatewayURL string `json:"selected_gateway_url,omitempty"` - HostServeArgs []string `json:"host_serve_args,omitempty"` + BootstrapArgs []string `json:"bootstrap_args,omitempty"` Executed bool `json:"executed"` DependencyInstallConfigured bool `json:"dependency_install_configured"` DependencyInstalled bool `json:"dependency_installed"` @@ -246,28 +252,35 @@ func Build(opts Options) (Package, error) { if generatedAt.IsZero() { generatedAt = time.Now().UTC() } - rdevCommand := strings.TrimSpace(opts.RdevCommand) - if rdevCommand == "" { - rdevCommand = "rdev" + bootstrapCommand := strings.TrimSpace(opts.BootstrapCommand) + if bootstrapCommand == "" { + bootstrapCommand = "rdev-bootstrap" + } + base := strings.TrimSuffix(strings.ToLower(filepath.Base(bootstrapCommand)), ".exe") + if base != "rdev-bootstrap" { + return Package{}, fmt.Errorf("bootstrap command must name rdev-bootstrap") } manifest := Manifest{ - SchemaVersion: ManifestSchemaVersion, - GeneratedAt: generatedAt.UTC(), - ConnectionEntryName: "Connection Entry", - TargetOS: targetOS, - TargetArch: targetArch, - SessionMode: sessionMode, - Mode: mode, - HostName: strings.TrimSpace(opts.HostName), - ManifestURL: opts.Invite.ManifestURL, - ManifestRootPublicKey: opts.Invite.ManifestRootPublicKey, - GatewayURL: opts.Invite.GatewayURL, - JoinURL: opts.Invite.JoinURL, - TransportPreference: firstNonEmpty(opts.Invite.Transport, "auto"), - Once: false, - ConnectionPaths: connectionPaths(opts.Invite), - RuntimeProbes: runtimeProbes(targetOS), - HelperPolicy: helperPolicy(), + SchemaVersion: ManifestSchemaVersion, + GeneratedAt: generatedAt.UTC(), + ConnectionEntryName: "Connection Entry", + TargetOS: targetOS, + TargetArch: targetArch, + SessionMode: sessionMode, + Mode: mode, + HostName: strings.TrimSpace(opts.HostName), + ManifestURL: opts.Invite.ManifestURL, + ManifestRootPublicKey: opts.Invite.ManifestRootPublicKey, + LayeredAssetsManifestURL: firstNonEmpty(opts.LayeredAssetsManifestURL, opts.Invite.LayeredAssetsManifestURL), + LayeredReleaseRootPublicKey: firstNonEmpty(opts.LayeredReleaseRootPublicKey, opts.Invite.LayeredReleaseRootPublicKey), + LayeredReleaseVersion: firstNonEmpty(opts.LayeredReleaseVersion, opts.Invite.LayeredReleaseVersion), + GatewayURL: opts.Invite.GatewayURL, + JoinURL: opts.Invite.JoinURL, + TransportPreference: firstNonEmpty(opts.Invite.Transport, "auto"), + Once: false, + ConnectionPaths: connectionPaths(opts.Invite), + RuntimeProbes: runtimeProbes(targetOS), + HelperPolicy: helperPolicy(), HumanConsent: []string{ "This visible Connection Entry starts an rdev host session for the support or development request.", "Closing the launcher stops an attended temporary session.", @@ -276,6 +289,9 @@ func Build(opts Options) (Package, error) { AgentOnlyParameters: []string{ "manifest_url", "manifest_root_public_key", + "layered_assets_manifest_url", + "layered_release_root_public_key", + "layered_release_version", "gateway_url", "ticket_code", "transport_preference", @@ -299,6 +315,15 @@ func Build(opts Options) (Package, error) { if manifest.GatewayURL == "" { manifest.MissingInputs = append(manifest.MissingInputs, "gateway_url") } + if manifest.LayeredAssetsManifestURL == "" { + manifest.MissingInputs = append(manifest.MissingInputs, "layered_assets_manifest_url") + } + if manifest.LayeredReleaseRootPublicKey == "" { + manifest.MissingInputs = append(manifest.MissingInputs, "layered_release_root_public_key") + } + if manifest.LayeredReleaseVersion == "" { + manifest.MissingInputs = append(manifest.MissingInputs, "layered_release_version") + } plan := RunnerPlan{ SchemaVersion: PlanSchemaVersion, @@ -309,8 +334,8 @@ func Build(opts Options) (Package, error) { "the target side runs one visible launcher or package runner", "the runner verifies and consumes the signed join manifest before host registration", "the runner probes direct gateway reachability before trying helper connectivity", - "the runner invokes rdev host serve with --transport auto unless a selected path requires a narrower fallback", - "connectivity helpers provide routing only; rdev ticket, host activation, signed session tasks, and policy authorize work", + "the runner invokes rdev-bootstrap layered-run exactly once and passes --transport auto to the verified core", + "connectivity helpers provide routing only; the signed ticket, session tasks, and policy authorize work", }, SelectionOrder: []string{ "native-direct-gateway", @@ -323,7 +348,7 @@ func Build(opts Options) (Package, error) { }, ConnectivityHelpers: connectivityTools(), FallbackBehavior: []string{ - "start with WSS, then HTTPS long-poll, then short polling through rdev host serve --transport auto", + "the single verified core starts with WSS and can switch to HTTPS long-poll or short polling without another registration", "use proxy environment variables automatically when present", "use existing non-privileged relay, mesh, VPN, or SSH tooling only when the route and credential choice are unambiguous", "report a machine-readable manual_action_required item instead of guessing when route, credential, enrollment, or privilege is unclear", @@ -334,7 +359,7 @@ func Build(opts Options) (Package, error) { "gateway probe result", "helper tool detection result", "host registration result", - "transport fallback attempts from rdev host serve", + "transport selections and in-core route changes", }, } @@ -348,7 +373,7 @@ func Build(opts Options) (Package, error) { if !opts.WritePackage || strings.TrimSpace(opts.OutDir) == "" { return pkg, nil } - if err := writePackage(&pkg, rdevCommand); err != nil { + if err := writePackage(&pkg, bootstrapCommand); err != nil { return Package{}, err } return pkg, nil @@ -385,6 +410,15 @@ func ValidateManifest(manifest Manifest) error { if strings.TrimSpace(manifest.GatewayURL) == "" { return fmt.Errorf("runner manifest missing gateway_url") } + if strings.TrimSpace(manifest.LayeredAssetsManifestURL) == "" { + return fmt.Errorf("runner manifest missing layered_assets_manifest_url") + } + if strings.TrimSpace(manifest.LayeredReleaseRootPublicKey) == "" { + return fmt.Errorf("runner manifest missing layered_release_root_public_key") + } + if strings.TrimSpace(manifest.LayeredReleaseVersion) == "" { + return fmt.Errorf("runner manifest missing layered_release_version") + } if !manifest.Mode.Valid() { return fmt.Errorf("runner manifest has invalid mode %q", manifest.Mode) } @@ -415,9 +449,13 @@ func Run(opts RunOptions) (RunResult, error) { if httpProbe == nil { httpProbe = probeGatewayHTTP } - rdevCommand := strings.TrimSpace(opts.RdevCommand) - if rdevCommand == "" { - rdevCommand = "rdev" + bootstrapCommand := strings.TrimSpace(opts.BootstrapCommand) + if bootstrapCommand == "" { + bootstrapCommand = "rdev-bootstrap" + } + base := strings.TrimSuffix(strings.ToLower(filepath.Base(bootstrapCommand)), ".exe") + if base != "rdev-bootstrap" { + return RunResult{}, fmt.Errorf("bootstrap command must name rdev-bootstrap") } result := RunResult{ SchemaVersion: "rdev.connection-entry.runner-result.v1", @@ -439,10 +477,14 @@ func Run(opts RunOptions) (RunResult, error) { return result, nil } result.SelectedPath = selected.ID - result.SelectedTransport = firstNonEmpty(selected.TransportOverride, manifest.TransportPreference, "auto") + result.SelectedTransport = "auto" result.SelectedGatewayURL = firstNonEmpty(selected.GatewayOverride, manifest.GatewayURL) result.AuthorizationRequired = append(result.AuthorizationRequired, selected.AuthorizationRequired...) - result.HostServeArgs = hostServeArgs(manifest, result.SelectedGatewayURL, result.SelectedTransport, opts.ExtraHostArgs) + cacheDir, err := defaultBootstrapCacheDir() + if err != nil { + return result, err + } + result.BootstrapArgs = bootstrapRunArgs(manifest, result.SelectedGatewayURL, cacheDir, opts.ExtraHostArgs) appendHelperTranscript(&result, "selected_path "+selected.ID) appendHelperTranscript(&result, "selected_transport "+result.SelectedTransport) installAction, installTool, installConfigured, err := dependencyInstallAction(*selected, manifest.Mode) @@ -516,13 +558,13 @@ func Run(opts RunOptions) (RunResult, error) { } appendHelperTranscript(&result, "helper_gateway_reachable selected_path="+result.SelectedPath) } - appendHelperTranscript(&result, "host_serve_invoked") - if err := commandRunner(rdevCommand, result.HostServeArgs); err != nil { + appendHelperTranscript(&result, "bootstrap_invoked") + if err := commandRunner(bootstrapCommand, result.BootstrapArgs); err != nil { cleanupHelper(&result, cleanup, helperTool) return result, err } result.Executed = true - appendHelperTranscript(&result, "host_serve_completed") + appendHelperTranscript(&result, "bootstrap_completed") cleanupHelper(&result, cleanup, helperTool) return result, nil } @@ -627,12 +669,12 @@ func hostStatusEvidence(result RunResult) map[string]any { status = "manual-action-required" } return map[string]any{ - "schema_version": "rdev.connection-entry.host-status.v1", - "ok": result.Executed, - "host_status": status, - "selected_path": result.SelectedPath, - "host_serve_invoked": helperTranscriptHas(result, "host_serve_invoked"), - "host_serve_done": result.Executed, + "schema_version": "rdev.connection-entry.host-status.v1", + "ok": result.Executed, + "host_status": status, + "selected_path": result.SelectedPath, + "bootstrap_invoked": helperTranscriptHas(result, "bootstrap_invoked"), + "bootstrap_done": result.Executed, } } @@ -653,7 +695,7 @@ func auditEvidenceJSONL(result RunResult, generatedAt time.Time) string { events := []map[string]any{ {"schema_version": "rdev.connection-entry.runner-audit-event.v1", "generated_at": generatedAt, "event": "selected_path", "selected_path": result.SelectedPath}, {"schema_version": "rdev.connection-entry.runner-audit-event.v1", "generated_at": generatedAt, "event": "helper_start", "configured": result.HelperStartConfigured, "started": result.HelperStarted, "tool": result.HelperStartTool}, - {"schema_version": "rdev.connection-entry.runner-audit-event.v1", "generated_at": generatedAt, "event": "host_serve", "executed": result.Executed}, + {"schema_version": "rdev.connection-entry.runner-audit-event.v1", "generated_at": generatedAt, "event": "bootstrap", "executed": result.Executed}, {"schema_version": "rdev.connection-entry.runner-audit-event.v1", "generated_at": generatedAt, "event": "cleanup", "attempted": result.HelperCleanupAttempted, "succeeded": result.HelperCleanupSucceeded}, } var builder strings.Builder @@ -702,7 +744,7 @@ func writeTextFile(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) } -func writePackage(pkg *Package, rdevCommand string) error { +func writePackage(pkg *Package, bootstrapCommand string) error { outDir, err := filepath.Abs(pkg.OutDir) if err != nil { return err @@ -723,7 +765,7 @@ func writePackage(pkg *Package, rdevCommand string) error { pkg.ManifestPath = manifestPath pkg.Plan.ManifestPath = manifestPath launcherPath := filepath.Join(outDir, launcherName(pkg.Manifest.TargetOS)) - launcherContent := renderLauncher(pkg.Manifest.TargetOS, rdevCommand, manifestPath) + launcherContent := renderLauncher(pkg.Manifest, bootstrapCommand) if err := os.WriteFile(launcherPath, []byte(launcherContent), 0o700); err != nil { return err } @@ -753,10 +795,10 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { Status: "implemented", BestFor: "target can reach the gateway URL directly over outbound HTTP(S)/WSS", Probe: []string{"GET gateway /healthz", "GET signed join manifest", "verify manifest root"}, - UsesHostServe: true, + UsesBootstrap: true, TransportOverride: "auto", ExecuteWhen: []string{"gateway health check succeeds", "signed join manifest is reachable"}, - Evidence: []string{"gateway health probe", "manifest verification", "rdev host serve transport fallback output"}, + Evidence: []string{"gateway health probe", "manifest verification", "bootstrap and in-core route output"}, }, { ID: "native-lan-gateway", @@ -765,7 +807,7 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { Status: lanStatus(gatewayURL), BestFor: "agent and target share a LAN, VPN, or routed private subnet", Probe: []string{"resolve gateway host", "probe gateway port", "GET gateway /healthz"}, - UsesHostServe: true, + UsesBootstrap: true, TransportOverride: "auto", ExecuteWhen: []string{"gateway host is private or local and reachable from the target"}, Evidence: []string{"selected LAN/private gateway URL", "port probe", "health probe"}, @@ -777,7 +819,7 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { Status: "implemented-via-standard-http-proxy-environment", BestFor: "corporate network where HTTPS egress requires HTTP_PROXY, HTTPS_PROXY, or NO_PROXY", Probe: []string{"inspect proxy environment variables", "GET signed join manifest through default HTTP client"}, - UsesHostServe: true, + UsesBootstrap: true, TransportOverride: "auto", ExecuteWhen: []string{"proxy environment variables are present and manifest fetch succeeds"}, Evidence: []string{"proxy variable names only", "manifest fetch result"}, @@ -793,10 +835,10 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { GatewayEnvVars: []string{"RDEV_SSH_GATEWAY_URL"}, DependencyInstallActionEnvVars: []string{"RDEV_SSH_INSTALL_ACTION_JSON"}, HelperStartArgvEnvVars: []string{"RDEV_SSH_TUNNEL_START_ARGV_JSON"}, - UsesHostServe: true, + UsesBootstrap: true, ExecuteWhen: []string{"SSH route and local forward are already configured by operator policy"}, AuthorizationRequired: []string{"ask before creating new SSH keys, editing SSH config, or selecting among ambiguous SSH identities"}, - Evidence: []string{"ssh tool detection", "local forward endpoint", "host serve through forwarded gateway"}, + Evidence: []string{"ssh tool detection", "local forward endpoint", "bootstrap through forwarded gateway"}, }, { ID: "existing-frp-or-chisel-relay", @@ -809,7 +851,7 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { GatewayEnvVars: []string{"RDEV_RELAY_GATEWAY_URL"}, DependencyInstallActionEnvVars: []string{"RDEV_RELAY_INSTALL_ACTION_JSON"}, HelperStartArgvEnvVars: []string{"RDEV_RELAY_START_ARGV_JSON"}, - UsesHostServe: true, + UsesBootstrap: true, ExecuteWhen: []string{"relay config exists and credential choice is unambiguous"}, AuthorizationRequired: []string{"ask before downloading relay binaries, editing relay config, creating relay accounts, opening firewall ports, or using paid relay services"}, Evidence: []string{"relay tool detection", "redacted relay config identity", "selected forwarded gateway"}, @@ -825,7 +867,7 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { GatewayEnvVars: []string{"RDEV_MESH_GATEWAY_URL"}, DependencyInstallActionEnvVars: []string{"RDEV_MESH_INSTALL_ACTION_JSON"}, HelperStartArgvEnvVars: []string{"RDEV_MESH_START_ARGV_JSON"}, - UsesHostServe: true, + UsesBootstrap: true, ExecuteWhen: []string{"mesh route to gateway exists and no new enrollment is needed"}, AuthorizationRequired: []string{"ask before mesh enrollment, auth-key use, ACL changes, DNS changes, or persistent service changes"}, Evidence: []string{"mesh tool detection", "redacted mesh route status", "selected mesh gateway"}, @@ -841,7 +883,7 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { GatewayEnvVars: []string{"RDEV_VPN_GATEWAY_URL"}, DependencyInstallActionEnvVars: []string{"RDEV_VPN_INSTALL_ACTION_JSON"}, HelperStartArgvEnvVars: []string{"RDEV_VPN_START_ARGV_JSON"}, - UsesHostServe: true, + UsesBootstrap: true, ExecuteWhen: []string{"WireGuard route is active and gateway health check succeeds"}, AuthorizationRequired: []string{"ask before creating keys, importing profiles, starting persistent VPN tunnels, changing DNS, or editing firewall routes"}, Evidence: []string{"WireGuard tool detection", "active route proof", "selected VPN gateway"}, @@ -853,7 +895,7 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { Status: "implemented", BestFor: "gateway health endpoint is unavailable but signed join manifest can still be fetched", Probe: []string{"GET signed join manifest", "verify manifest root"}, - UsesHostServe: true, + UsesBootstrap: true, TransportOverride: "auto", ExecuteWhen: []string{"manifest URL is reachable and root is pinned"}, Evidence: []string{"manifest URL", "manifest root verification"}, @@ -865,7 +907,7 @@ func connectionPaths(invite agentinvite.Invite) []ConnectionPath { Status: "not-executable", BestFor: "carrying agent-only values so target-side humans never assemble flags", Probe: []string{"manifest_url=" + manifestURL, "manifest_root_public_key_present=" + fmt.Sprint(root != ""), "gateway_url_present=" + fmt.Sprint(gatewayURL != "")}, - UsesHostServe: false, + UsesBootstrap: false, ExecuteWhen: []string{"never selected as a connection path"}, Evidence: []string{"runner manifest checksum"}, }, @@ -926,6 +968,9 @@ func runnerChecks(manifest Manifest) []Check { {Name: "runner_schema", Passed: manifest.SchemaVersion == ManifestSchemaVersion, Detail: manifest.SchemaVersion}, {Name: "manifest_url", Passed: strings.TrimSpace(manifest.ManifestURL) != "", Detail: manifest.ManifestURL}, {Name: "manifest_root_public_key", Passed: strings.TrimSpace(manifest.ManifestRootPublicKey) != "", Detail: manifest.ManifestRootPublicKey}, + {Name: "layered_assets_manifest_url", Passed: strings.TrimSpace(manifest.LayeredAssetsManifestURL) != "", Detail: manifest.LayeredAssetsManifestURL}, + {Name: "layered_release_root_public_key", Passed: strings.TrimSpace(manifest.LayeredReleaseRootPublicKey) != "", Detail: presenceDetail(manifest.LayeredReleaseRootPublicKey)}, + {Name: "layered_release_version", Passed: strings.TrimSpace(manifest.LayeredReleaseVersion) != "", Detail: manifest.LayeredReleaseVersion}, {Name: "gateway_url", Passed: strings.TrimSpace(manifest.GatewayURL) != "", Detail: manifest.GatewayURL}, {Name: "connection_paths", Passed: len(manifest.ConnectionPaths) >= 7, Detail: fmt.Sprint(len(manifest.ConnectionPaths))}, {Name: "no_manual_assembly", Passed: manifest.NoManualAssembly, Detail: "runner carries ticket/root/gateway/transport metadata"}, @@ -933,6 +978,13 @@ func runnerChecks(manifest Manifest) []Check { } } +func presenceDetail(value string) string { + if strings.TrimSpace(value) == "" { + return "missing" + } + return "present" +} + func connectivityTools() []ConnectivityTool { return []ConnectivityTool{ { @@ -1019,7 +1071,7 @@ func selectPath(manifest Manifest, tools []ToolResult, httpProbe func(string, ti }) var probes []ProbeResult for _, path := range paths { - if !path.UsesHostServe { + if !path.UsesBootstrap { continue } ok := false @@ -1089,15 +1141,28 @@ func selectPath(manifest Manifest, tools []ToolResult, httpProbe func(string, ti return nil, probes } -func hostServeArgs(manifest Manifest, gatewayURL, transport string, extra []string) []string { +func bootstrapRunArgs(manifest Manifest, gatewayURL, cacheDir string, extraCoreArgs []string) []string { + args := []string{ + "layered-run", + "--manifest-url", manifest.LayeredAssetsManifestURL, + "--root-public-key", manifest.LayeredReleaseRootPublicKey, + "--expected-release-version", manifest.LayeredReleaseVersion, + "--platform", manifest.TargetOS + "/" + manifest.TargetArch, + "--cache-dir", cacheDir, + "--mode", "temporary", + "--", + } + return append(args, coreRunArgs(manifest, gatewayURL, extraCoreArgs)...) +} + +func coreRunArgs(manifest Manifest, gatewayURL string, extra []string) []string { manifestURL := manifestURLForGateway(manifest.ManifestURL, gatewayURL) args := []string{ - "host", "serve", "--mode", hostModeArg(manifest.Mode), "--gateway", gatewayURL, "--manifest-url", manifestURL, "--manifest-root-public-key", manifest.ManifestRootPublicKey, - "--transport", firstNonEmpty(transport, "auto"), + "--transport", "auto", "--once=false", } if manifest.HostName != "" { @@ -1106,6 +1171,14 @@ func hostServeArgs(manifest Manifest, gatewayURL, transport string, extra []stri return append(args, extra...) } +func defaultBootstrapCacheDir() (string, error) { + root, err := os.UserCacheDir() + if err != nil || strings.TrimSpace(root) == "" { + return "", fmt.Errorf("resolve user cache directory") + } + return filepath.Join(root, "RemoteDevSkillkit", "cache"), nil +} + func manifestURLForGateway(manifestURL, gatewayURL string) string { manifestParsed, err := url.Parse(manifestURL) if err != nil { @@ -1491,21 +1564,45 @@ func waitForGateway(gatewayURL string, httpProbe func(string, time.Duration) err } } -func renderLauncher(targetOS, rdevCommand, manifestPath string) string { - if targetOS == "windows" { +func renderLauncher(manifest Manifest, bootstrapCommand string) string { + if manifest.TargetOS == "windows" { return fmt.Sprintf(`$ErrorActionPreference = 'Stop' -$ManifestPath = Join-Path $PSScriptRoot 'connection-entry-runner.json' -Write-Host "Starting Remote Dev Skillkit Connection Entry..." -& %s connection-entry run --runner-manifest "$ManifestPath" -`, psCommand(rdevCommand)) + $CacheDir = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'RemoteDevSkillkit\cache' + $AttemptDir = Join-Path (Join-Path $CacheDir 'attempts') ([Guid]::NewGuid().ToString('N')) + & %s layered-run attempt-check --attempt-dir $AttemptDir --launcher powershell --create + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host "Starting Remote Dev Skillkit Connection Entry through rdev-bootstrap..." + & %s layered-run --manifest-url '%s' --root-public-key '%s' --expected-release-version '%s' --platform '%s/%s' --cache-dir $CacheDir --attempt-dir $AttemptDir --launcher powershell --mode temporary -- --mode %s --gateway '%s' --manifest-url '%s' --manifest-root-public-key '%s' --transport auto --once=false%s + exit $LASTEXITCODE + `, psCommand(bootstrapCommand), psCommand(bootstrapCommand), psValue(manifest.LayeredAssetsManifestURL), psValue(manifest.LayeredReleaseRootPublicKey), psValue(manifest.LayeredReleaseVersion), psValue(manifest.TargetOS), psValue(manifest.TargetArch), psValue(hostModeArg(manifest.Mode)), psValue(manifest.GatewayURL), psValue(manifest.ManifestURL), psValue(manifest.ManifestRootPublicKey), renderPowerShellHostNameArg(manifest.HostName)) } return fmt.Sprintf(`#!/bin/sh -set -eu -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -MANIFEST="$SCRIPT_DIR/connection-entry-runner.json" -echo "Starting Remote Dev Skillkit Connection Entry..." -exec %s connection-entry run --runner-manifest "$MANIFEST" -`, shellToken(rdevCommand)) + set -eu + cache_base="${XDG_CACHE_HOME:-${HOME:?HOME is required}/.cache}" + cache_dir="$cache_base/RemoteDevSkillkit/cache" + mkdir -p "$cache_dir" + chmod 700 "$cache_dir" + echo "Starting Remote Dev Skillkit Connection Entry through rdev-bootstrap..." + exec %s layered-run --manifest-url %s --root-public-key %s --expected-release-version %s --platform %s --cache-dir "$cache_dir" --mode temporary -- --mode %s --gateway %s --manifest-url %s --manifest-root-public-key %s --transport auto --once=false%s + `, shellToken(bootstrapCommand), shellToken(manifest.LayeredAssetsManifestURL), shellToken(manifest.LayeredReleaseRootPublicKey), shellToken(manifest.LayeredReleaseVersion), shellToken(manifest.TargetOS+"/"+manifest.TargetArch), shellToken(hostModeArg(manifest.Mode)), shellToken(manifest.GatewayURL), shellToken(manifest.ManifestURL), shellToken(manifest.ManifestRootPublicKey), renderShellHostNameArg(manifest.HostName)) +} + +func psValue(value string) string { + return strings.ReplaceAll(value, "'", "''") +} + +func renderPowerShellHostNameArg(name string) string { + if strings.TrimSpace(name) == "" { + return "" + } + return " --name '" + psValue(name) + "'" +} + +func renderShellHostNameArg(name string) string { + if strings.TrimSpace(name) == "" { + return "" + } + return " --name " + shellToken(name) } func launcherName(targetOS string) string { diff --git a/internal/connectionrunner/runner_test.go b/internal/connectionrunner/runner_test.go index 8dbbe26..eab8c30 100644 --- a/internal/connectionrunner/runner_test.go +++ b/internal/connectionrunner/runner_test.go @@ -21,7 +21,6 @@ func TestBuildWritesSelfContainedRunnerPackage(t *testing.T) { TargetOS: "windows", TargetArch: "amd64", SessionMode: string(model.HostModeAttendedTemporary), - RdevCommand: "rdev", GeneratedAt: time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC), WritePackage: true, }) @@ -44,8 +43,8 @@ func TestBuildWritesSelfContainedRunnerPackage(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(launcher), "connection-entry run --runner-manifest") { - t.Fatalf("launcher should call runner, got:\n%s", string(launcher)) + if !strings.Contains(string(launcher), "rdev-bootstrap' layered-run") { + t.Fatalf("launcher should call rdev-bootstrap layered-run, got:\n%s", string(launcher)) } if !pkg.Manifest.NoManualAssembly || !slices.Contains(pkg.Manifest.AgentOnlyParameters, "manifest_root_public_key") || @@ -57,6 +56,58 @@ func TestBuildWritesSelfContainedRunnerPackage(t *testing.T) { } } +func TestRunnerLauncherUsesBootstrapOnly(t *testing.T) { + out := filepath.Join(t.TempDir(), "runner") + pkg, err := Build(Options{ + Invite: testInvite(t), + OutDir: out, + TargetOS: "windows", + TargetArch: "amd64", + SessionMode: string(model.HostModeAttendedTemporary), + GeneratedAt: time.Now().UTC(), + WritePackage: true, + }) + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(pkg.LauncherPath) + if err != nil { + t.Fatal(err) + } + launcher := string(content) + if !strings.Contains(launcher, "rdev-bootstrap") { + t.Fatalf("runner launcher must invoke rdev-bootstrap:\n%s", launcher) + } + for _, forbidden := range []string{"host serve", "rdev-host", "Get-Command rdev", "command -v rdev", "rdev-bootstrap upgrade"} { + if strings.Contains(launcher, forbidden) { + t.Fatalf("runner launcher contains legacy path %q:\n%s", forbidden, launcher) + } + } +} + +func TestRunRejectsExplicitNonBootstrapCommand(t *testing.T) { + out := filepath.Join(t.TempDir(), "runner") + pkg, err := Build(Options{ + Invite: testInvite(t), + OutDir: out, + TargetOS: "linux", + TargetArch: "amd64", + SessionMode: string(model.HostModeAttendedTemporary), + WritePackage: true, + }) + if err != nil { + t.Fatal(err) + } + _, err = Run(RunOptions{ + ManifestPath: pkg.ManifestPath, + BootstrapCommand: "rdev-host", + DryRun: true, + }) + if err == nil || !strings.Contains(err.Error(), "rdev-bootstrap") { + t.Fatalf("explicit non-bootstrap command must fail closed, got %v", err) + } +} + func TestRunDryRunSelectsDirectGateway(t *testing.T) { out := filepath.Join(t.TempDir(), "runner") pkg, err := Build(Options{ @@ -89,12 +140,12 @@ func TestRunDryRunSelectsDirectGateway(t *testing.T) { } if result.SelectedPath != "native-direct-gateway" || result.SelectedTransport != "auto" || - len(result.HostServeArgs) == 0 || - !slices.Contains(result.HostServeArgs, "--manifest-root-public-key") { - t.Fatalf("expected direct host serve dry-run, got %#v", result) + len(result.BootstrapArgs) == 0 || + !slices.Contains(result.BootstrapArgs, "--manifest-root-public-key") { + t.Fatalf("expected bootstrap dry-run, got %#v", result) } if result.Executed { - t.Fatalf("dry-run must not execute host serve") + t.Fatalf("dry-run must not execute bootstrap") } } @@ -169,10 +220,10 @@ func TestRunUsesConfiguredRelayGatewayOverride(t *testing.T) { result.SelectedGatewayURL != "http://127.0.0.1:8787" { t.Fatalf("expected relay gateway override, got %#v", result) } - joined := strings.Join(result.HostServeArgs, " ") + joined := strings.Join(result.BootstrapArgs, " ") if !strings.Contains(joined, "--gateway http://127.0.0.1:8787") || !strings.Contains(joined, "--manifest-url http://127.0.0.1:8787/v1/tickets/") { - t.Fatalf("expected rewritten manifest URL for selected relay gateway, got %v", result.HostServeArgs) + t.Fatalf("expected rewritten manifest URL for selected relay gateway, got %v", result.BootstrapArgs) } } @@ -261,9 +312,9 @@ func TestRunStartsConfiguredHelperBeforeHostServe(t *testing.T) { }, nil }, CommandRunner: func(command string, args []string) error { - events = append(events, "host-serve") - if command != "rdev" || !slices.Contains(args, "--gateway") { - t.Fatalf("unexpected host serve command: %s %v", command, args) + events = append(events, "bootstrap") + if command != "rdev-bootstrap" || !slices.Contains(args, "layered-run") || !slices.Contains(args, "--gateway") { + t.Fatalf("unexpected bootstrap command: %s %v", command, args) } return nil }, @@ -273,7 +324,7 @@ func TestRunStartsConfiguredHelperBeforeHostServe(t *testing.T) { t.Fatal(err) } if !result.HelperStartConfigured || !result.HelperStarted || !result.Executed { - t.Fatalf("expected helper and host serve execution, got %#v", result) + t.Fatalf("expected helper and bootstrap execution, got %#v", result) } transcript := strings.Join(result.HelperTranscript, "\n") for _, expected := range []string{ @@ -281,8 +332,8 @@ func TestRunStartsConfiguredHelperBeforeHostServe(t *testing.T) { "helper_start_configured tool=chisel", "helper_started tool=chisel", "helper_gateway_reachable selected_path=existing-frp-or-chisel-relay", - "host_serve_invoked", - "host_serve_completed", + "bootstrap_invoked", + "bootstrap_completed", "helper_cleanup_attempted tool=chisel", "helper_cleanup_succeeded tool=chisel", } { @@ -293,7 +344,7 @@ func TestRunStartsConfiguredHelperBeforeHostServe(t *testing.T) { if !result.HelperCleanupAttempted || !result.HelperCleanupSucceeded { t.Fatalf("expected cleanup evidence, got %#v", result) } - want := []string{"helper-started", "helper-probed", "host-serve", "helper-cleaned"} + want := []string{"helper-started", "helper-probed", "bootstrap", "helper-cleaned"} if strings.Join(events, "|") != strings.Join(want, "|") { t.Fatalf("unexpected event order: got %v want %v", events, want) } @@ -409,9 +460,9 @@ func TestRunStartsConfiguredStandardConnectivityHelpers(t *testing.T) { }, nil }, CommandRunner: func(command string, args []string) error { - events = append(events, "host-serve") - if command != "rdev" || !slices.Contains(args, "--gateway") || !slices.Contains(args, tc.gatewayURL) { - t.Fatalf("unexpected host serve command: %s %v", command, args) + events = append(events, "bootstrap") + if command != "rdev-bootstrap" || !slices.Contains(args, "layered-run") || !slices.Contains(args, "--gateway") || !slices.Contains(args, tc.gatewayURL) { + t.Fatalf("unexpected bootstrap command: %s %v", command, args) } return nil }, @@ -428,7 +479,7 @@ func TestRunStartsConfiguredStandardConnectivityHelpers(t *testing.T) { !result.Executed { t.Fatalf("expected executed %s helper path, got %#v", tc.selected, result) } - want := []string{"helper-started", "helper-probed", "host-serve", "helper-cleaned"} + want := []string{"helper-started", "helper-probed", "bootstrap", "helper-cleaned"} if strings.Join(events, "|") != strings.Join(want, "|") { t.Fatalf("unexpected event order: got %v want %v", events, want) } @@ -521,7 +572,7 @@ func TestRunInstallsMissingHelperDependencyBeforeHostServe(t *testing.T) { }, nil }, CommandRunner: func(command string, args []string) error { - events = append(events, "host-serve") + events = append(events, "bootstrap") return nil }, ProbeTimeout: time.Second, @@ -531,14 +582,14 @@ func TestRunInstallsMissingHelperDependencyBeforeHostServe(t *testing.T) { } if !result.DependencyInstallConfigured || !result.DependencyInstalled || result.DependencyInstallTool != "chisel" || !result.HelperStarted || !result.Executed { - t.Fatalf("expected install, helper start, and host serve, got %#v", result) + t.Fatalf("expected install, helper start, and bootstrap, got %#v", result) } transcript := strings.Join(result.HelperTranscript, "\n") if !strings.Contains(transcript, "dependency_install_configured tool=chisel") || !strings.Contains(transcript, "dependency_installed tool=chisel") { t.Fatalf("expected dependency install transcript, got %#v", result.HelperTranscript) } - want := []string{"dependency-installed", "helper-started", "helper-probed", "host-serve", "helper-cleaned"} + want := []string{"dependency-installed", "helper-started", "helper-probed", "bootstrap", "helper-cleaned"} if strings.Join(events, "|") != strings.Join(want, "|") { t.Fatalf("unexpected event order: got %v want %v", events, want) } @@ -666,15 +717,18 @@ func testInvite(t *testing.T) agentinvite.Invite { t.Fatal(err) } invite, err := agentinvite.New(agentinvite.Options{ - GatewayURL: "https://api.example.com/v1", - JoinURL: "https://api.example.com/join/" + ticket.Code, - ManifestURL: "https://api.example.com/v1/tickets/" + ticket.Code + "/manifest", - ManifestRootPublicKey: "manifest-root:" + strings.Repeat("d", 43), - Ticket: ticket, - Transport: "auto", - NetworkScope: "auto", - AuthorityProfile: "max-control", - CreatedAt: now, + GatewayURL: "https://api.example.com/v1", + JoinURL: "https://api.example.com/join/" + ticket.Code, + ManifestURL: "https://api.example.com/v1/tickets/" + ticket.Code + "/manifest", + ManifestRootPublicKey: "manifest-root:" + strings.Repeat("d", 43), + LayeredAssetsManifestURL: "https://api.example.com/layered-assets.json", + LayeredReleaseRootPublicKey: "release-root:" + strings.Repeat("e", 43), + LayeredReleaseVersion: "v2.0.0-test", + Ticket: ticket, + Transport: "auto", + NetworkScope: "auto", + AuthorityProfile: "max-control", + CreatedAt: now, }) if err != nil { t.Fatal(err) diff --git a/internal/controlplane/store.go b/internal/controlplane/store.go index b3f495b..f9c5054 100644 --- a/internal/controlplane/store.go +++ b/internal/controlplane/store.go @@ -322,6 +322,47 @@ func (s *MemoryStore) EventsAfter(sessionID string, cursor EventCursor, limit in return events, lease, replay, nil } +// PeekEventsAfter validates the endpoint lease and returns visible events +// without renewing the lease or mutating endpoint cursor state. +func (s *MemoryStore) PeekEventsAfter(sessionID string, cursor EventCursor, limit int) ([]Event, Lease, EventReplayState, error) { + s.mu.Lock() + defer s.mu.Unlock() + + session, ok := s.sessions[sessionID] + if !ok { + return nil, Lease{}, EventReplayState{}, s.err(ErrInvalidJoinCode, "session not found", false) + } + replay := EventReplayState{ + SnapshotSeq: session.SnapshotSeq, + LastSeq: session.LastSeq, + RetryAfterMS: session.RetryAfterMS, + } + if s.sessionExpired(session) { + return nil, Lease{}, replay, s.err(ErrTerminalSession, "session is expired", false) + } + if sessionTerminal(session.Status) { + return nil, Lease{}, replay, s.err(ErrTerminalSession, "session is terminal", false) + } + if cursor.AfterSeq < session.SnapshotSeq { + replay.SnapshotRequired = true + return nil, Lease{}, replay, s.err(ErrSnapshotRequired, "event cursor is older than compacted snapshot", true) + } + endpointIndex := s.endpointIndexLocked(session, cursor.EndpointID) + if endpointIndex < 0 { + return nil, Lease{}, replay, s.err(ErrEndpointNotFound, "endpoint not found", true) + } + endpoint := session.Endpoints[endpointIndex] + reconnecting, err := s.authorizeLeaseLocked(session, endpoint.ID, cursor.LeaseSecret) + if err != nil { + return nil, Lease{}, replay, err + } + replay.Reconnecting = reconnecting + if limit <= 0 || limit > session.Limits.EventBatch { + limit = session.Limits.EventBatch + } + return s.visibleEventsLocked(sessionID, endpoint, cursor.AfterSeq, limit), s.leases[endpoint.ID].Current, replay, nil +} + func (s *MemoryStore) EventsAfterForAgent(sessionID string, afterSeq uint64, limit int) ([]Event, EventReplayState, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/gateway/session.go b/internal/gateway/session.go index 74e3a12..03a66ac 100644 --- a/internal/gateway/session.go +++ b/internal/gateway/session.go @@ -87,6 +87,10 @@ func (g *MemoryGateway) SessionEventsAfter(sessionID string, cursor controlplane return g.controlPlane().EventsAfter(sessionID, cursor, limit) } +func (g *MemoryGateway) PeekSessionEventsAfter(sessionID string, cursor controlplane.EventCursor, limit int) ([]controlplane.Event, controlplane.Lease, controlplane.EventReplayState, error) { + return g.controlPlane().PeekEventsAfter(sessionID, cursor, limit) +} + func (g *MemoryGateway) SessionEventsAfterForAgent(sessionID string, afterSeq uint64, limit int) ([]controlplane.Event, controlplane.EventReplayState, error) { return g.controlPlane().EventsAfterForAgent(sessionID, afterSeq, limit) } diff --git a/internal/hostcmd/hostcmd.go b/internal/hostcmd/hostcmd.go index 0152b19..98ce306 100644 --- a/internal/hostcmd/hostcmd.go +++ b/internal/hostcmd/hostcmd.go @@ -14,7 +14,6 @@ import ( "fmt" "io" "math" - "net" "net/http" "net/url" "os" @@ -122,7 +121,7 @@ func (a App) serve(ctx context.Context, args []string) error { manifestURL := fs.String("manifest-url", "", "signed join manifest URL") name := fs.String("name", "", "host display name; defaults to detected hostname") once := fs.Bool("once", true, "join once and exit after printing status") - transport := fs.String("transport", "poll", "session event transport: auto, poll, long-poll, or wss") + transport := fs.String("transport", "poll", "session event transport: auto, poll, or long-poll") pollInterval := fs.Duration("poll-interval", time.Second, "session event polling interval when --once=false") longPollTimeout := fs.Duration("long-poll-timeout", 25*time.Second, "long-poll wait duration when --transport=long-poll") maxTasks := fs.Int("max-tasks", 1, "maximum session tasks to process when --once=false; 0 = unlimited") @@ -194,7 +193,7 @@ func (a App) runServe(ctx context.Context, opts serveOptions) error { opts.Transport = "poll" } switch opts.Transport { - case "auto", "poll", "long-poll", "wss": + case "auto", "poll", "long-poll": default: return fmt.Errorf("unsupported host transport %q", opts.Transport) } @@ -207,13 +206,22 @@ func (a App) runServe(ctx context.Context, opts serveOptions) error { return err } if opts.ManifestURL != "" { - manifest, err := fetchJoinManifest(ctx, gatewayClient, opts.ManifestURL, opts.TrustPin, opts.ManifestRootPublicKey) + if !safeRouteURL(strings.TrimSpace(opts.ManifestURL)) { + return fmt.Errorf("join manifest URL must use HTTPS or an explicit loopback development endpoint") + } + configuredGateway := strings.TrimRight(strings.TrimSpace(opts.GatewayURL), "/") + manifestCtx, cancelManifest := context.WithTimeout(ctx, 30*time.Second) + manifest, err := fetchJoinManifest(manifestCtx, gatewayClient, opts.ManifestURL, opts.TrustPin, opts.ManifestRootPublicKey) + cancelManifest() if err != nil { - return err + return fmt.Errorf("join manifest verification failed") + } + if configuredGateway != "" && !joinManifestContainsGateway(manifest, configuredGateway) { + return fmt.Errorf("configured gateway is not present in the verified join manifest") } opts.ManifestGatewayCandidates = manifest.GatewayCandidates if strings.TrimSpace(opts.GatewayURL) == "" { - opts.GatewayURL = selectJoinManifestGatewayURL(ctx, gatewayClient, manifest) + opts.GatewayURL = manifest.GatewayURL } opts.TicketCode = manifest.TicketCode opts.TrustPin = manifest.TrustFingerprint @@ -231,6 +239,16 @@ func (a App) runServe(ctx context.Context, opts serveOptions) error { if !isLocalDevGatewayURL(opts.GatewayURL) && !isSignedManifestGatewayURL(opts.GatewayURL, manifestRootVerified) { return fmt.Errorf("non-local gateway registration requires --manifest-url with --manifest-root-public-key and an HTTPS or private/LAN gateway URL") } + routes := newGatewayCandidateSet(opts.GatewayURL, opts.ManifestGatewayCandidates, opts.Transport) + if err := validateGatewayCandidateSet(routes, manifestRootVerified); err != nil { + return err + } + selectedRoute, err := routes.initialize(ctx, gatewayClient, opts.TrustPin) + if err != nil { + return err + } + opts.GatewayURL = selectedRoute.URL + opts.Transport = selectedRoute.Transport identity, identityCreated, err := hostidentity.LoadOrCreate(opts.IdentityStorePath, opts.IdentityKeyID) if err != nil { return err @@ -250,10 +268,7 @@ func (a App) runServe(ctx context.Context, opts serveOptions) error { RenewAfterMS: 20_000, RetryAfterMS: 1_000, } - switch opts.Transport { - case "wss": - endpointSpec.Transport = controlplane.TransportWSS - case "poll": + if opts.Transport == "poll" { endpointSpec.Transport = controlplane.TransportPoll } var enrollmentCertificateSummary *model.HostEnrollmentCertificate @@ -264,7 +279,9 @@ func (a App) runServe(ctx context.Context, opts serveOptions) error { } enrollmentCertificateSummary = &certificate } - session, endpoint, lease, _, err := joinSessionByCode(ctx, gatewayClient, opts.GatewayURL, opts.TicketCode, endpointSpec) + joinCtx, cancelJoin := context.WithTimeout(ctx, 30*time.Second) + session, endpoint, lease, initialEvents, err := joinSessionByCode(joinCtx, gatewayClient, opts.GatewayURL, opts.TicketCode, endpointSpec) + cancelJoin() if err != nil { return err } @@ -319,7 +336,7 @@ func (a App) runServe(ctx context.Context, opts serveOptions) error { } else { _, _ = fmt.Fprintf(a.Stderr, "[rdev] keep-awake not active: %s\n", keepAwake.Detail) } - processed, err := a.runSessionTasks(ctx, opts, gatewayClient, session.ID, endpoint.ID, identity.Fingerprint(), lease.Secret, lease) + processed, err := a.runSessionTasksWithEvents(ctx, opts, gatewayClient, session.ID, endpoint.ID, identity.Fingerprint(), lease.Secret, lease, routes, initialEvents) if err != nil { return err } @@ -460,7 +477,7 @@ func gatewayHTTPClient(opts serveOptions) (*http.Client, error) { transport.TLSClientConfig = tlsConfig base = transport } - return &http.Client{Transport: retryingRoundTripper{Base: base, MaxRetries: 3}}, nil + return &http.Client{Transport: retryingRoundTripper{Base: base, MaxRetries: 3}, CheckRedirect: rejectGatewayRedirect}, nil } func gatewayTLSClientConfig(opts serveOptions) (*tls.Config, error) { @@ -495,7 +512,7 @@ func gatewayTLSClientConfig(opts serveOptions) (*tls.Config, error) { func isLocalDevGatewayURL(value string) bool { parsed, err := url.Parse(value) return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && - (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "localhost") && parsed.Port() != "" + (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "::1" || parsed.Hostname() == "localhost") && parsed.Port() != "" } func isSignedManifestGatewayURL(value string, manifestRootVerified bool) bool { @@ -509,16 +526,7 @@ func isSignedManifestGatewayURL(value string, manifestRootVerified bool) bool { if parsed.Scheme == "https" { return true } - return parsed.Scheme == "http" && isPrivateOrLANHost(parsed.Hostname()) && parsed.Port() != "" -} - -func isPrivateOrLANHost(host string) bool { - normalized := strings.Trim(strings.ToLower(host), "[]") - if normalized == "localhost" || strings.HasSuffix(normalized, ".local") || strings.HasSuffix(normalized, ".lan") { - return true - } - ip := net.ParseIP(normalized) - return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()) + return isLocalDevGatewayURL(value) } func readEnrollmentCertificateFile(path string) (model.HostEnrollmentCertificate, error) { @@ -626,11 +634,23 @@ func isRetryableNetErr(err error) bool { func doGatewayRequest(client *http.Client, req *http.Request) (*http.Response, error) { if client == nil { - client = &http.Client{Transport: retryingRoundTripper{Base: http.DefaultTransport, MaxRetries: 3}} + client = &http.Client{Transport: retryingRoundTripper{Base: http.DefaultTransport, MaxRetries: 3}, CheckRedirect: rejectGatewayRedirect} + } else { + guarded := *client + guarded.CheckRedirect = rejectGatewayRedirect + client = &guarded } return client.Do(req) } +func rejectGatewayRedirect(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse +} + +func opaqueGatewayRouteError(operation string) error { + return fmt.Errorf("gateway route %s", operation) +} + func newIdempotencyKey(prefix string) string { var raw [16]byte if _, err := rand.Read(raw[:]); err != nil { @@ -649,18 +669,18 @@ func joinSessionByCode(ctx context.Context, client *http.Client, gatewayURL, joi } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(gatewayURL, "/")+"/v1/session-joins", bytes.NewReader(body)) if err != nil { - return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, err + return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, opaqueGatewayRouteError("registration request is invalid") } req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", newIdempotencyKey("session-join")) resp, err := doGatewayRequest(client, req) if err != nil { - return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, err + return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, opaqueGatewayRouteError("registration failed") } defer resp.Body.Close() - body, err = io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + body, err = readBoundedGatewayBody(resp.Body, 256*1024) if err != nil { - return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, err + return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, opaqueGatewayRouteError("registration response failed") } var payload struct { Session controlplane.Session `json:"session"` @@ -677,10 +697,21 @@ func joinSessionByCode(ctx context.Context, client *http.Client, gatewayURL, joi if payload.Session.ID == "" || payload.Endpoint.ID == "" || payload.Lease.Secret == "" { return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, fmt.Errorf("join session failed: incomplete session join response") } + if err := validateLeaseBinding(controlplane.Lease{}, payload.Lease, payload.Session.ID, payload.Endpoint.ID); err != nil { + return controlplane.Session{}, controlplane.Endpoint{}, controlplane.Lease{}, nil, err + } return payload.Session, payload.Endpoint, payload.Lease, payload.Events, nil } -func (a App) runSessionTasks(ctx context.Context, opts serveOptions, client *http.Client, sessionID, endpointID, identityFingerprint, leaseSecret string, lease controlplane.Lease) (int, error) { +func (a App) runSessionTasks(ctx context.Context, opts serveOptions, client *http.Client, sessionID, endpointID, identityFingerprint, leaseSecret string, lease controlplane.Lease, providedRoutes ...*gatewayCandidateSet) (int, error) { + var routes *gatewayCandidateSet + if len(providedRoutes) > 0 { + routes = providedRoutes[0] + } + return a.runSessionTasksWithEvents(ctx, opts, client, sessionID, endpointID, identityFingerprint, leaseSecret, lease, routes, nil) +} + +func (a App) runSessionTasksWithEvents(ctx context.Context, opts serveOptions, client *http.Client, sessionID, endpointID, identityFingerprint, leaseSecret string, lease controlplane.Lease, providedRoutes *gatewayCandidateSet, initialEvents []controlplane.Event) (int, error) { maxTasks := opts.MaxTasks switch { case maxTasks == 0: @@ -692,30 +723,97 @@ func (a App) runSessionTasks(ctx context.Context, opts serveOptions, client *htt if interval <= 0 { interval = time.Second } - routes := newGatewayCandidateSet(opts.GatewayURL, opts.ManifestGatewayCandidates) + routes := newGatewayCandidateSet(opts.GatewayURL, opts.ManifestGatewayCandidates, opts.Transport) + if providedRoutes != nil { + routes = providedRoutes + } + selectedRoute, err := routes.initialize(ctx, client, opts.TrustPin) + if err != nil { + return 0, err + } + opts.GatewayURL = selectedRoute.URL + opts.Transport = selectedRoute.Transport for { - opts.GatewayURL = routes.current() - if _, err := fetchHostTrust(ctx, client, opts.GatewayURL, opts.TrustPin, opts.TrustStorePath); err == nil { - break - } else if !routes.rotate(ctx, client, opts.TrustPin) { - return 0, err + snapshot, ok := routes.currentSnapshot() + if !ok { + return 0, errNoHealthyRoutes + } + opts.GatewayURL = snapshot.Candidate.URL + opts.Transport = snapshot.Candidate.Transport + trustCtx, cancelTrust := routeRequestContext(ctx, snapshot, 2*time.Second) + _, trustErr := fetchHostTrust(trustCtx, client, opts.GatewayURL, opts.TrustPin, opts.TrustStorePath) + cancelTrust() + if trustErr == nil { + if routes.reportSuccess(snapshot) { + break + } + continue + } else if !routes.reportFailure(ctx, snapshot) { + return 0, opaqueGatewayRouteError("trust verification failed") } } + monitorCtx, stopMonitor := context.WithCancel(ctx) + monitorErrors := make(chan error, 1) + monitorDone := make(chan struct{}) + go func() { + defer close(monitorDone) + monitorErrors <- routes.monitor(monitorCtx, time.Second) + }() + defer func() { + stopMonitor() + <-monitorDone + }() processed := 0 afterSeq := uint64(0) + currentLease := lease + pendingInitialEvents := append([]controlplane.Event(nil), initialEvents...) if lease.RetryAfterMS > 0 { interval = time.Duration(lease.RetryAfterMS) * time.Millisecond } pollLoop: for processed < maxTasks { - opts.GatewayURL = routes.current() - events, nextLease, replay, err := fetchSessionEvents(ctx, client, opts.GatewayURL, sessionID, endpointID, leaseSecret, afterSeq, sessionEventLimit(opts.Transport)) + select { + case monitorErr := <-monitorErrors: + if monitorErr != nil && !errors.Is(monitorErr, context.Canceled) { + return processed, monitorErr + } + default: + } + snapshot, ok := routes.currentSnapshot() + if !ok { + if _, err := routes.probe(ctx); err != nil { + return processed, err + } + snapshot, ok = routes.currentSnapshot() + if !ok { + if err := sleepOrDone(ctx, interval); err != nil { + return processed, err + } + continue + } + } + opts.GatewayURL = snapshot.Candidate.URL + opts.Transport = snapshot.Candidate.Transport + var events []controlplane.Event + var nextLease controlplane.Lease + var replay controlplane.EventReplayState + var err error + eventLimit := sessionEventLimit(opts.Transport) + eventsFromInitial := len(pendingInitialEvents) > 0 + if eventsFromInitial { + events = append([]controlplane.Event(nil), pendingInitialEvents...) + pendingInitialEvents = nil + } else { + requestCtx, cancelRequest := routeRequestContext(ctx, snapshot, sessionRequestTimeout(opts)) + events, nextLease, replay, err = fetchSessionEvents(requestCtx, client, opts.GatewayURL, sessionID, endpointID, leaseSecret, afterSeq, eventLimit, sessionLongPollWait(opts)) + cancelRequest() + } if err != nil { if isTransientGatewayResponseError(err) { - if routes.rotate(ctx, client, opts.TrustPin) { + if routes.reportFailure(ctx, snapshot) { continue pollLoop } - _, _ = fmt.Fprintf(a.Stderr, "rdev-host: transient gateway response while polling session events: %v\n", err) + _, _ = fmt.Fprintln(a.Stderr, "rdev-host: transient gateway response while polling session events; retrying after backoff") if err := sleepOrDone(ctx, interval); err != nil { return processed, err } @@ -723,8 +821,29 @@ pollLoop: } return processed, err } + if !eventsFromInitial { + if nextLease.Secret == "" { + if routes.reportFailure(ctx, snapshot) { + continue pollLoop + } + return processed, opaqueGatewayRouteError("returned an incomplete lease") + } + if err := validateLeaseBinding(currentLease, nextLease, sessionID, endpointID); err != nil { + if routes.reportFailure(ctx, snapshot) { + continue pollLoop + } + return processed, err + } + } + if !routes.reportSuccess(snapshot) { + if eventsFromInitial { + pendingInitialEvents = append([]controlplane.Event(nil), events...) + } + continue pollLoop + } if nextLease.Secret != "" { leaseSecret = nextLease.Secret + currentLease = nextLease if nextLease.RetryAfterMS > 0 { interval = time.Duration(nextLease.RetryAfterMS) * time.Millisecond } @@ -733,39 +852,55 @@ pollLoop: return processed, fmt.Errorf("session event cursor is stale; restart host session to refresh snapshot") } foundTask := false - batchStartSeq := afterSeq - for _, event := range events { - if event.Seq > afterSeq { - afterSeq = event.Seq + for eventIndex, event := range events { + if event.Seq <= afterSeq { + continue } if event.Type != controlplane.EventTypeTask || event.TaskID == "" { + afterSeq = event.Seq continue } action, _ := event.Payload["action"].(string) if action != "offer" { + afterSeq = event.Seq continue } - task, err := fetchSessionTask(ctx, client, opts.GatewayURL, sessionID, event.TaskID) + taskSnapshot, ok := routes.currentSnapshot() + if !ok { + pendingInitialEvents = append([]controlplane.Event(nil), events[eventIndex:]...) + continue pollLoop + } + opts.GatewayURL = taskSnapshot.Candidate.URL + opts.Transport = taskSnapshot.Candidate.Transport + requestCtx, cancelRequest := routeRequestContext(ctx, taskSnapshot, sessionRequestTimeout(opts)) + task, err := fetchSessionTask(requestCtx, client, opts.GatewayURL, sessionID, event.TaskID) + cancelRequest() if err != nil { - if isTransientGatewayResponseError(err) && routes.rotate(ctx, client, opts.TrustPin) { - afterSeq = batchStartSeq + if isTransientGatewayResponseError(err) && routes.reportFailure(ctx, taskSnapshot) { + pendingInitialEvents = append([]controlplane.Event(nil), events[eventIndex:]...) continue pollLoop } return processed, err } + if !routes.reportSuccess(taskSnapshot) { + pendingInitialEvents = append([]controlplane.Event(nil), events[eventIndex:]...) + continue pollLoop + } if task.TargetEndpointID != endpointID || task.Terminal() { + afterSeq = event.Seq continue } foundTask = true if err := a.runSessionTaskWithRoutes(ctx, opts, client, sessionID, endpointID, identityFingerprint, leaseSecret, task, routes); err != nil { return processed, err } + afterSeq = event.Seq processed++ if processed >= maxTasks { return processed, nil } } - if replay.LastSeq > afterSeq { + if len(events) < eventLimit && replay.LastSeq > afterSeq { afterSeq = replay.LastSeq } if !foundTask { @@ -812,10 +947,30 @@ func (a App) runSessionTaskWithRoutes(ctx context.Context, opts serveOptions, cl payload["runtime_fixture_content"] = result.RuntimeFixtureContent } for { - opts.GatewayURL = routes.current() - if _, _, completeErr := completeSessionTask(ctx, client, opts.GatewayURL, sessionID, task.ID, leaseSecret, payload); completeErr == nil { + snapshot, pooled := routes.currentSnapshot() + if pooled { + opts.GatewayURL = snapshot.Candidate.URL + opts.Transport = snapshot.Candidate.Transport + } else { + if routes.pool != nil { + return errNoHealthyRoutes + } + opts.GatewayURL = routes.current() + opts.Transport = routes.currentTransport() + } + requestCtx := ctx + cancelRequest := func() {} + if pooled { + requestCtx, cancelRequest = routeRequestContext(ctx, snapshot, sessionRequestTimeout(opts)) + } + _, _, completeErr := completeSessionTask(requestCtx, client, opts.GatewayURL, sessionID, task.ID, leaseSecret, payload) + cancelRequest() + if completeErr == nil { + if pooled { + _ = routes.reportSuccess(snapshot) + } return nil - } else if !isTransientGatewayResponseError(completeErr) || !routes.rotate(ctx, client, opts.TrustPin) { + } else if !isTransientGatewayResponseError(completeErr) || (pooled && !routes.reportFailure(ctx, snapshot)) || (!pooled && !routes.rotate(ctx, client, opts.TrustPin)) { if err != nil { return fmt.Errorf("%v; additionally failed to report session task failure: %w", err, completeErr) } @@ -913,13 +1068,20 @@ func intValueFromAny(value any) int { } } -func fetchSessionEvents(ctx context.Context, client *http.Client, gatewayURL, sessionID, endpointID, leaseSecret string, afterSeq uint64, limit int) ([]controlplane.Event, controlplane.Lease, controlplane.EventReplayState, error) { +func fetchSessionEvents(ctx context.Context, client *http.Client, gatewayURL, sessionID, endpointID, leaseSecret string, afterSeq uint64, limit int, wait ...time.Duration) ([]controlplane.Event, controlplane.Lease, controlplane.EventReplayState, error) { values := url.Values{} values.Set("endpoint_id", endpointID) values.Set("after_seq", strconv.FormatUint(afterSeq, 10)) if limit > 0 { values.Set("limit", strconv.Itoa(limit)) } + if len(wait) > 0 && wait[0] > 0 { + waitMS := wait[0].Milliseconds() + if waitMS > 60_000 { + waitMS = 60_000 + } + values.Set("wait_ms", strconv.FormatInt(waitMS, 10)) + } endpoint := strings.TrimRight(gatewayURL, "/") + "/v1/sessions/" + url.PathEscape(sessionID) + "/events?" + values.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -933,7 +1095,7 @@ func fetchSessionEvents(ctx context.Context, client *http.Client, gatewayURL, se return nil, controlplane.Lease{}, controlplane.EventReplayState{}, transientGatewayResponseError{Endpoint: endpoint, Cause: err} } defer resp.Body.Close() - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + body, readErr := readBoundedGatewayBody(resp.Body, 256*1024) if readErr != nil { return nil, controlplane.Lease{}, controlplane.EventReplayState{}, transientGatewayResponseError{Endpoint: endpoint, Status: resp.Status, Cause: readErr} } @@ -975,7 +1137,7 @@ func fetchSessionTask(ctx context.Context, client *http.Client, gatewayURL, sess return controlplane.Task{}, transientGatewayResponseError{Endpoint: req.URL.String(), Cause: err} } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + body, err := readBoundedGatewayBody(resp.Body, 256*1024) if err != nil { return controlplane.Task{}, transientGatewayResponseError{Endpoint: req.URL.String(), Status: resp.Status, Cause: err} } @@ -1019,7 +1181,7 @@ func completeSessionTask(ctx context.Context, client *http.Client, gatewayURL, s return controlplane.Task{}, controlplane.Event{}, transientGatewayResponseError{Endpoint: req.URL.String(), Cause: err} } defer resp.Body.Close() - responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + responseBody, err := readBoundedGatewayBody(resp.Body, 256*1024) if err != nil { return controlplane.Task{}, controlplane.Event{}, transientGatewayResponseError{Endpoint: req.URL.String(), Status: resp.Status, Cause: err} } @@ -1043,6 +1205,40 @@ func sessionEventLimit(transport string) int { return 64 } +func validateLeaseBinding(current, next controlplane.Lease, sessionID, endpointID string) error { + if next.Secret == "" { + return nil + } + if next.SessionID != sessionID || next.EndpointID != endpointID { + return fmt.Errorf("gateway lease binding does not match the registered session") + } + if next.Generation <= 0 || (current.Generation > 0 && next.Generation <= current.Generation) { + return fmt.Errorf("gateway lease generation did not advance") + } + return nil +} + +func sessionRequestTimeout(opts serveOptions) time.Duration { + if opts.Transport == "long-poll" { + return sessionLongPollWait(opts) + 5*time.Second + } + return 30 * time.Second +} + +func sessionLongPollWait(opts serveOptions) time.Duration { + if opts.Transport != "long-poll" { + return 0 + } + wait := opts.LongPollTimeout + if wait <= 0 { + wait = 25 * time.Second + } + if wait > 60*time.Second { + wait = 60 * time.Second + } + return wait +} + func sleepOrDone(ctx context.Context, interval time.Duration) error { if interval <= 0 { interval = time.Second @@ -1060,11 +1256,11 @@ func sleepOrDone(ctx context.Context, interval time.Duration) error { func fetchJoinManifest(ctx context.Context, client *http.Client, manifestURL, trustPin, manifestRootPublicKey string) (model.JoinManifest, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) if err != nil { - return model.JoinManifest{}, err + return model.JoinManifest{}, fmt.Errorf("join manifest request is invalid") } resp, err := doGatewayRequest(client, req) if err != nil { - return model.JoinManifest{}, err + return model.JoinManifest{}, fmt.Errorf("join manifest request failed") } defer resp.Body.Close() var payload struct { @@ -1072,9 +1268,12 @@ func fetchJoinManifest(ctx context.Context, client *http.Client, manifestURL, tr GatewayTimeProof model.GatewayTimeProof `json:"gateway_time_proof"` Error string `json:"error"` } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + if err := decodeBoundedGatewayJSON(resp.Body, 256*1024, &payload); err != nil { return model.JoinManifest{}, err } + if len(payload.Manifest.GatewayCandidates) > maxManifestCandidates { + return model.JoinManifest{}, fmt.Errorf("join manifest contains too many gateway candidates") + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { if payload.Error == "" { payload.Error = resp.Status @@ -1114,6 +1313,14 @@ func fetchJoinManifest(ctx context.Context, client *http.Client, manifestURL, tr if err := payload.Manifest.Trust.VerifyPin(trustPin); err != nil { return model.JoinManifest{}, err } + if !safeRouteURL(payload.Manifest.GatewayURL) { + return model.JoinManifest{}, fmt.Errorf("join manifest contains an invalid gateway URL") + } + for _, candidate := range payload.Manifest.GatewayCandidates { + if !safeRouteURL(candidate.URL) { + return model.JoinManifest{}, fmt.Errorf("join manifest contains an invalid gateway candidate") + } + } return payload.Manifest, nil } @@ -1143,57 +1350,164 @@ func selectJoinManifestGatewayURL(ctx context.Context, client *http.Client, mani return fallback } +func joinManifestContainsGateway(manifest model.JoinManifest, value string) bool { + value = strings.TrimRight(strings.TrimSpace(value), "/") + if value == "" || !safeRouteURL(value) { + return false + } + if strings.TrimRight(strings.TrimSpace(manifest.GatewayURL), "/") == value { + return true + } + for _, candidate := range manifest.GatewayCandidates { + if strings.TrimRight(strings.TrimSpace(candidate.URL), "/") == value { + return true + } + } + return false +} + type gatewayCandidateSet struct { - urls []string - index int + routes []routeCandidate + pool *routePool + transport string } -func newGatewayCandidateSet(current string, candidates []model.JoinManifestGatewayCandidate) *gatewayCandidateSet { - urls := make([]string, 0, len(candidates)+1) - add := func(value string) { - value = strings.TrimRight(strings.TrimSpace(value), "/") - if value == "" { - return - } - for _, existing := range urls { - if existing == value { - return - } +func newGatewayCandidateSet(current string, candidates []model.JoinManifestGatewayCandidate, transport ...string) *gatewayCandidateSet { + selectedTransport := "poll" + if len(transport) > 0 && strings.TrimSpace(transport[0]) != "" { + selectedTransport = transport[0] + } + return &gatewayCandidateSet{ + routes: routeCandidatesFromManifest(current, candidates, selectedTransport), + transport: selectedTransport, + } +} + +func newGatewayCandidateSetWithPool(pool *routePool) *gatewayCandidateSet { + return &gatewayCandidateSet{pool: pool} +} + +func validateGatewayCandidateSet(routes *gatewayCandidateSet, manifestRootVerified bool) error { + if routes == nil || len(routes.routes) == 0 { + return errNoHealthyRoutes + } + for _, route := range routes.routes { + if !isLocalDevGatewayURL(route.URL) && !isSignedManifestGatewayURL(route.URL, manifestRootVerified) { + return fmt.Errorf("gateway route is outside the verified manifest trust boundary") } - urls = append(urls, value) } - add(current) - for _, candidate := range candidates { - add(candidate.URL) + return nil +} + +func (s *gatewayCandidateSet) ensurePool(client *http.Client, trustPin string) *routePool { + if s == nil { + return nil + } + if s.pool != nil { + return s.pool + } + probe := func(ctx context.Context, route routeCandidate) routeProbeResult { + started := time.Now() + _, err := fetchHostTrust(ctx, client, route.URL, trustPin, "") + return routeProbeResult{Healthy: err == nil, Latency: time.Since(started), Err: err} } - return &gatewayCandidateSet{urls: urls} + s.pool = newRoutePool(s.routes, routePoolConfig{Probe: probe, ReprobeInterval: 5 * time.Second, ShareGatewayHealth: true}) + return s.pool } func (s *gatewayCandidateSet) current() string { - if s == nil || len(s.urls) == 0 { + if s == nil { return "" } - if s.index >= len(s.urls) { - s.index = 0 + if route, ok := s.poolCurrent(); ok { + return route.URL + } + if len(s.routes) > 0 { + return s.routes[0].URL + } + return "" +} + +func (s *gatewayCandidateSet) currentTransport() string { + if s == nil { + return "poll" + } + if route, ok := s.poolCurrent(); ok { + return route.Transport + } + if len(s.routes) > 0 { + return s.routes[0].Transport + } + return normalizeRouteTransport(s.transport) +} + +func (s *gatewayCandidateSet) poolCurrent() (routeCandidate, bool) { + if s == nil || s.pool == nil { + return routeCandidate{}, false + } + return s.pool.current() +} + +func (s *gatewayCandidateSet) currentSnapshot() (routeSnapshot, bool) { + if s == nil || s.pool == nil { + return routeSnapshot{}, false + } + return s.pool.currentSnapshot() +} + +func (s *gatewayCandidateSet) reportSuccess(snapshot routeSnapshot) bool { + if s == nil || s.pool == nil { + return false + } + return s.pool.reportSnapshotSuccess(snapshot) +} + +func (s *gatewayCandidateSet) reportFailure(ctx context.Context, snapshot routeSnapshot) bool { + if s == nil || s.pool == nil { + return false + } + _, ok := s.pool.reportSnapshotFailure(ctx, snapshot) + return ok +} + +func (s *gatewayCandidateSet) initialize(ctx context.Context, client *http.Client, trustPin string) (routeCandidate, error) { + pool := s.ensurePool(client, trustPin) + if pool == nil { + return routeCandidate{}, errNoHealthyRoutes } - return s.urls[s.index] + return pool.initialize(ctx) } func (s *gatewayCandidateSet) rotate(ctx context.Context, client *http.Client, trustPin string) bool { - if s == nil || len(s.urls) < 2 { + pool := s.ensurePool(client, trustPin) + if pool == nil { return false } - for step := 1; step < len(s.urls); step++ { - index := (s.index + step) % len(s.urls) - probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - reachable := joinManifestGatewayReachable(probeCtx, client, s.urls[index], trustPin) - cancel() - if reachable { - s.index = index - return true - } + before, beforeOK := pool.current() + if !beforeOK { + _, err := pool.initialize(ctx) + return err == nil } - return false + _, afterOK := pool.reportFailure(ctx, before) + if !afterOK { + return false + } + after, currentOK := pool.current() + return currentOK && after != before +} + +func (s *gatewayCandidateSet) probe(ctx context.Context) (bool, error) { + if s == nil || s.pool == nil { + return false, nil + } + return s.pool.probe(ctx) +} + +func (s *gatewayCandidateSet) monitor(ctx context.Context, interval time.Duration) error { + if s == nil || s.pool == nil { + return errNoHealthyRoutes + } + return s.pool.monitor(ctx, interval) } func joinManifestGatewayReachable(ctx context.Context, client *http.Client, gatewayURL, trustPin string) bool { @@ -1208,6 +1522,19 @@ type hostTrust struct { SignedBundle *model.SignedTrustBundle } +type signedTrustHTTPError struct { + status int +} + +func (e signedTrustHTTPError) Error() string { + return fmt.Sprintf("signed trust endpoint returned status class %d", e.status/100) +} + +func signedTrustEndpointUnsupported(err error) bool { + var statusErr signedTrustHTTPError + return errors.As(err, &statusErr) && statusErr.status == http.StatusNotFound +} + func fetchHostTrust(ctx context.Context, client *http.Client, gatewayURL, trustPin, trustStorePath string) (hostTrust, error) { store, err := hosttrust.OpenStore(trustStorePath) if err != nil { @@ -1226,14 +1553,21 @@ func fetchHostTrust(ctx context.Context, client *http.Client, gatewayURL, trustP } return hostTrust{SignedBundle: &signed}, nil } + if !signedTrustEndpointUnsupported(err) { + return hostTrust{}, fmt.Errorf("signed trust verification failed") + } if stored, ok, storeErr := store.Load(); storeErr != nil { return hostTrust{}, storeErr } else if ok { + root, rootErr := activeSigningRoot(stored) + if rootErr != nil || stored.Verify(root, time.Now()) != nil || root.VerifyPin(trustPin) != nil { + return hostTrust{}, fmt.Errorf("stored signed trust verification failed") + } return hostTrust{SignedBundle: &stored}, nil } legacy, legacyErr := fetchTrustBundle(ctx, client, gatewayURL, trustPin) if legacyErr != nil { - return hostTrust{}, fmt.Errorf("fetch signed trust bundle failed: %v; fallback legacy trust failed: %w", err, legacyErr) + return hostTrust{}, fmt.Errorf("legacy trust verification failed") } return hostTrust{Legacy: &legacy}, nil } @@ -1257,22 +1591,20 @@ func fetchSignedTrustBundle(ctx context.Context, client *http.Client, gatewayURL } resp, err := doGatewayRequest(client, req) if err != nil { - return model.SignedTrustBundle{}, err + return model.SignedTrustBundle{}, fmt.Errorf("signed trust request failed") } defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + return model.SignedTrustBundle{}, signedTrustHTTPError{status: resp.StatusCode} + } var payload struct { TrustBundle model.SignedTrustBundle `json:"trust_bundle"` Error string `json:"error"` } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + if err := decodeBoundedGatewayJSON(resp.Body, 256*1024, &payload); err != nil { return model.SignedTrustBundle{}, err } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - if payload.Error == "" { - payload.Error = resp.Status - } - return model.SignedTrustBundle{}, fmt.Errorf("fetch signed trust bundle failed: %s", payload.Error) - } root, err := activeSigningRoot(payload.TrustBundle) if err != nil { return model.SignedTrustBundle{}, err @@ -1293,22 +1625,20 @@ func fetchTrustBundle(ctx context.Context, client *http.Client, gatewayURL, trus } resp, err := doGatewayRequest(client, req) if err != nil { - return model.TrustBundle{}, err + return model.TrustBundle{}, fmt.Errorf("legacy trust request failed") } defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + return model.TrustBundle{}, fmt.Errorf("legacy trust endpoint returned status class %d", resp.StatusCode/100) + } var payload struct { Trust model.TrustBundle `json:"trust"` Error string `json:"error"` } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + if err := decodeBoundedGatewayJSON(resp.Body, 64*1024, &payload); err != nil { return model.TrustBundle{}, err } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - if payload.Error == "" { - payload.Error = resp.Status - } - return model.TrustBundle{}, fmt.Errorf("fetch trust bundle failed: %s", payload.Error) - } if _, err := payload.Trust.Ed25519PublicKey(); err != nil { return model.TrustBundle{}, err } @@ -1333,11 +1663,7 @@ func gatewayResponseError(operation, endpoint string, resp *http.Response, body Cause: cause, } } - status := "gateway response unavailable" - if resp != nil { - status = resp.Status - } - return fmt.Errorf("%s: %s", operation, gatewayErrorMessage(status, body, cause)) + return fmt.Errorf("%s: gateway response rejected", operation) } func gatewayRouteFailure(statusCode int, body []byte) bool { @@ -1355,17 +1681,7 @@ func gatewayRouteFailure(statusCode int, body []byte) bool { } func (e transientGatewayResponseError) Error() string { - parts := []string{"unexpected gateway response"} - if e.Status != "" { - parts = append(parts, "status="+e.Status) - } - if e.Body != "" { - parts = append(parts, "body="+e.Body) - } - if e.Cause != nil { - parts = append(parts, "cause="+e.Cause.Error()) - } - return strings.Join(parts, " ") + return "transient gateway response" } func isTransientGatewayResponseError(err error) bool { @@ -1374,18 +1690,11 @@ func isTransientGatewayResponseError(err error) bool { } func gatewayErrorMessage(status string, body []byte, cause error) string { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err == nil { - if message, _ := payload["error"].(string); strings.TrimSpace(message) != "" { - return message - } - } - message := strings.TrimSpace(bodyPreview(body)) + _ = body + _ = cause + message := strings.TrimSpace(status) if message == "" { - message = status - } - if cause != nil { - return message + " (" + cause.Error() + ")" + message = "gateway response rejected" } return message } @@ -1399,6 +1708,31 @@ func bodyPreview(body []byte) string { return value } +func decodeBoundedGatewayJSON(body io.Reader, maxBytes int64, destination any) error { + content, err := readBoundedGatewayBody(body, maxBytes) + if err != nil { + return err + } + if err := json.Unmarshal(content, destination); err != nil { + return fmt.Errorf("decode gateway response: %w", err) + } + return nil +} + +func readBoundedGatewayBody(body io.Reader, maxBytes int64) ([]byte, error) { + if maxBytes <= 0 { + return nil, fmt.Errorf("gateway response size limit is invalid") + } + content, err := io.ReadAll(io.LimitReader(body, maxBytes+1)) + if err != nil { + return nil, fmt.Errorf("read gateway response: %w", err) + } + if int64(len(content)) > maxBytes { + return nil, fmt.Errorf("gateway response exceeds the size limit") + } + return content, nil +} + func splitCommaList(value string) []string { if strings.TrimSpace(value) == "" { return nil diff --git a/internal/hostcmd/hostcmd_test.go b/internal/hostcmd/hostcmd_test.go index 8f938d9..4f48de5 100644 --- a/internal/hostcmd/hostcmd_test.go +++ b/internal/hostcmd/hostcmd_test.go @@ -151,7 +151,7 @@ func TestJoinSessionRetriesTransientEOFWithIdempotencyKey(t *testing.T) { return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{"session":{"schema_version":"rdev.session.v1","id":"ses_1","join_code":"TICKET-1","status":"waiting","created_at":"2026-07-08T00:00:00Z","updated_at":"2026-07-08T00:00:00Z"},"endpoint":{"schema_version":"rdev.endpoint.v1","id":"ep_1","session_id":"ses_1","role":"target","name":"win-temp","platform":"windows/amd64","identity_fingerprint":"fp_1","state":"online","transport":"long-poll","capabilities":["shell.user"],"lease_expires_at":"2026-07-08T00:01:00Z","last_seen_at":"2026-07-08T00:00:00Z","joined_at":"2026-07-08T00:00:00Z"},"lease":{"schema_version":"rdev.lease.v1","endpoint_id":"ep_1","secret":"lease_1","issued_at":"2026-07-08T00:00:00Z","expires_at":"2026-07-08T00:01:00Z","ttl_ms":60000,"renew_after_ms":20000,"retry_after_ms":1000},"events":[]}`)), + Body: io.NopCloser(strings.NewReader(`{"session":{"schema_version":"rdev.session.v1","id":"ses_1","join_code":"TICKET-1","status":"waiting","created_at":"2026-07-08T00:00:00Z","updated_at":"2026-07-08T00:00:00Z"},"endpoint":{"schema_version":"rdev.endpoint.v1","id":"ep_1","session_id":"ses_1","role":"target","name":"win-temp","platform":"windows/amd64","identity_fingerprint":"fp_1","state":"online","transport":"long-poll","capabilities":["shell.user"],"lease_expires_at":"2026-07-08T00:01:00Z","last_seen_at":"2026-07-08T00:00:00Z","joined_at":"2026-07-08T00:00:00Z"},"lease":{"schema_version":"rdev.lease.v1","session_id":"ses_1","endpoint_id":"ep_1","generation":1,"secret":"lease_1","issued_at":"2026-07-08T00:00:00Z","expires_at":"2026-07-08T00:01:00Z","ttl_ms":60000,"renew_after_ms":20000,"retry_after_ms":1000},"events":[]}`)), Request: req, }, nil }) @@ -400,7 +400,7 @@ func TestCompleteSessionTaskRetriesTransientEOFWithIdempotencyKey(t *testing.T) } } -func TestRunSessionTasksRotatesAfterTunnelRouteIsReclaimed(t *testing.T) { +func TestRunSessionTasksSelectsHealthyRouteAfterConcurrentInitialProbe(t *testing.T) { publicKey, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) @@ -433,7 +433,8 @@ func TestRunSessionTasksRotatesAfterTunnelRouteIsReclaimed(t *testing.T) { } if strings.HasSuffix(req.URL.Path, "/events") { healthyEvents++ - return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"events":[],"lease":{"secret":"lease-test"},"last_seq":0}`)), Request: req}, nil + body := fmt.Sprintf(`{"events":[],"lease":{"session_id":"ses_test","endpoint_id":"end_test","generation":%d,"secret":"lease-test-%d"},"last_seq":0}`, healthyEvents, healthyEvents) + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil } return &http.Response{StatusCode: http.StatusNotFound, Status: "404 Not Found", Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found")), Request: req}, nil })} @@ -449,8 +450,468 @@ func TestRunSessionTasksRotatesAfterTunnelRouteIsReclaimed(t *testing.T) { if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("runSessionTasks error = %v, want context deadline after continued polling", err) } - if processed != 0 || deadEvents == 0 || healthyEvents == 0 { - t.Fatalf("expected route rotation and continued polling, processed=%d dead_events=%d healthy_events=%d", processed, deadEvents, healthyEvents) + if processed != 0 || healthyEvents == 0 { + t.Fatalf("expected healthy route selection and continued polling, processed=%d dead_events=%d healthy_events=%d", processed, deadEvents, healthyEvents) + } +} + +func TestRunSessionTasksSwitchesRouteWithoutReregistering(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + trustBody, err := json.Marshal(map[string]any{"trust": model.NewTrustBundle("route-test", publicKey)}) + if err != nil { + t.Fatal(err) + } + registrationCalls := 0 + primaryEventCalls := 0 + secondaryEventCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + response := func(status int, body string) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + } + if req.URL.Path == "/v1/session-joins" { + registrationCalls++ + return response(http.StatusOK, `{"session":{"schema_version":"rdev.session.v1","id":"ses_route","join_code":"TEST-CODE","status":"active"},"endpoint":{"schema_version":"rdev.endpoint.v1","id":"ep_route","session_id":"ses_route","role":"target","state":"online"},"lease":{"schema_version":"rdev.lease.v1","session_id":"ses_route","endpoint_id":"ep_route","generation":1,"secret":"lease_one"},"events":[]}`) + } + if req.URL.Path == "/v1/trust-bundle" { + return response(http.StatusNotFound, `{"error":"legacy fixture"}`) + } + if req.URL.Path == "/v1/trust" { + return response(http.StatusOK, string(trustBody)) + } + if strings.HasSuffix(req.URL.Path, "/events") { + if req.URL.Query().Get("endpoint_id") != "ep_route" { + t.Fatalf("event endpoint_id = %q, want original endpoint", req.URL.Query().Get("endpoint_id")) + } + switch req.URL.Host { + case "primary.example.test": + primaryEventCalls++ + if primaryEventCalls == 1 { + if req.URL.Query().Get("after_seq") != "0" || req.Header.Get("Authorization") != "Bearer lease_one" { + t.Fatalf("initial event request lost cursor or lease binding: query=%v auth=%q", req.URL.Query(), req.Header.Get("Authorization")) + } + return response(http.StatusOK, `{"events":[],"lease":{"session_id":"ses_route","endpoint_id":"ep_route","generation":2,"secret":"lease_two","retry_after_ms":1},"last_seq":7}`) + } + return nil, io.ErrUnexpectedEOF + case "secondary.example.test": + secondaryEventCalls++ + if req.URL.Path != "/v1/sessions/ses_route/events" || req.URL.Query().Get("after_seq") != "7" || req.Header.Get("Authorization") != "Bearer lease_two" { + t.Fatalf("route switch changed session continuity: path=%q query=%v auth=%q", req.URL.Path, req.URL.Query(), req.Header.Get("Authorization")) + } + return response(http.StatusOK, `{"events":[{"seq":8,"type":"task","task_id":"task_route","payload":{"action":"offer"}}],"lease":{"session_id":"ses_route","endpoint_id":"ep_route","generation":3,"secret":"lease_three"},"last_seq":8}`) + default: + return nil, fmt.Errorf("unexpected event route %q", req.URL.Host) + } + } + if req.Method == http.MethodGet && req.URL.Path == "/v1/sessions/ses_route" { + if req.URL.Host != "secondary.example.test" { + t.Fatalf("task fetch route = %q, want secondary", req.URL.Host) + } + return response(http.StatusOK, `{"snapshot":{"tasks":[{"schema_version":"rdev.task.v1","id":"task_route","session_id":"ses_route","target_endpoint_id":"ep_route","attempt_id":"attempt_route","adapter":"shell","intent":"fixture","capabilities":["shell.user"],"status":"offered"}]}}`) + } + if req.Method == http.MethodPost && req.URL.Path == "/v1/sessions/ses_route/tasks/task_route/result" { + if req.URL.Host != "secondary.example.test" || req.Header.Get("Authorization") != "Bearer lease_three" || req.Header.Get("Idempotency-Key") == "" { + t.Fatalf("task result lost route, lease, or idempotency binding: host=%q auth=%q key=%q", req.URL.Host, req.Header.Get("Authorization"), req.Header.Get("Idempotency-Key")) + } + return response(http.StatusOK, `{"task":{"id":"task_route"},"event":{"seq":9}}`) + } + return nil, fmt.Errorf("unexpected request %s %s", req.Method, req.URL.String()) + })} + + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "long-poll"}, + }, routePoolConfig{ + ReprobeInterval: time.Hour, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + latency := 20 * time.Millisecond + if route.URL == "https://primary.example.test" { + latency = 10 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + selected, err := pool.initialize(context.Background()) + if err != nil { + t.Fatal(err) + } + session, endpoint, lease, _, err := joinSessionByCode(context.Background(), client, selected.URL, "TEST-CODE", controlplane.EndpointSpec{ + Role: controlplane.EndpointRoleTarget, + Transport: controlplane.TransportPoll, + }) + if err != nil { + t.Fatal(err) + } + app := New(io.Discard, io.Discard) + processed, err := app.runSessionTasks(context.Background(), serveOptions{ + GatewayURL: selected.URL, + Transport: selected.Transport, + PollInterval: time.Millisecond, + MaxTasks: 1, + CapabilityCeilingSet: true, + }, client, session.ID, endpoint.ID, "fixture-fingerprint", lease.Secret, lease, newGatewayCandidateSetWithPool(pool)) + if err != nil { + t.Fatal(err) + } + if processed != 1 || registrationCalls != 1 || primaryEventCalls != 2 || secondaryEventCalls != 1 { + t.Fatalf("continuity counts processed=%d registrations=%d primary_events=%d secondary_events=%d", processed, registrationCalls, primaryEventCalls, secondaryEventCalls) + } +} + +func TestRunSessionTasksDoesNotSkipEventsBeyondTransportLimit(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + trustBody, err := json.Marshal(map[string]any{"trust": model.NewTrustBundle("cursor-test", publicKey)}) + if err != nil { + t.Fatal(err) + } + eventCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + response := func(status int, payload any) (*http.Response, error) { + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: status, Status: http.StatusText(status), Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(body)), Request: req}, nil + } + if req.URL.Path == "/v1/trust-bundle" { + return response(http.StatusNotFound, map[string]any{"error": "legacy"}) + } + if req.URL.Path == "/v1/trust" { + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(trustBody)), Request: req}, nil + } + if strings.HasSuffix(req.URL.Path, "/events") { + eventCalls++ + if req.URL.Query().Get("limit") != "16" || req.URL.Query().Get("wait_ms") != "25000" { + t.Fatalf("long-poll adapter query = %v, want limit=16 wait_ms=25000", req.URL.Query()) + } + if eventCalls == 1 { + if got := req.URL.Query().Get("after_seq"); got != "0" { + t.Fatalf("first after_seq = %q, want 0", got) + } + events := make([]controlplane.Event, 0, 16) + for seq := uint64(1); seq <= 16; seq++ { + events = append(events, controlplane.Event{Seq: seq, Type: controlplane.EventType("status")}) + } + return response(http.StatusOK, map[string]any{ + "events": events, + "lease": controlplane.Lease{SessionID: "ses_cursor", EndpointID: "ep_cursor", Generation: 1, Secret: "lease_one"}, + "last_seq": 17, + }) + } + if got := req.URL.Query().Get("after_seq"); got != "16" { + t.Fatalf("second after_seq = %q, want committed limited-batch cursor 16", got) + } + return response(http.StatusOK, map[string]any{ + "events": []controlplane.Event{{Seq: 17, Type: controlplane.EventTypeTask, TaskID: "task_cursor", Payload: map[string]any{"action": "offer"}}}, + "lease": controlplane.Lease{SessionID: "ses_cursor", EndpointID: "ep_cursor", Generation: 2, Secret: "lease_two"}, + "last_seq": 17, + }) + } + if req.Method == http.MethodGet && req.URL.Path == "/v1/sessions/ses_cursor" { + return response(http.StatusOK, map[string]any{"snapshot": controlplane.SessionSnapshot{Tasks: []controlplane.Task{{ID: "task_cursor", SessionID: "ses_cursor", TargetEndpointID: "ep_cursor", AttemptID: "attempt_cursor", Capabilities: []string{"shell.user"}}}}}) + } + if req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/tasks/task_cursor/result") { + return response(http.StatusOK, map[string]any{"task": controlplane.Task{ID: "task_cursor"}, "event": controlplane.Event{Seq: 18}}) + } + return nil, fmt.Errorf("unexpected request %s %s", req.Method, req.URL.String()) + })} + pool := newRoutePool([]routeCandidate{{URL: "https://cursor.example.test", Transport: "long-poll"}}, routePoolConfig{ + ReprobeInterval: time.Hour, + Probe: func(context.Context, routeCandidate) routeProbeResult { + return routeProbeResult{Healthy: true, Latency: time.Millisecond} + }, + }) + selected, err := pool.initialize(context.Background()) + if err != nil { + t.Fatal(err) + } + lease := controlplane.Lease{SessionID: "ses_cursor", EndpointID: "ep_cursor", Secret: "lease_zero"} + processed, err := New(io.Discard, io.Discard).runSessionTasks(context.Background(), serveOptions{ + GatewayURL: selected.URL, + Transport: selected.Transport, + PollInterval: time.Millisecond, + MaxTasks: 1, + CapabilityCeilingSet: true, + }, client, "ses_cursor", "ep_cursor", "fixture-fingerprint", lease.Secret, lease, newGatewayCandidateSetWithPool(pool)) + if err != nil { + t.Fatal(err) + } + if processed != 1 || eventCalls != 2 { + t.Fatalf("processed=%d event_calls=%d, want 1 and 2", processed, eventCalls) + } +} + +func TestRunSessionTasksRequeuesInitialEventAcrossTaskFetchFailover(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + trustBody, err := json.Marshal(map[string]any{"trust": model.NewTrustBundle("initial-event-test", publicKey)}) + if err != nil { + t.Fatal(err) + } + var taskFetchHosts []string + resultCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + response := func(status int, body string) (*http.Response, error) { + return &http.Response{StatusCode: status, Status: http.StatusText(status), Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + } + if req.URL.Path == "/v1/trust-bundle" { + return response(http.StatusNotFound, `{"error":"legacy"}`) + } + if req.URL.Path == "/v1/trust" { + return response(http.StatusOK, string(trustBody)) + } + if req.Method == http.MethodGet && req.URL.Path == "/v1/sessions/ses_initial" { + taskFetchHosts = append(taskFetchHosts, req.URL.Host) + if req.URL.Host == "primary.example.test" { + return nil, io.ErrUnexpectedEOF + } + return response(http.StatusOK, `{"snapshot":{"tasks":[{"id":"task_initial","session_id":"ses_initial","target_endpoint_id":"ep_initial","attempt_id":"attempt_initial","capabilities":["shell.user"]}]}}`) + } + if req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/tasks/task_initial/result") { + resultCalls++ + if req.URL.Host != "secondary.example.test" { + t.Fatalf("result host = %q, want secondary", req.URL.Host) + } + return response(http.StatusOK, `{"task":{"id":"task_initial"},"event":{"seq":2}}`) + } + if strings.HasSuffix(req.URL.Path, "/events") { + t.Fatal("initial event was dropped and fetched again from the gateway") + } + return nil, fmt.Errorf("unexpected request %s %s", req.Method, req.URL.String()) + })} + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "poll"}, + }, routePoolConfig{ + ReprobeInterval: time.Hour, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + latency := 20 * time.Millisecond + if route.URL == "https://primary.example.test" { + latency = 10 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + selected, err := pool.initialize(context.Background()) + if err != nil { + t.Fatal(err) + } + lease := controlplane.Lease{SessionID: "ses_initial", EndpointID: "ep_initial", Generation: 1, Secret: newIdempotencyKey("fixture-lease")} + processed, err := New(io.Discard, io.Discard).runSessionTasksWithEvents(context.Background(), serveOptions{ + GatewayURL: selected.URL, + Transport: selected.Transport, + PollInterval: time.Millisecond, + MaxTasks: 1, + CapabilityCeilingSet: true, + }, client, "ses_initial", "ep_initial", "fixture-fingerprint", lease.Secret, lease, newGatewayCandidateSetWithPool(pool), []controlplane.Event{{Seq: 1, Type: controlplane.EventTypeTask, TaskID: "task_initial", Payload: map[string]any{"action": "offer"}}}) + if err != nil { + t.Fatal(err) + } + if processed != 1 || strings.Join(taskFetchHosts, ",") != "primary.example.test,secondary.example.test" || resultCalls != 1 { + t.Fatalf("processed=%d task_fetch_hosts=%v result_calls=%d", processed, taskFetchHosts, resultCalls) + } +} + +func TestRunSessionTasksRejectsNetworkEventsWithoutRenewedLease(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + trustBody, err := json.Marshal(map[string]any{"trust": model.NewTrustBundle("missing-lease-test", publicKey)}) + if err != nil { + t.Fatal(err) + } + taskFetches := 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := "" + status := http.StatusOK + switch { + case req.URL.Path == "/v1/trust-bundle": + status, body = http.StatusNotFound, `{"error":"legacy"}` + case req.URL.Path == "/v1/trust": + body = string(trustBody) + case strings.HasSuffix(req.URL.Path, "/events"): + body = `{"events":[{"seq":1,"type":"task","task_id":"must_not_run","payload":{"action":"offer"}}],"last_seq":1}` + case req.URL.Path == "/v1/sessions/ses_missing_lease": + taskFetches++ + body = `{"snapshot":{}}` + default: + return nil, fmt.Errorf("unexpected request %s", req.URL.String()) + } + return &http.Response{StatusCode: status, Status: http.StatusText(status), Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })} + pool := newRoutePool([]routeCandidate{{URL: "https://missing-lease.example.test", Transport: "poll"}}, routePoolConfig{ + Probe: func(context.Context, routeCandidate) routeProbeResult { + return routeProbeResult{Healthy: true, Latency: time.Millisecond} + }, + }) + selected, err := pool.initialize(context.Background()) + if err != nil { + t.Fatal(err) + } + lease := controlplane.Lease{SessionID: "ses_missing_lease", EndpointID: "ep_missing_lease", Generation: 1, Secret: newIdempotencyKey("fixture-lease")} + _, err = New(io.Discard, io.Discard).runSessionTasks(context.Background(), serveOptions{ + GatewayURL: selected.URL, + Transport: selected.Transport, + MaxTasks: 1, + PollInterval: time.Millisecond, + }, client, lease.SessionID, lease.EndpointID, "fixture-fingerprint", lease.Secret, lease, newGatewayCandidateSetWithPool(pool)) + if err == nil { + t.Fatal("events response without renewed lease was accepted") + } + if taskFetches != 0 { + t.Fatalf("task fetched %d times from an unbound events response", taskFetches) + } +} + +func TestJoinManifestContainsGatewayRequiresExactVerifiedCandidate(t *testing.T) { + manifest := model.JoinManifest{ + GatewayURL: "https://primary.example.test/base", + GatewayCandidates: []model.JoinManifestGatewayCandidate{ + {URL: "https://secondary.example.test/relay"}, + }, + } + for _, test := range []struct { + name string + value string + want bool + }{ + {name: "primary", value: "https://primary.example.test/base/", want: true}, + {name: "signed candidate", value: "https://secondary.example.test/relay", want: true}, + {name: "unlisted host", value: "https://other.example.test/base", want: false}, + {name: "query injection", value: "https://secondary.example.test/relay?x=1", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := joinManifestContainsGateway(manifest, test.value); got != test.want { + t.Fatalf("joinManifestContainsGateway(%q) = %v, want %v", test.value, got, test.want) + } + }) + } +} + +func TestValidateGatewayCandidateSetRejectsUnrootedPublicCandidate(t *testing.T) { + routes := newGatewayCandidateSet("http://127.0.0.1:8787", []model.JoinManifestGatewayCandidate{ + {URL: "https://public.example.test"}, + }, "poll") + if err := validateGatewayCandidateSet(routes, false); err == nil { + t.Fatal("unrooted public manifest candidate was accepted") + } + if err := validateGatewayCandidateSet(routes, true); err != nil { + t.Fatalf("root-verified candidate set was rejected: %v", err) + } +} + +func TestRunServeRejectsPublicHTTPManifestURLBeforeFetch(t *testing.T) { + err := New(io.Discard, io.Discard).runServe(context.Background(), serveOptions{ + Mode: "temporary", + ManifestURL: "http://192.0.2.10/v1/tickets/fixture/manifest", + Transport: "poll", + }) + if err == nil || !strings.Contains(err.Error(), "HTTPS") { + t.Fatalf("public HTTP manifest error = %v", err) + } +} + +func TestDoGatewayRequestRejectsRedirect(t *testing.T) { + targetCalls := 0 + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + targetCalls++ + })) + defer target.Close() + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(http.StatusTemporaryRedirect) + })) + defer redirect.Close() + req, err := http.NewRequest(http.MethodPost, redirect.URL, strings.NewReader(`{"join_code":"fixture"}`)) + if err != nil { + t.Fatal(err) + } + resp, err := doGatewayRequest(redirect.Client(), req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusTemporaryRedirect || targetCalls != 0 { + t.Fatalf("redirect status=%d target_calls=%d, want rejected redirect", resp.StatusCode, targetCalls) + } +} + +func TestJoinSessionNetworkErrorRedactsGatewayURL(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("dial https://private.example.test/hidden: connection failed") + })} + _, _, _, _, err := joinSessionByCode(context.Background(), client, "https://private.example.test/hidden", "TEST-CODE", controlplane.EndpointSpec{Role: controlplane.EndpointRoleTarget}) + if err == nil { + t.Fatal("expected registration network failure") + } + if message := err.Error(); strings.Contains(message, "private.example.test") || strings.Contains(message, "/hidden") || strings.Contains(message, "TEST-CODE") { + t.Fatalf("registration error leaked private connection data: %q", message) + } +} + +func TestDecodeBoundedGatewayJSONRejectsOversize(t *testing.T) { + var payload map[string]any + if err := decodeBoundedGatewayJSON(strings.NewReader(`{"ok":true}`), 64, &payload); err != nil { + t.Fatal(err) + } + if err := decodeBoundedGatewayJSON(strings.NewReader(`{"value":"`+strings.Repeat("x", 80)+`"}`), 64, &payload); err == nil { + t.Fatal("oversized gateway JSON was accepted") + } +} + +func TestValidateLeaseBindingRejectsCrossSessionAndStaleGeneration(t *testing.T) { + current := controlplane.Lease{SessionID: "ses", EndpointID: "ep", Generation: 2, Secret: "old"} + if err := validateLeaseBinding(current, controlplane.Lease{SessionID: "ses", EndpointID: "ep", Generation: 3, Secret: "next"}, "ses", "ep"); err != nil { + t.Fatal(err) + } + for _, next := range []controlplane.Lease{ + {SessionID: "other", EndpointID: "ep", Generation: 3, Secret: "next"}, + {SessionID: "ses", EndpointID: "other", Generation: 3, Secret: "next"}, + {SessionID: "ses", EndpointID: "ep", Generation: 2, Secret: "next"}, + } { + if err := validateLeaseBinding(current, next, "ses", "ep"); err == nil { + t.Fatalf("invalid lease was accepted: %#v", next) + } + } +} + +func TestFetchHostTrustDoesNotDowngradeInvalidSignedBundle(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + legacyBody, err := json.Marshal(map[string]any{"trust": model.NewTrustBundle("legacy", publicKey)}) + if err != nil { + t.Fatal(err) + } + legacyCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{"trust_bundle":{}}` + if req.URL.Path == "/v1/trust" { + legacyCalls++ + body = string(legacyBody) + } + return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })} + if _, err := fetchHostTrust(context.Background(), client, "https://gateway.example.test", "", ""); err == nil { + t.Fatal("invalid signed trust response downgraded to legacy trust") + } + if legacyCalls != 0 { + t.Fatalf("legacy trust endpoint called %d times after signed verification failure", legacyCalls) } } diff --git a/internal/hostcmd/route_pool.go b/internal/hostcmd/route_pool.go new file mode 100644 index 0000000..df4cde2 --- /dev/null +++ b/internal/hostcmd/route_pool.go @@ -0,0 +1,786 @@ +package hostcmd + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/model" +) + +var errNoHealthyRoutes = errors.New("no healthy gateway routes") + +const ( + maxGatewayRoutes = 16 + maxRouteProbeWorkers = 8 + maxManifestCandidates = 15 +) + +// routeCandidate is the immutable identity of a gateway and one of the +// event transports maintained by the host helper. +type routeCandidate struct { + URL string + Transport string +} + +func (r routeCandidate) key() string { + return r.URL + "\x00" + r.Transport +} + +type routeProbeResult struct { + Healthy bool + Latency time.Duration + Err error +} + +type routeSnapshot struct { + Candidate routeCandidate + Generation uint64 + changed <-chan struct{} +} + +type routePoolConfig struct { + MaxConcurrent int + ProbeTimeout time.Duration + Cooldown time.Duration + ReprobeInterval time.Duration + ShareGatewayHealth bool + Now func() time.Time + Probe func(context.Context, routeCandidate) routeProbeResult +} + +type routeState struct { + candidate routeCandidate + healthy bool + latency time.Duration + failures int + cooldownUntil time.Time + fastStreak int + epoch uint64 +} + +type routePool struct { + mu sync.Mutex + opMu sync.Mutex + routes []routeState + active int + initialized bool + generation uint64 + lastProbe time.Time + changed chan struct{} + probeSlots chan struct{} + config routePoolConfig +} + +type routeProbeObservation struct { + index int + epoch uint64 + result routeProbeResult +} + +func newRoutePool(candidates []routeCandidate, config routePoolConfig) *routePool { + if config.MaxConcurrent <= 0 { + config.MaxConcurrent = 4 + } + if config.MaxConcurrent > maxRouteProbeWorkers { + config.MaxConcurrent = maxRouteProbeWorkers + } + if config.ProbeTimeout <= 0 { + config.ProbeTimeout = 2 * time.Second + } + if config.Cooldown <= 0 { + config.Cooldown = 10 * time.Second + } + if config.Now == nil { + config.Now = time.Now + } + if config.ReprobeInterval < 0 { + config.ReprobeInterval = 0 + } + capacity := len(candidates) + if capacity > maxGatewayRoutes { + capacity = maxGatewayRoutes + } + seen := make(map[string]struct{}, capacity) + routes := make([]routeState, 0, capacity) + for _, candidate := range candidates { + if len(routes) >= maxGatewayRoutes { + break + } + candidate.URL = strings.TrimRight(strings.TrimSpace(candidate.URL), "/") + candidate.Transport = normalizeRouteTransport(candidate.Transport) + if !safeRouteURL(candidate.URL) || candidate.Transport == "" { + continue + } + if _, ok := seen[candidate.key()]; ok { + continue + } + seen[candidate.key()] = struct{}{} + routes = append(routes, routeState{candidate: candidate}) + } + return &routePool{routes: routes, config: config, active: -1, changed: make(chan struct{}), probeSlots: make(chan struct{}, config.MaxConcurrent)} +} + +func normalizeRouteTransport(value string) string { + switch strings.TrimSpace(strings.ToLower(value)) { + case "long-poll": + return "long-poll" + case "poll", "": + return "poll" + default: + return "" + } +} + +func (p *routePool) initialize(ctx context.Context) (routeCandidate, error) { + if p == nil { + return routeCandidate{}, errNoHealthyRoutes + } + p.opMu.Lock() + defer p.opMu.Unlock() + if err := ctx.Err(); err != nil { + return routeCandidate{}, err + } + p.mu.Lock() + if p.initialized { + if p.active >= 0 && p.active < len(p.routes) { + selected := p.routes[p.active].candidate + p.mu.Unlock() + return selected, nil + } + p.mu.Unlock() + return routeCandidate{}, errNoHealthyRoutes + } + indices := make([]int, len(p.routes)) + for index := range p.routes { + indices[index] = index + } + p.mu.Unlock() + if len(indices) == 0 { + return routeCandidate{}, errNoHealthyRoutes + } + observations := p.probeRoutes(ctx, indices) + if err := ctx.Err(); err != nil { + return routeCandidate{}, err + } + p.mu.Lock() + defer p.mu.Unlock() + for _, observation := range observations { + if observation.index < 0 || observation.index >= len(p.routes) { + continue + } + state := &p.routes[observation.index] + if state.epoch != observation.epoch { + continue + } + state.epoch++ + state.healthy = observation.result.Healthy && observation.result.Err == nil + state.latency = observation.result.Latency + if !state.healthy { + state.failures++ + state.cooldownUntil = p.config.Now().Add(p.config.Cooldown) + } + } + now := p.config.Now() + p.initialized = true + p.lastProbe = now + selected := p.fastestHealthyLocked(now, -1) + if selected < 0 { + return routeCandidate{}, fmt.Errorf("%w: probes did not find a reachable candidate", errNoHealthyRoutes) + } + p.setActiveLocked(selected) + return p.routes[selected].candidate, nil +} + +func (p *routePool) current() (routeCandidate, bool) { + snapshot, ok := p.currentSnapshot() + return snapshot.Candidate, ok +} + +func (p *routePool) currentSnapshot() (routeSnapshot, bool) { + if p == nil { + return routeSnapshot{}, false + } + p.mu.Lock() + defer p.mu.Unlock() + if !p.initialized || p.active < 0 || p.active >= len(p.routes) { + return routeSnapshot{}, false + } + if !p.routes[p.active].healthy || !p.routeEligibleLocked(p.active, p.config.Now()) { + return routeSnapshot{}, false + } + return routeSnapshot{Candidate: p.routes[p.active].candidate, Generation: p.generation, changed: p.changed}, true +} + +func (p *routePool) reportSnapshotSuccess(snapshot routeSnapshot) bool { + if p == nil { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + if snapshot.Generation != p.generation || p.active < 0 || p.active >= len(p.routes) || p.routes[p.active].candidate != snapshot.Candidate { + return false + } + state := &p.routes[p.active] + state.healthy = true + state.failures = 0 + state.cooldownUntil = time.Time{} + state.fastStreak = 0 + return true +} + +func (p *routePool) reportFailure(ctx context.Context, route routeCandidate) (routeCandidate, bool) { + return p.reportSnapshotFailure(ctx, routeSnapshot{Candidate: route}) +} + +func (p *routePool) reportSnapshotFailure(ctx context.Context, snapshot routeSnapshot) (routeCandidate, bool) { + if p == nil { + return routeCandidate{}, false + } + if err := ctx.Err(); err != nil { + return routeCandidate{}, false + } + now := p.config.Now() + p.mu.Lock() + index := p.indexOfLocked(snapshot.Candidate) + if index < 0 { + p.mu.Unlock() + return routeCandidate{}, false + } + if snapshot.Generation != 0 && (snapshot.Generation != p.generation || p.active != index) { + candidate, ok := p.currentLocked() + p.mu.Unlock() + return candidate, ok + } + state := &p.routes[index] + state.healthy = false + state.failures++ + state.fastStreak = 0 + state.epoch++ + state.cooldownUntil = now.Add(p.config.Cooldown) + wasActive := p.initialized && p.active == index + if !wasActive { + p.mu.Unlock() + return p.current() + } + selected := p.fastestHealthyLocked(now, index) + if selected >= 0 { + p.setActiveLocked(selected) + p.resetFastStreaksLocked() + candidate := p.routes[selected].candidate + p.mu.Unlock() + return candidate, true + } + p.setActiveLocked(-1) + replacementGeneration := p.generation + p.resetFastStreaksLocked() + p.mu.Unlock() + + // Last-known health may be stale. A failure path bypasses the normal + // latency probe interval so an active outage is handled immediately. + replacement, ok := p.probeReplacement(ctx, index, replacementGeneration) + return replacement, ok +} + +func (p *routePool) probeReplacement(ctx context.Context, excluded int, expectedGeneration uint64) (routeCandidate, bool) { + p.mu.Lock() + indices := p.eligibleIndicesLocked(p.config.Now(), excluded) + p.mu.Unlock() + if len(indices) == 0 { + return routeCandidate{}, false + } + observations := p.probeRoutes(ctx, indices) + if err := ctx.Err(); err != nil { + return routeCandidate{}, false + } + p.mu.Lock() + defer p.mu.Unlock() + for _, observation := range observations { + if observation.index < 0 || observation.index >= len(p.routes) { + continue + } + state := &p.routes[observation.index] + if state.epoch != observation.epoch { + continue + } + state.healthy = observation.result.Healthy && observation.result.Err == nil + state.latency = observation.result.Latency + state.epoch++ + if state.healthy { + state.failures = 0 + state.cooldownUntil = time.Time{} + } else { + state.failures++ + state.cooldownUntil = p.config.Now().Add(p.config.Cooldown) + } + } + selected := p.fastestHealthyLocked(p.config.Now(), excluded) + if p.generation != expectedGeneration || p.active != -1 { + return p.currentLocked() + } + if selected < 0 { + return routeCandidate{}, false + } + p.setActiveLocked(selected) + return p.routes[selected].candidate, true +} + +func (p *routePool) routeEligible(route routeCandidate, now time.Time) bool { + if p == nil { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + index := p.indexOfLocked(route) + return index >= 0 && p.routeEligibleLocked(index, now) +} + +func (p *routePool) probe(ctx context.Context) (bool, error) { + if p == nil { + return false, errNoHealthyRoutes + } + if err := ctx.Err(); err != nil { + return false, err + } + p.mu.Lock() + initialized := p.initialized + p.mu.Unlock() + if !initialized { + if _, err := p.initialize(ctx); err != nil { + return false, err + } + return true, nil + } + p.opMu.Lock() + defer p.opMu.Unlock() + now := p.config.Now() + p.mu.Lock() + if !p.initialized { + p.mu.Unlock() + return false, errNoHealthyRoutes + } + if p.config.ReprobeInterval > 0 && !p.lastProbe.IsZero() && now.Sub(p.lastProbe) < p.config.ReprobeInterval { + p.mu.Unlock() + return false, nil + } + indices := p.eligibleIndicesLocked(now, -1) + active := p.active + activeGeneration := p.generation + p.lastProbe = now + p.mu.Unlock() + if len(indices) == 0 { + return false, nil + } + observations := p.probeRoutes(ctx, indices) + if err := ctx.Err(); err != nil { + return false, err + } + p.mu.Lock() + defer p.mu.Unlock() + observedHealthy := make(map[int]bool, len(observations)) + for _, observation := range observations { + if observation.index < 0 || observation.index >= len(p.routes) { + continue + } + state := &p.routes[observation.index] + if state.epoch != observation.epoch { + continue + } + state.healthy = observation.result.Healthy && observation.result.Err == nil + state.latency = observation.result.Latency + state.epoch++ + if state.healthy { + state.failures = 0 + state.cooldownUntil = time.Time{} + observedHealthy[observation.index] = true + } else { + state.failures++ + state.fastStreak = 0 + state.cooldownUntil = now.Add(p.config.Cooldown) + } + } + if p.generation != activeGeneration || p.active != active { + return p.reconcileActiveHealthLocked(now), nil + } + if active < 0 || active >= len(p.routes) { + selected := p.fastestHealthyLocked(now, -1) + if selected < 0 { + return false, nil + } + p.setActiveLocked(selected) + return true, nil + } + if !p.routes[active].healthy { + selected := p.fastestHealthyLocked(now, active) + if selected >= 0 { + p.setActiveLocked(selected) + p.resetFastStreaksLocked() + return true, nil + } + p.setActiveLocked(-1) + p.resetFastStreaksLocked() + return false, nil + } + if !observedHealthy[active] { + p.resetFastStreaksLocked() + return false, nil + } + activeLatency := p.routes[active].latency + if activeLatency <= 0 { + return false, nil + } + best := -1 + for index := range p.routes { + if index == active || !observedHealthy[index] || !p.routes[index].healthy || !p.routeEligibleLocked(index, now) { + continue + } + candidateLatency := p.routes[index].latency + if candidateLatency <= 0 || candidateLatency > activeLatency-activeLatency/5 { + continue + } + if best < 0 || candidateLatency < p.routes[best].latency || (candidateLatency == p.routes[best].latency && index < best) { + best = index + } + } + for index := range p.routes { + if index != best { + p.routes[index].fastStreak = 0 + } + } + if best < 0 { + return false, nil + } + p.routes[best].fastStreak++ + if p.routes[best].fastStreak >= 2 { + p.setActiveLocked(best) + p.resetFastStreaksLocked() + return true, nil + } + return false, nil +} + +func (p *routePool) monitor(ctx context.Context, interval time.Duration) error { + if p == nil { + return errNoHealthyRoutes + } + if interval <= 0 { + interval = time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if _, err := p.probe(ctx); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + } + } +} + +func (p *routePool) probeRoutes(ctx context.Context, indices []int) []routeProbeObservation { + if len(indices) == 0 || p.config.Probe == nil { + return nil + } + type probeJob struct { + indices []int + } + jobsToRun := make([]probeJob, 0, len(indices)) + if p.config.ShareGatewayHealth { + byURL := make(map[string]int, len(indices)) + for _, index := range indices { + p.mu.Lock() + if index < 0 || index >= len(p.routes) { + p.mu.Unlock() + continue + } + gatewayURL := p.routes[index].candidate.URL + p.mu.Unlock() + if jobIndex, ok := byURL[gatewayURL]; ok { + jobsToRun[jobIndex].indices = append(jobsToRun[jobIndex].indices, index) + continue + } + byURL[gatewayURL] = len(jobsToRun) + jobsToRun = append(jobsToRun, probeJob{indices: []int{index}}) + } + } else { + for _, index := range indices { + jobsToRun = append(jobsToRun, probeJob{indices: []int{index}}) + } + } + if len(jobsToRun) == 0 { + return nil + } + workerCount := p.config.MaxConcurrent + if workerCount > len(jobsToRun) { + workerCount = len(jobsToRun) + } + jobs := make(chan probeJob) + results := make(chan routeProbeObservation, len(indices)) + var workers sync.WaitGroup + workers.Add(workerCount) + for worker := 0; worker < workerCount; worker++ { + go func() { + defer workers.Done() + for { + select { + case <-ctx.Done(): + return + case job, ok := <-jobs: + if !ok { + return + } + if len(job.indices) == 0 { + continue + } + p.mu.Lock() + representative := job.indices[0] + if representative < 0 || representative >= len(p.routes) { + p.mu.Unlock() + continue + } + candidate := p.routes[representative].candidate + epochs := make(map[int]uint64, len(job.indices)) + for _, index := range job.indices { + if index >= 0 && index < len(p.routes) { + epochs[index] = p.routes[index].epoch + } + } + p.mu.Unlock() + probeCtx := ctx + cancel := func() {} + if p.config.ProbeTimeout > 0 { + probeCtx, cancel = context.WithTimeout(ctx, p.config.ProbeTimeout) + } + select { + case p.probeSlots <- struct{}{}: + case <-ctx.Done(): + cancel() + return + } + startedAt := time.Now() + result := p.config.Probe(probeCtx, candidate) + <-p.probeSlots + cancel() + if result.Latency <= 0 { + result.Latency = time.Since(startedAt) + } + for _, index := range job.indices { + epoch, ok := epochs[index] + if ok { + results <- routeProbeObservation{index: index, epoch: epoch, result: result} + } + } + } + } + }() + } + go func() { + defer close(jobs) + for _, job := range jobsToRun { + select { + case jobs <- job: + case <-ctx.Done(): + return + } + } + }() + go func() { + workers.Wait() + close(results) + }() + observations := make([]routeProbeObservation, 0, len(indices)) + for observation := range results { + observations = append(observations, observation) + } + return observations +} + +func (p *routePool) indexOfLocked(route routeCandidate) int { + for index, state := range p.routes { + if state.candidate == route { + return index + } + } + return -1 +} + +func (p *routePool) currentLocked() (routeCandidate, bool) { + if !p.initialized || p.active < 0 || p.active >= len(p.routes) { + return routeCandidate{}, false + } + if !p.routes[p.active].healthy || !p.routeEligibleLocked(p.active, p.config.Now()) { + return routeCandidate{}, false + } + return p.routes[p.active].candidate, true +} + +func (p *routePool) reconcileActiveHealthLocked(now time.Time) bool { + if p.active < 0 || p.active >= len(p.routes) || (p.routes[p.active].healthy && p.routeEligibleLocked(p.active, now)) { + return false + } + failed := p.active + selected := p.fastestHealthyLocked(now, failed) + if selected >= 0 { + p.setActiveLocked(selected) + } else { + p.setActiveLocked(-1) + } + p.resetFastStreaksLocked() + return true +} + +func (p *routePool) setActiveLocked(index int) { + if p.active == index { + return + } + if p.changed == nil { + p.changed = make(chan struct{}) + } + previous := p.changed + p.active = index + p.generation++ + close(previous) + p.changed = make(chan struct{}) +} + +func routeRequestContext(parent context.Context, snapshot routeSnapshot, timeout time.Duration) (context.Context, func()) { + base, cancelBase := context.WithCancel(parent) + requestCtx := base + cancelTimeout := func() {} + if timeout > 0 { + requestCtx, cancelTimeout = context.WithTimeout(base, timeout) + } + go func() { + select { + case <-snapshot.changed: + cancelBase() + case <-requestCtx.Done(): + } + }() + return requestCtx, func() { + cancelTimeout() + cancelBase() + } +} + +func (p *routePool) routeEligibleLocked(index int, now time.Time) bool { + return index >= 0 && index < len(p.routes) && (p.routes[index].cooldownUntil.IsZero() || !now.Before(p.routes[index].cooldownUntil)) +} + +func (p *routePool) eligibleIndicesLocked(now time.Time, excluded int) []int { + indices := make([]int, 0, len(p.routes)) + for index, state := range p.routes { + if index == excluded || !p.routeEligibleLocked(index, now) { + continue + } + if state.healthy || !state.cooldownUntil.IsZero() { + indices = append(indices, index) + } + } + return indices +} + +func (p *routePool) fastestHealthyLocked(now time.Time, excluded int) int { + best := -1 + for index, state := range p.routes { + if index == excluded || !state.healthy || !p.routeEligibleLocked(index, now) { + continue + } + if best < 0 || state.latency < p.routes[best].latency || (state.latency == p.routes[best].latency && index < best) { + best = index + } + } + return best +} + +func (p *routePool) resetFastStreaksLocked() { + for index := range p.routes { + p.routes[index].fastStreak = 0 + } +} + +// routeCandidatesFromManifest expands only the maintained HTTP event +// adapters. Unsupported transports are intentionally left out. +func routeCandidatesFromManifest(current string, candidates []model.JoinManifestGatewayCandidate, transport string) []routeCandidate { + transports := []string{} + switch strings.TrimSpace(strings.ToLower(transport)) { + case "auto": + transports = []string{"long-poll", "poll"} + case "long-poll": + transports = []string{"long-poll"} + case "poll": + transports = []string{"poll"} + default: + return nil + } + maxURLs := maxGatewayRoutes / len(transports) + capacity := len(candidates) + 1 + if capacity > maxURLs { + capacity = maxURLs + } + urls := make([]string, 0, capacity) + seen := make(map[string]struct{}, capacity) + addURL := func(value string) { + value = strings.TrimRight(strings.TrimSpace(value), "/") + if !safeRouteURL(value) { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + urls = append(urls, value) + } + addURL(current) + for _, candidate := range candidates { + if len(urls) >= maxURLs { + break + } + addURL(candidate.URL) + } + routes := make([]routeCandidate, 0, len(urls)*len(transports)) + for _, gatewayURL := range urls { + for _, routeTransport := range transports { + if len(routes) >= maxGatewayRoutes { + return routes + } + routes = append(routes, routeCandidate{URL: gatewayURL, Transport: routeTransport}) + } + } + return routes +} + +func safeRouteURL(value string) bool { + if value == "" || strings.ContainsAny(value, "\\\x00\r\n") { + return false + } + parsed, err := url.Parse(value) + if err != nil || parsed.Opaque != "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.ForceQuery { + return false + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return false + } + if strings.Contains(parsed.Path, "\\") { + return false + } + for _, segment := range strings.Split(strings.Trim(parsed.Path, "/"), "/") { + if segment == "." || segment == ".." { + return false + } + } + if parsed.Scheme == "http" { + return isLocalDevGatewayURL(value) + } + return true +} diff --git a/internal/hostcmd/route_pool_concurrency_test.go b/internal/hostcmd/route_pool_concurrency_test.go new file mode 100644 index 0000000..53ac213 --- /dev/null +++ b/internal/hostcmd/route_pool_concurrency_test.go @@ -0,0 +1,77 @@ +package hostcmd + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestRoutePoolReconcilesNewActiveRouteAgainstInFlightProbe(t *testing.T) { + var mu sync.Mutex + probePhase := false + started := make(chan struct{}) + release := make(chan struct{}) + var startedOnce sync.Once + pool := newRoutePool([]routeCandidate{ + {URL: "https://a.example.test", Transport: "poll"}, + {URL: "https://b.example.test", Transport: "poll"}, + {URL: "https://c.example.test", Transport: "poll"}, + }, routePoolConfig{ + Probe: func(ctx context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + phase := probePhase + mu.Unlock() + if phase && route.URL == "https://a.example.test" { + startedOnce.Do(func() { close(started) }) + select { + case <-release: + case <-ctx.Done(): + return routeProbeResult{Err: ctx.Err()} + } + } + if phase && route.URL == "https://b.example.test" { + return routeProbeResult{Err: errors.New("b degraded")} + } + latency := 30 * time.Millisecond + if route.URL == "https://a.example.test" { + latency = 10 * time.Millisecond + } else if route.URL == "https://b.example.test" { + latency = 20 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + aSnapshot, _ := pool.currentSnapshot() + mu.Lock() + probePhase = true + mu.Unlock() + probeDone := make(chan error, 1) + go func() { + _, err := pool.probe(context.Background()) + probeDone <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("in-flight probe did not start") + } + if got, ok := pool.reportSnapshotFailure(context.Background(), aSnapshot); !ok || got.URL != "https://b.example.test" { + t.Fatalf("request failure selected %#v ok=%v, want b", got, ok) + } + close(release) + if err := <-probeDone; err != nil { + t.Fatal(err) + } + if got, ok := pool.current(); !ok || got.URL != "https://c.example.test" { + t.Fatalf("post-probe active route = %#v ok=%v, want healthy c", got, ok) + } + b := routeCandidate{URL: "https://b.example.test", Transport: "poll"} + if pool.routeEligible(b, time.Now()) { + t.Fatal("failed b route remained eligible") + } +} diff --git a/internal/hostcmd/route_pool_test.go b/internal/hostcmd/route_pool_test.go new file mode 100644 index 0000000..b7ae3bc --- /dev/null +++ b/internal/hostcmd/route_pool_test.go @@ -0,0 +1,751 @@ +package hostcmd + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/model" +) + +type routePoolFakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *routePoolFakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *routePoolFakeClock) Advance(delta time.Duration) { + c.mu.Lock() + c.now = c.now.Add(delta) + c.mu.Unlock() +} + +func TestRoutePoolInitializesWithFastestHealthyRouteAndBoundedProbes(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(100, 0)} + var mu sync.Mutex + active, maxActive := 0, 0 + started := make(chan struct{}, 3) + release := make(chan struct{}) + pool := newRoutePool([]routeCandidate{ + {URL: "https://slow.example.test", Transport: "poll"}, + {URL: "https://fast.example.test", Transport: "long-poll"}, + {URL: "https://down.example.test", Transport: "poll"}, + }, routePoolConfig{ + MaxConcurrent: 2, + ProbeTimeout: time.Second, + Cooldown: time.Minute, + Now: clock.Now, + Probe: func(ctx context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + active++ + if active > maxActive { + maxActive = active + } + mu.Unlock() + started <- struct{}{} + select { + case <-release: + case <-ctx.Done(): + mu.Lock() + active-- + mu.Unlock() + return routeProbeResult{Err: ctx.Err()} + } + mu.Lock() + active-- + mu.Unlock() + switch route.URL { + case "https://slow.example.test": + return routeProbeResult{Healthy: true, Latency: 80 * time.Millisecond} + case "https://fast.example.test": + return routeProbeResult{Healthy: true, Latency: 20 * time.Millisecond} + default: + return routeProbeResult{Err: errors.New("unreachable")} + } + }, + }) + initialized := make(chan struct{}) + var selected routeCandidate + var initErr error + go func() { + selected, initErr = pool.initialize(context.Background()) + close(initialized) + }() + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for bounded probes") + } + } + select { + case <-started: + t.Fatal("started more than the configured probe bound") + default: + } + close(release) + select { + case <-initialized: + case <-time.After(time.Second): + t.Fatal("timed out waiting for route pool initialization") + } + if initErr != nil { + t.Fatal(initErr) + } + if selected.URL != "https://fast.example.test" || selected.Transport != "long-poll" { + t.Fatalf("selected route = %#v, want fastest healthy route", selected) + } + mu.Lock() + gotMax := maxActive + mu.Unlock() + if gotMax > 2 { + t.Fatalf("maximum concurrent probes = %d, want <= 2", gotMax) + } +} + +func TestRoutePoolFailureSwitchesImmediatelyAndCoolsFailedRoute(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(200, 0)} + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "long-poll"}, + }, routePoolConfig{ + Cooldown: time.Minute, + Now: clock.Now, + Probe: func(context.Context, routeCandidate) routeProbeResult { + return routeProbeResult{Healthy: true, Latency: 10 * time.Millisecond} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + primary, ok := pool.current() + if !ok { + t.Fatal("pool has no current route") + } + if _, ok := pool.reportFailure(context.Background(), primary); !ok { + t.Fatal("reportFailure did not select a healthy replacement") + } + current, _ := pool.current() + if current.URL == primary.URL { + t.Fatalf("failure left the primary route active: %#v", current) + } + if pool.routeEligible(primary, clock.Now()) { + t.Fatal("failed route re-entered before cooldown") + } + clock.Advance(time.Minute) + if !pool.routeEligible(primary, clock.Now()) { + t.Fatal("failed route did not become eligible after cooldown") + } +} + +func TestRoutePoolLatencySwitchRequiresTwoConsecutiveTwentyPercentWins(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(300, 0)} + var mu sync.Mutex + probeCount := map[string]int{} + pool := newRoutePool([]routeCandidate{ + {URL: "https://active.example.test", Transport: "poll"}, + {URL: "https://candidate.example.test", Transport: "poll"}, + }, routePoolConfig{ + ReprobeInterval: time.Millisecond, + Now: clock.Now, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + probeCount[route.URL]++ + count := probeCount[route.URL] + mu.Unlock() + if route.URL == "https://active.example.test" { + return routeProbeResult{Healthy: true, Latency: 100 * time.Millisecond} + } + if count == 1 { + return routeProbeResult{Healthy: true, Latency: 100 * time.Millisecond} + } + return routeProbeResult{Healthy: true, Latency: 70 * time.Millisecond} + }, + }) + // Stable order makes the active route deterministic when the first sweep ties. + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + active, _ := pool.current() + if active.URL != "https://active.example.test" { + t.Fatalf("initial route = %#v, want active fixture", active) + } + clock.Advance(time.Millisecond) + if switched, err := pool.probe(context.Background()); err != nil || switched { + t.Fatalf("first latency probe switched=%v err=%v, want no switch", switched, err) + } + if got, _ := pool.current(); got.URL != active.URL { + t.Fatalf("route switched after one fast probe: %#v", got) + } + clock.Advance(time.Millisecond) + if switched, err := pool.probe(context.Background()); err != nil || !switched { + t.Fatalf("second latency probe switched=%v err=%v, want switch", switched, err) + } + if got, _ := pool.current(); got.URL != "https://candidate.example.test" { + t.Fatalf("route after hysteresis = %#v, want candidate", got) + } +} + +func TestRoutePoolInitializationHonorsCancellation(t *testing.T) { + started := make(chan struct{}) + pool := newRoutePool([]routeCandidate{{URL: "https://blocked.example.test", Transport: "poll"}}, routePoolConfig{ + ProbeTimeout: time.Second, + Probe: func(ctx context.Context, _ routeCandidate) routeProbeResult { + close(started) + <-ctx.Done() + return routeProbeResult{Err: ctx.Err()} + }, + }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := pool.initialize(ctx) + done <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("probe did not start") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("initialize error = %v, want context cancellation", err) + } + case <-time.After(time.Second): + t.Fatal("initialize did not honor cancellation") + } +} + +func TestRoutePoolReentersAfterCooldownOnlyAfterSuccessfulRecoveryProbe(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(400, 0)} + var mu sync.Mutex + primaryHealthy := true + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://backup.example.test", Transport: "poll"}, + }, routePoolConfig{ + Cooldown: time.Second, + Now: clock.Now, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + healthy := primaryHealthy || route.URL != "https://primary.example.test" + mu.Unlock() + if !healthy { + return routeProbeResult{Err: errors.New("primary unavailable")} + } + return routeProbeResult{Healthy: true, Latency: 10 * time.Millisecond} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + primary, _ := pool.current() + mu.Lock() + primaryHealthy = false + mu.Unlock() + if _, ok := pool.reportFailure(context.Background(), primary); !ok { + t.Fatal("expected backup route after primary failure") + } + clock.Advance(time.Second) + if switched, err := pool.probe(context.Background()); err != nil || switched { + t.Fatalf("probe with failed primary switched=%v err=%v, want no switch", switched, err) + } + if got, _ := pool.current(); got.URL == primary.URL { + t.Fatalf("failed primary was selected without a successful recovery probe: %#v", got) + } + mu.Lock() + primaryHealthy = true + mu.Unlock() + clock.Advance(time.Second) + if _, err := pool.probe(context.Background()); err != nil { + t.Fatal(err) + } + backup, _ := pool.current() + if got, ok := pool.reportFailure(context.Background(), backup); !ok || got.URL != primary.URL { + t.Fatalf("recovered primary was not selectable after a successful probe: %#v ok=%v", got, ok) + } +} + +func TestRoutePoolLatencyWinnerMustStayFastToAvoidFlapping(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(500, 0)} + var mu sync.Mutex + sweep := 0 + pool := newRoutePool([]routeCandidate{ + {URL: "https://a.example.test", Transport: "poll"}, + {URL: "https://b.example.test", Transport: "poll"}, + }, routePoolConfig{ + ReprobeInterval: time.Millisecond, + Now: clock.Now, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + currentSweep := sweep + mu.Unlock() + if route.URL == "https://a.example.test" { + return routeProbeResult{Healthy: true, Latency: 100 * time.Millisecond} + } + if currentSweep == 0 { + return routeProbeResult{Healthy: true, Latency: 100 * time.Millisecond} + } + if currentSweep == 1 { + return routeProbeResult{Healthy: true, Latency: 70 * time.Millisecond} + } + if currentSweep == 2 { + return routeProbeResult{Healthy: true, Latency: 90 * time.Millisecond} + } + return routeProbeResult{Healthy: true, Latency: 70 * time.Millisecond} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + for sweepNumber := 1; sweepNumber <= 3; sweepNumber++ { + mu.Lock() + sweep = sweepNumber + mu.Unlock() + clock.Advance(time.Millisecond) + switched, err := pool.probe(context.Background()) + if err != nil { + t.Fatal(err) + } + if sweepNumber == 2 && switched { + t.Fatal("latency miss should reset the hysteresis streak") + } + } + if got, _ := pool.current(); got.URL != "https://a.example.test" { + t.Fatalf("route flapped after an alternating latency result: %#v", got) + } +} + +func TestRoutePoolLatencyWinnerChangeRestartsHysteresis(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(550, 0)} + var mu sync.Mutex + sweep := 0 + pool := newRoutePool([]routeCandidate{ + {URL: "https://active.example.test", Transport: "poll"}, + {URL: "https://b.example.test", Transport: "poll"}, + {URL: "https://c.example.test", Transport: "poll"}, + }, routePoolConfig{ + ReprobeInterval: time.Millisecond, + Now: clock.Now, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + currentSweep := sweep + mu.Unlock() + latency := 100 * time.Millisecond + if currentSweep == 1 && route.URL == "https://b.example.test" { + latency = 70 * time.Millisecond + } + if currentSweep == 1 && route.URL == "https://c.example.test" { + latency = 75 * time.Millisecond + } + if currentSweep >= 2 && route.URL == "https://b.example.test" { + latency = 75 * time.Millisecond + } + if currentSweep >= 2 && route.URL == "https://c.example.test" { + latency = 70 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + for sweepNumber := 1; sweepNumber <= 2; sweepNumber++ { + mu.Lock() + sweep = sweepNumber + mu.Unlock() + clock.Advance(time.Millisecond) + if switched, err := pool.probe(context.Background()); err != nil || switched { + t.Fatalf("winner-changing sweep %d switched=%v err=%v", sweepNumber, switched, err) + } + } + if got, _ := pool.current(); got.URL != "https://active.example.test" { + t.Fatalf("winner change reused another route's streak: %#v", got) + } + mu.Lock() + sweep = 3 + mu.Unlock() + clock.Advance(time.Millisecond) + if switched, err := pool.probe(context.Background()); err != nil || !switched { + t.Fatalf("second consecutive c win switched=%v err=%v, want switch", switched, err) + } + if got, _ := pool.current(); got.URL != "https://c.example.test" { + t.Fatalf("route after stable winner = %#v, want c", got) + } +} + +func TestRoutePoolIgnoresStaleSuccessAfterFailureSwitch(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(575, 0)} + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "poll"}, + }, routePoolConfig{ + Cooldown: time.Minute, + Now: clock.Now, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + latency := 20 * time.Millisecond + if route.URL == "https://primary.example.test" { + latency = 10 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + stale, ok := pool.currentSnapshot() + if !ok { + t.Fatal("missing active route snapshot") + } + if _, ok := pool.reportSnapshotFailure(context.Background(), stale); !ok { + t.Fatal("failed active route did not switch") + } + pool.reportSnapshotSuccess(stale) + if got, _ := pool.current(); got.URL != "https://secondary.example.test" { + t.Fatalf("stale success changed active route: %#v", got) + } + if pool.routeEligible(stale.Candidate, clock.Now()) { + t.Fatal("stale success cleared the failed route cooldown") + } +} + +func TestRoutePoolIgnoresProbeResultStartedBeforeActiveFailure(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(590, 0)} + var mu sync.Mutex + blockProbes := false + started := make(chan struct{}) + release := make(chan struct{}) + var startedOnce sync.Once + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "poll"}, + }, routePoolConfig{ + Now: clock.Now, + Probe: func(ctx context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + blocked := blockProbes && route.URL == "https://primary.example.test" + mu.Unlock() + if blocked { + startedOnce.Do(func() { close(started) }) + select { + case <-release: + case <-ctx.Done(): + return routeProbeResult{Err: ctx.Err()} + } + } + latency := 20 * time.Millisecond + if route.URL == "https://primary.example.test" { + latency = 10 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + stale, ok := pool.currentSnapshot() + if !ok { + t.Fatal("missing primary snapshot") + } + mu.Lock() + blockProbes = true + mu.Unlock() + probeDone := make(chan error, 1) + go func() { + _, err := pool.probe(context.Background()) + probeDone <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("stale probe did not start") + } + if got, ok := pool.reportSnapshotFailure(context.Background(), stale); !ok || got.URL != "https://secondary.example.test" { + t.Fatalf("active failure did not switch immediately: route=%#v ok=%v", got, ok) + } + close(release) + select { + case err := <-probeDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("stale probe did not finish") + } + if got, _ := pool.current(); got.URL != "https://secondary.example.test" { + t.Fatalf("stale probe result resurrected primary: %#v", got) + } + if pool.routeEligible(stale.Candidate, clock.Now()) { + t.Fatal("stale probe result cleared primary cooldown") + } +} + +func TestRoutePoolRecoversExpiredAlternativeDuringImmediateFailover(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(595, 0)} + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "poll"}, + }, routePoolConfig{ + Cooldown: time.Second, + Now: clock.Now, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + latency := 20 * time.Millisecond + if route.URL == "https://primary.example.test" { + latency = 10 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + secondary := routeCandidate{URL: "https://secondary.example.test", Transport: "poll"} + if current, ok := pool.reportFailure(context.Background(), secondary); !ok || current.URL != "https://primary.example.test" { + t.Fatalf("non-active failure changed current route: %#v ok=%v", current, ok) + } + clock.Advance(time.Second) + primary, _ := pool.current() + if replacement, ok := pool.reportFailure(context.Background(), primary); !ok || replacement != secondary { + t.Fatalf("expired alternative recovery = %#v ok=%v, want secondary", replacement, ok) + } +} + +func TestRoutePoolTrustProbeFailureWinsOverConcurrentRequestSuccess(t *testing.T) { + var mu sync.Mutex + failPrimary := false + started := make(chan struct{}) + release := make(chan struct{}) + var startedOnce sync.Once + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "poll"}, + }, routePoolConfig{ + Probe: func(ctx context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + fail := failPrimary && route.URL == "https://primary.example.test" + mu.Unlock() + if fail { + startedOnce.Do(func() { close(started) }) + select { + case <-release: + case <-ctx.Done(): + return routeProbeResult{Err: ctx.Err()} + } + return routeProbeResult{Err: errors.New("trust verification failed")} + } + latency := 20 * time.Millisecond + if route.URL == "https://primary.example.test" { + latency = 10 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + snapshot, _ := pool.currentSnapshot() + mu.Lock() + failPrimary = true + mu.Unlock() + probeDone := make(chan error, 1) + go func() { + _, err := pool.probe(context.Background()) + probeDone <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("trust probe did not start") + } + if !pool.reportSnapshotSuccess(snapshot) { + t.Fatal("current request success was unexpectedly stale") + } + close(release) + if err := <-probeDone; err != nil { + t.Fatal(err) + } + if got, _ := pool.current(); got.URL != "https://secondary.example.test" { + t.Fatalf("request success masked trust probe failure: %#v", got) + } +} + +func TestRoutePoolMonitorSwitchCancelsActiveRouteRequest(t *testing.T) { + var mu sync.Mutex + failPrimary := false + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "poll"}, + }, routePoolConfig{ + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + fail := failPrimary && route.URL == "https://primary.example.test" + mu.Unlock() + if fail { + return routeProbeResult{Err: errors.New("route degraded")} + } + latency := 20 * time.Millisecond + if route.URL == "https://primary.example.test" { + latency = 10 * time.Millisecond + } + return routeProbeResult{Healthy: true, Latency: latency} + }, + }) + if _, err := pool.initialize(context.Background()); err != nil { + t.Fatal(err) + } + snapshot, _ := pool.currentSnapshot() + requestCtx, cancelRequest := routeRequestContext(context.Background(), snapshot, time.Second) + defer cancelRequest() + mu.Lock() + failPrimary = true + mu.Unlock() + monitorCtx, cancelMonitor := context.WithCancel(context.Background()) + monitorDone := make(chan error, 1) + go func() { monitorDone <- pool.monitor(monitorCtx, time.Millisecond) }() + select { + case <-requestCtx.Done(): + case <-time.After(time.Second): + cancelMonitor() + t.Fatal("route switch did not cancel the active request") + } + if got, _ := pool.current(); got.URL != "https://secondary.example.test" { + cancelMonitor() + t.Fatalf("monitor selected %#v, want secondary", got) + } + cancelMonitor() + if err := <-monitorDone; !errors.Is(err, context.Canceled) { + t.Fatalf("monitor exit = %v, want cancellation", err) + } +} + +func TestRoutePoolCapsResourcesAndRecoversAfterAllInitialProbesFail(t *testing.T) { + clock := &routePoolFakeClock{now: time.Unix(600, 0)} + candidates := make([]routeCandidate, 0, 20) + for index := 0; index < 20; index++ { + candidates = append(candidates, routeCandidate{URL: "https://route-" + fmt.Sprint(index) + ".example.test", Transport: "poll"}) + } + var mu sync.Mutex + recovered := false + pool := newRoutePool(candidates, routePoolConfig{ + MaxConcurrent: 100, + Cooldown: time.Second, + Now: clock.Now, + Probe: func(_ context.Context, route routeCandidate) routeProbeResult { + mu.Lock() + healthy := recovered && route == candidates[0] + mu.Unlock() + if healthy { + return routeProbeResult{Healthy: true, Latency: time.Millisecond} + } + return routeProbeResult{Err: errors.New("down")} + }, + }) + if len(pool.routes) != maxGatewayRoutes || cap(pool.probeSlots) != maxRouteProbeWorkers { + t.Fatalf("pool resources routes=%d probe_slots=%d", len(pool.routes), cap(pool.probeSlots)) + } + if _, err := pool.initialize(context.Background()); !errors.Is(err, errNoHealthyRoutes) { + t.Fatalf("initialization error = %v, want no healthy routes", err) + } + if _, ok := pool.current(); ok { + t.Fatal("pool exposed a failed initial route") + } + if _, err := pool.initialize(context.Background()); !errors.Is(err, errNoHealthyRoutes) { + t.Fatalf("repeat initialization bypassed cooldown: %v", err) + } + mu.Lock() + recovered = true + mu.Unlock() + clock.Advance(time.Second) + if switched, err := pool.probe(context.Background()); err != nil || !switched { + t.Fatalf("recovery probe switched=%v err=%v", switched, err) + } + if got, _ := pool.current(); got != candidates[0] { + t.Fatalf("recovered route = %#v, want first candidate", got) + } + var nilPool *routePool + if err := nilPool.monitor(context.Background(), time.Millisecond); !errors.Is(err, errNoHealthyRoutes) { + t.Fatalf("nil monitor error = %v", err) + } +} + +func TestRoutePoolCurrentLockedAndNoReplacementFailClosed(t *testing.T) { + pool := newRoutePool([]routeCandidate{ + {URL: "https://primary.example.test", Transport: "poll"}, + {URL: "https://secondary.example.test", Transport: "poll"}, + }, routePoolConfig{ + Cooldown: time.Minute, + Probe: func(context.Context, routeCandidate) routeProbeResult { + return routeProbeResult{Healthy: true, Latency: time.Millisecond} + }, + }) + pool.mu.Lock() + if _, ok := pool.currentLocked(); ok { + t.Fatal("uninitialized pool exposed a current route") + } + pool.initialized = true + pool.active = 0 + pool.routes[0].healthy = true + if got, ok := pool.currentLocked(); !ok || got.URL != "https://primary.example.test" { + t.Fatalf("currentLocked() = %#v ok=%v", got, ok) + } + pool.mu.Unlock() + primary, _ := pool.current() + secondary := routeCandidate{URL: "https://secondary.example.test", Transport: "poll"} + pool.reportFailure(context.Background(), secondary) + if _, ok := pool.reportFailure(context.Background(), primary); ok { + t.Fatal("all routes in cooldown produced a replacement") + } + if _, ok := pool.current(); ok { + t.Fatal("pool exposed a cooled route after replacement failure") + } +} + +func TestRoutePoolSharesGatewayProbeAcrossMaintainedAdapters(t *testing.T) { + probeCalls := 0 + pool := newRoutePool([]routeCandidate{ + {URL: "https://gateway.example.test", Transport: "long-poll"}, + {URL: "https://gateway.example.test", Transport: "poll"}, + }, routePoolConfig{ + ShareGatewayHealth: true, + Probe: func(context.Context, routeCandidate) routeProbeResult { + probeCalls++ + return routeProbeResult{Healthy: true, Latency: 10 * time.Millisecond} + }, + }) + selected, err := pool.initialize(context.Background()) + if err != nil { + t.Fatal(err) + } + if probeCalls != 1 || selected.Transport != "long-poll" { + t.Fatalf("probe_calls=%d selected=%#v, want one gateway probe and deterministic long-poll preference", probeCalls, selected) + } + if replacement, ok := pool.reportFailure(context.Background(), selected); !ok || replacement.Transport != "poll" { + t.Fatalf("transport fallback = %#v ok=%v, want poll adapter", replacement, ok) + } +} + +func TestRouteCandidatesFromManifestRejectsUnsafeURLsAndUnsupportedTransports(t *testing.T) { + routes := routeCandidatesFromManifest("https://primary.example.test/base", []model.JoinManifestGatewayCandidate{ + {URL: "https://ok.example.test/relay"}, + {URL: "https://ok.example.test/relay?x=1"}, + {URL: "https://ok.example.test/../escape"}, + {URL: "http://public.example.test:8080"}, + {URL: "http://192.168.1.20:8787"}, + }, "auto") + if len(routes) != 4 { + t.Fatalf("safe route expansion = %#v, want primary + one candidate across two adapters", routes) + } + if got := routeCandidatesFromManifest("https://primary.example.test", nil, "wss"); got != nil { + t.Fatalf("unsupported wss transport produced routes: %#v", got) + } +} diff --git a/internal/httpapi/layered_assets_test.go b/internal/httpapi/layered_assets_test.go new file mode 100644 index 0000000..01fff0a --- /dev/null +++ b/internal/httpapi/layered_assets_test.go @@ -0,0 +1,97 @@ +package httpapi + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/EitanWong/remote-dev-skillkit/internal/gateway" +) + +func TestLayeredAssetsServeOnlyExactConfiguredPaths(t *testing.T) { + dir := t.TempDir() + manifestContent := []byte("{\"schema_version\":\"rdev.layered-assets.v1\"}\n") + manifestPath := filepath.Join(dir, "layered-assets.json") + hostContent := []byte("signed Windows host runtime\n") + hostPath := filepath.Join(dir, "rdev-host-windows-amd64.exe") + for path, content := range map[string][]byte{ + manifestPath: manifestContent, + hostPath: hostContent, + } { + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + } + + server := NewServer(gateway.NewMemoryGateway()) + server.Assets.LayeredAssetManifestPath = manifestPath + server.Assets.RdevHostWindowsAMD64Path = hostPath + handler := server.Handler() + + for requestPath, want := range map[string][]byte{ + "/layered-assets.json": manifestContent, + "/assets/rdev-host-windows-amd64.exe": hostContent, + } { + rec := serveAssetRequestForTest(handler, requestPath) + if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), want) { + t.Fatalf("exact asset %q = %d %q, want 200 and %q", requestPath, rec.Code, rec.Body.Bytes(), want) + } + } + + for _, requestPath := range []string{ + "/layered-assets.json?download=1", + "/layered-assets.json?", + "/%6cayered-assets.json", + "/layered%2dassets.json", + "/assets/%2e%2e/layered-assets.json", + "/assets/../layered-assets.json", + "/layered-assets.json.sha256", + "/layered-assets.json.extra", + "/nested/layered-assets.json", + "/layered-assets.json/", + } { + rec := serveAssetRequestForTest(handler, requestPath) + if rec.Code == http.StatusOK || bytes.Contains(rec.Body.Bytes(), manifestContent) { + t.Fatalf("manifest alias %q exposed configured content: %d %q", requestPath, rec.Code, rec.Body.Bytes()) + } + } + + fragmentRequest := httptest.NewRequest(http.MethodGet, "/layered-assets.json", nil) + fragmentRequest.URL.Fragment = "payload" + fragmentResponse := httptest.NewRecorder() + handler.ServeHTTP(fragmentResponse, fragmentRequest) + if fragmentResponse.Code == http.StatusOK || bytes.Contains(fragmentResponse.Body.Bytes(), manifestContent) { + t.Fatalf("fragment alias exposed configured content: %d %q", fragmentResponse.Code, fragmentResponse.Body.Bytes()) + } + + for _, requestPath := range []string{ + "/assets/rdev-host-windows-amd64.exe?download=1", + "/assets/rdev-host-windows-amd64.exe?", + "/assets/rdev-host-windows-amd64%2eexe", + "/assets/%2e/rdev-host-windows-amd64.exe", + "/assets/nested/rdev-host-windows-amd64.exe", + "/assets/rdev-host-windows-amd64.exe.extra", + "/assets/rdev-host-windows-amd64.exe/", + } { + rec := serveAssetRequestForTest(handler, requestPath) + if rec.Code == http.StatusOK || bytes.Contains(rec.Body.Bytes(), hostContent) { + t.Fatalf("core asset alias %q exposed configured content: %d %q", requestPath, rec.Code, rec.Body.Bytes()) + } + } + + unconfigured := NewServer(gateway.NewMemoryGateway()).Handler() + rec := serveAssetRequestForTest(unconfigured, "/layered-assets.json") + if rec.Code == http.StatusOK || bytes.Contains(rec.Body.Bytes(), manifestContent) { + t.Fatalf("unconfigured manifest path exposed content: %d %q", rec.Code, rec.Body.Bytes()) + } +} + +func serveAssetRequestForTest(handler http.Handler, target string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 070397a..8ef08d5 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -14,6 +14,7 @@ import ( "net/netip" "net/url" "os" + "path" "path/filepath" "strconv" "strings" @@ -43,13 +44,20 @@ type Server struct { var gatewayInstanceFallbackCounter atomic.Uint64 const permanentHostFailureExitCode = 78 +const layeredAssetManifestHTTPPath = "/layered-assets.json" +const layeredAssetManifestFileName = "layered-assets.json" type AssetConfig struct { - RdevWindowsAMD64Path string - RdevDarwinARM64Path string - RdevDarwinAMD64Path string - RdevLinuxAMD64Path string - RdevLinuxARM64Path string + LayeredAssetManifestPath string + LayeredReleaseRootPublicKey string + LayeredReleaseVersion string + RdevHostWindowsAMD64Path string + RdevBootstrapWindowsAMD64Path string + RdevBootstrapWindowsARM64Path string + RdevBootstrapDarwinARM64Path string + RdevBootstrapDarwinAMD64Path string + RdevBootstrapLinuxAMD64Path string + RdevBootstrapLinuxARM64Path string } func NewServer(gw *gateway.MemoryGateway) Server { @@ -127,11 +135,32 @@ func (s Server) Handler() http.Handler { mux.HandleFunc("POST /v1/tickets", s.createTicket) mux.HandleFunc("GET /v1/tickets/", s.ticketSubresource) mux.HandleFunc("GET /join/", s.join) + mux.HandleFunc("GET "+layeredAssetManifestHTTPPath, s.layeredAssetManifest) mux.HandleFunc("GET /assets/", s.asset) mux.HandleFunc("POST /v1/support-session/preconnect", s.supportSessionPreconnect) mux.HandleFunc("GET /v1/support-session/status", s.supportSessionStatus) mux.HandleFunc("GET /v1/audit", s.listAudit) - return mux + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isLayeredAssetTraversalAlias(r) { + writeError(w, http.StatusNotFound, "unknown asset") + return + } + mux.ServeHTTP(w, r) + }) +} + +func isLayeredAssetTraversalAlias(r *http.Request) bool { + cleanPath := path.Clean(r.URL.Path) + for _, exactPath := range []string{ + layeredAssetManifestHTTPPath, + "/assets/rdev-host-windows-amd64.exe", + "/assets/rdev-host-windows-amd64.exe.sha256", + } { + if cleanPath == exactPath && r.URL.Path != exactPath { + return true + } + } + return false } func (s Server) bootstrapProbeTemplate(w http.ResponseWriter, r *http.Request) { @@ -308,6 +337,11 @@ func (s Server) joinSession(w http.ResponseWriter, r *http.Request, sessionID st } func (s Server) sessionEventsAfter(w http.ResponseWriter, r *http.Request, sessionID string) { + wait, err := parseLongPollWait(r) + if err != nil { + writeProtocolError(w, http.StatusBadRequest, protocolHTTPError(controlplane.ErrTooManyEvents, err.Error(), true)) + return + } afterSeq, err := parseOptionalUint(r.URL.Query().Get("after_seq"), "after_seq") if err != nil { writeProtocolError(w, http.StatusBadRequest, protocolHTTPError(controlplane.ErrStaleCursor, err.Error(), true)) @@ -328,13 +362,56 @@ func (s Server) sessionEventsAfter(w http.ResponseWriter, r *http.Request, sessi writeProtocolError(w, http.StatusBadRequest, protocolHTTPError(controlplane.ErrTooManyEvents, err.Error(), true)) return } - events, lease, replay, err := s.Gateway.SessionEventsAfter(sessionID, controlplane.EventCursor{ + cursor := controlplane.EventCursor{ EndpointID: r.URL.Query().Get("endpoint_id"), LeaseSecret: extractBearerToken(r), AfterSeq: afterSeq, ReceivedSeq: receivedSeq, ProcessedSeq: processedSeq, - }, limit) + } + if wait > 0 { + preview, currentLease, peekReplay, peekErr := s.Gateway.PeekSessionEventsAfter(sessionID, cursor, limit) + if peekErr != nil { + writeControlPlaneErrorWithReplay(w, peekErr, peekReplay) + return + } + deadline := time.Now().Add(wait) + if !currentLease.RenewAfter.IsZero() { + renewalDeadline := currentLease.RenewAfter.Add(-time.Second) + if renewalDeadline.Before(deadline) { + deadline = renewalDeadline + } + } + if len(preview) == 0 && time.Now().Before(deadline) { + timer := time.NewTimer(time.Until(deadline)) + ticker := time.NewTicker(100 * time.Millisecond) + waitLoop: + for { + select { + case <-r.Context().Done(): + timer.Stop() + ticker.Stop() + return + case <-timer.C: + break waitLoop + case <-ticker.C: + preview, _, peekReplay, peekErr = s.Gateway.PeekSessionEventsAfter(sessionID, cursor, limit) + if peekErr != nil { + timer.Stop() + ticker.Stop() + writeControlPlaneErrorWithReplay(w, peekErr, peekReplay) + return + } + if len(preview) > 0 { + timer.Stop() + break waitLoop + } + } + } + ticker.Stop() + } + } + events, lease, replay, err := s.Gateway.SessionEventsAfter(sessionID, cursor, limit) if err != nil { writeControlPlaneErrorWithReplay(w, err, replay) return @@ -780,11 +857,12 @@ func (s Server) helperAssetContracts(gatewayURL string) map[string]model.HelperA name string path string }{ - {id: "windows-amd64", name: "rdev-windows-amd64.exe", path: s.Assets.RdevWindowsAMD64Path}, - {id: "darwin-arm64", name: "rdev-darwin-arm64", path: s.Assets.RdevDarwinARM64Path}, - {id: "darwin-amd64", name: "rdev-darwin-amd64", path: s.Assets.RdevDarwinAMD64Path}, - {id: "linux-amd64", name: "rdev-linux-amd64", path: s.Assets.RdevLinuxAMD64Path}, - {id: "linux-arm64", name: "rdev-linux-arm64", path: s.Assets.RdevLinuxARM64Path}, + {id: "windows-amd64", name: "rdev-bootstrap-windows-amd64.exe", path: s.Assets.RdevBootstrapWindowsAMD64Path}, + {id: "windows-arm64", name: "rdev-bootstrap-windows-arm64.exe", path: s.Assets.RdevBootstrapWindowsARM64Path}, + {id: "darwin-arm64", name: "rdev-bootstrap-darwin-arm64", path: s.Assets.RdevBootstrapDarwinARM64Path}, + {id: "darwin-amd64", name: "rdev-bootstrap-darwin-amd64", path: s.Assets.RdevBootstrapDarwinAMD64Path}, + {id: "linux-amd64", name: "rdev-bootstrap-linux-amd64", path: s.Assets.RdevBootstrapLinuxAMD64Path}, + {id: "linux-arm64", name: "rdev-bootstrap-linux-arm64", path: s.Assets.RdevBootstrapLinuxARM64Path}, } { contracts[asset.id] = helperAssetContract(gatewayURL, asset.name, asset.path) } @@ -1062,8 +1140,8 @@ func joinCopy(locale string) joinPageCopy { SelectedCommand: "如果无法自动识别,请使用下面的 macOS/Linux 或 Windows 命令。", PackageCatalogHeading: "Agent 包目录", NextHeading: "接下来会发生什么", - StepCheck: `启动脚本会检查 rdev。`, - StepStart: `它会用 --transport long-poll 启动一个可见、稳定的协助式主机会话。`, + StepCheck: `启动脚本会下载并校验 rdev-bootstrap。`, + StepStart: `rdev-bootstrap 会校验签名 core,并只启动一个使用自动通道选择的可见协助式主机会话。`, StepAgent: "Agent 会等待主机上线,在策略需要时完成批准,然后运行受限的修复任务。", }) case "es": @@ -1072,8 +1150,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "Conectar Esta Maquina", Note: "Ejecuta un comando en el equipo que necesita ayuda. La conexion es visible, solo saliente, revocable y limitada a este ticket.", NextHeading: "Que pasa despues", - StepCheck: `El bootstrap comprueba rdev.`, - StepStart: `Inicia una sesion visible y estable con --transport long-poll.`, + StepCheck: `El script descarga y verifica rdev-bootstrap.`, + StepStart: `rdev-bootstrap verifica el core firmado e inicia una sola sesion visible con seleccion automatica de canal.`, StepAgent: "El Agent espera el host, lo aprueba si la politica lo requiere y ejecuta trabajos de reparacion limitados.", }) case "fr": @@ -1082,8 +1160,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "Connecter Cette Machine", Note: "Executez une commande sur l'ordinateur a aider. La connexion est visible, sortante uniquement, revocable et limitee a ce ticket.", NextHeading: "Et ensuite", - StepCheck: `Le bootstrap verifie rdev.`, - StepStart: `Il demarre une session visible et stable avec --transport long-poll.`, + StepCheck: `Le script telecharge et verifie rdev-bootstrap.`, + StepStart: `rdev-bootstrap verifie le core signe et demarre une seule session visible avec selection automatique du canal.`, StepAgent: "L'Agent attend le host, l'approuve si la politique l'exige, puis execute des reparations limitees.", }) case "de": @@ -1092,8 +1170,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "Diese Maschine Verbinden", Note: "Fuhre einen Befehl auf dem Computer aus, der Hilfe braucht. Die Verbindung ist sichtbar, nur ausgehend, widerrufbar und auf dieses Ticket begrenzt.", NextHeading: "Was als Nachstes passiert", - StepCheck: `Der Bootstrap pruft rdev.`, - StepStart: `Er startet eine sichtbare, stabile Sitzung mit --transport long-poll.`, + StepCheck: `Das Skript ladt rdev-bootstrap herunter und pruft es.`, + StepStart: `rdev-bootstrap pruft den signierten Core und startet genau eine sichtbare Sitzung mit automatischer Kanalwahl.`, StepAgent: "Der Agent wartet auf den Host, genehmigt ihn falls erforderlich und startet begrenzte Reparaturjobs.", }) case "ja": @@ -1102,8 +1180,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "このマシンを接続", Note: "サポートが必要なコンピューターで 1 つのコマンドを実行します。接続は可視、アウトバウンドのみ、取り消し可能で、このサポートチケットに限定されます。", NextHeading: "次に行われること", - StepCheck: `bootstrap は rdev を確認します。`, - StepStart: `--transport long-poll で可視で安定したホストセッションを開始します。`, + StepCheck: `スクリプトは rdev-bootstrap をダウンロードして検証します。`, + StepStart: `rdev-bootstrap は署名済み core を検証し、自動チャネル選択で可視セッションを 1 つだけ開始します。`, StepAgent: "Agent はホストを待ち、ポリシーが必要とする場合に承認し、限定された修復ジョブを実行します。", }) case "ko": @@ -1112,8 +1190,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "이 머신 연결", Note: "도움이 필요한 컴퓨터에서 명령 하나를 실행합니다. 연결은 보이는 방식이며, 아웃바운드 전용이고, 철회 가능하며, 이 지원 티켓 범위로 제한됩니다.", NextHeading: "다음 단계", - StepCheck: `bootstrap 이 rdev 를 확인합니다.`, - StepStart: `--transport long-poll 로 보이고 안정적인 호스트 세션을 시작합니다.`, + StepCheck: `스크립트가 rdev-bootstrap 을 다운로드하고 검증합니다.`, + StepStart: `rdev-bootstrap 이 서명된 core 를 검증하고 자동 채널 선택으로 보이는 세션 하나만 시작합니다.`, StepAgent: "Agent 는 호스트를 기다리고, 정책상 필요하면 승인한 뒤 제한된 복구 작업을 실행합니다.", }) case "pt-BR": @@ -1122,8 +1200,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "Conectar Esta Maquina", Note: "Execute um comando no computador que precisa de ajuda. A conexao e visivel, somente de saida, revogavel e limitada a este ticket.", NextHeading: "O que acontece depois", - StepCheck: `O bootstrap verifica rdev.`, - StepStart: `Ele inicia uma sessao visivel e estavel com --transport long-poll.`, + StepCheck: `O script baixa e verifica rdev-bootstrap.`, + StepStart: `rdev-bootstrap verifica o core assinado e inicia uma unica sessao visivel com selecao automatica de canal.`, StepAgent: "O Agent aguarda o host, aprova quando a politica exige e executa tarefas de reparo limitadas.", }) case "hi": @@ -1132,8 +1210,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "इस मशीन को कनेक्ट करें", Note: "जिस कंप्यूटर को मदद चाहिए उस पर एक कमांड चलाएं। कनेक्शन दिखने वाला, केवल outbound, revoke करने योग्य, और इस support ticket तक सीमित है।", NextHeading: "आगे क्या होगा", - StepCheck: `bootstrap rdev जांचता है।`, - StepStart: `यह --transport long-poll के साथ visible और stable host session शुरू करता है।`, + StepCheck: `स्क्रिप्ट rdev-bootstrap डाउनलोड करके सत्यापित करती है।`, + StepStart: `rdev-bootstrap signed core सत्यापित करके automatic channel selection के साथ केवल एक visible session शुरू करता है।`, StepAgent: "Agent host का इंतजार करता है, policy की जरूरत पर authorize करता है, और scoped session tasks चलाता है।", }) case "ar": @@ -1142,8 +1220,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "توصيل هذا الجهاز", Note: "شغّل أمرا واحدا على الكمبيوتر الذي يحتاج إلى مساعدة. الاتصال ظاهر، صادر فقط، قابل للإلغاء، ومحدود بتذكرة الدعم هذه.", NextHeading: "ماذا يحدث بعد ذلك", - StepCheck: `يتحقق bootstrap من rdev.`, - StepStart: `يبدأ جلسة host مرئية ومستقرة باستخدام --transport long-poll.`, + StepCheck: `يقوم البرنامج النصي بتنزيل rdev-bootstrap والتحقق منه.`, + StepStart: `يتحقق rdev-bootstrap من core الموقع ويبدأ جلسة مرئية واحدة مع اختيار تلقائي للقناة.`, StepAgent: "ينتظر Agent ظهور host، ويوافق عليه عند الحاجة حسب السياسة، ثم يشغل مهام إصلاح محددة النطاق.", }) case "ru": @@ -1152,8 +1230,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "Подключить Эту Машину", Note: "Выполните одну команду на компьютере, которому нужна помощь. Подключение видимое, только исходящее, отзывное и ограничено этим тикетом.", NextHeading: "Что будет дальше", - StepCheck: `bootstrap проверит rdev.`, - StepStart: `Он запустит видимую и стабильную сессию host с --transport long-poll.`, + StepCheck: `Сценарий загружает и проверяет rdev-bootstrap.`, + StepStart: `rdev-bootstrap проверяет подписанный core и запускает один видимый сеанс с автоматическим выбором канала.`, StepAgent: "Agent дождется host, выполнит authorization при необходимости и запустит ограниченные session tasks.", }) default: @@ -1162,8 +1240,8 @@ func joinCopy(locale string) joinPageCopy { Heading: "Connect This Machine", Note: "Run one command on the computer that needs help. The connection is visible, outbound-only, revocable, and scoped to this support ticket.", NextHeading: "What Happens Next", - StepCheck: `The bootstrap checks for rdev.`, - StepStart: `It starts a visible, stable attended host session with --transport long-poll.`, + StepCheck: `The script downloads and verifies rdev-bootstrap.`, + StepStart: `rdev-bootstrap verifies the signed core and starts exactly one visible session with automatic channel selection.`, StepAgent: "The Agent waits for the host, authorizes it when policy requires, and runs scoped session tasks.", }) } @@ -1223,357 +1301,196 @@ func (s Server) writeShellBootstrap(w http.ResponseWriter, ticketCode, baseURL, s.setTicketBootstrapHeaders(w, ticketCode) w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8") w.WriteHeader(http.StatusOK) - rootArg := "" - if strings.TrimSpace(manifestRootPublicKey) != "" { - rootArg = " --manifest-root-public-key " + shellQuote(manifestRootPublicKey) - } - assetBase := shellQuote(strings.TrimRight(baseURL, "/") + "/assets") - preconnectURL := shellQuote(strings.TrimRight(baseURL, "/") + "/v1/support-session/preconnect") _, _ = fmt.Fprintf(w, `#!/bin/sh set -eu +bootstrap_base=%s +layered_manifest_url=%s +release_root=%s +release_version=%s +join_manifest_url=%s +join_manifest_root=%s +gateway_url=%s + +case "$bootstrap_base:$layered_manifest_url:$join_manifest_url:$gateway_url" in + *http://*|*ftp://*) echo "Connection Entry requires HTTPS assets and manifests." >&2; exit 78 ;; +esac +if [ -z "$release_root" ] || [ -z "$release_version" ] || [ -z "$join_manifest_root" ]; then + echo "Signed Connection Entry release metadata is unavailable." >&2 + exit 78 +fi +command -v curl >/dev/null 2>&1 || { echo "curl is required to obtain rdev-bootstrap." >&2; exit 127; } + os="$(uname -s | tr '[:upper:]' '[:lower:]')" arch="$(uname -m)" -asset="" -rdev_preconnect_url=%s -rdev_preconnect() { - phase="$1" - message="${2:-}" - if command -v curl >/dev/null 2>&1; then - curl -fsS -m 3 -X POST "$rdev_preconnect_url" \ - -H 'Content-Type: application/json' \ - --data "{\"ticket_code\":\"%s\",\"phase\":\"$phase\",\"os\":\"$os\",\"arch\":\"$arch\",\"asset\":\"$asset\",\"source\":\"rdev-bootstrap-preconnect\",\"message\":\"$message\"}" >/dev/null 2>&1 || true - fi - } - rdev_curl_retry_flags="--retry 3 --retry-delay 2 --connect-timeout 10" - if ! command -v rdev >/dev/null 2>&1; then - case "$arch" in - x86_64|amd64) arch="amd64" ;; - arm64|aarch64) arch="arm64" ;; - *) echo "unsupported architecture: $arch" >&2; exit 127 ;; - esac - case "$os" in - darwin|linux) ;; - *) echo "unsupported operating system: $os" >&2; exit 127 ;; - esac - asset="rdev-${os}-${arch}" - expected="$(curl $rdev_curl_retry_flags -fsSL %s"/${asset}.sha256")" - cache_base="${XDG_CACHE_HOME:-}" - if [ -z "$cache_base" ]; then - if [ -n "${HOME:-}" ]; then - cache_base="$HOME/.cache" - else - cache_base="${TMPDIR:-/tmp}" - fi - fi - cache_dir="$cache_base/remote-dev-skillkit/helpers" - mkdir -p "$cache_dir" - cache_path="$cache_dir/${asset}" - if [ -f "$cache_path" ]; then - rdev_preconnect "verifying-helper" "checking cached verified helper" - cache_actual="$(shasum -a 256 "$cache_path" | awk '{print $1}')" - if [ "$cache_actual" = "$expected" ]; then - rdev_preconnect "using-cached-helper" "using cached verified helper" - chmod 700 "$cache_path" - out="$cache_path" - rdev_cmd="$out" - else - rm -f "$cache_path" - fi - fi - if [ -z "${rdev_cmd:-}" ]; then - rdev_preconnect "downloading-helper" "downloading verified helper" - mkdir -p "${TMPDIR:-/tmp}/rdev-connection-entry" - out="${TMPDIR:-/tmp}/rdev-connection-entry/rdev" - echo "Downloading verified rdev helper ${asset}..." - gz_status="000" - if command -v gzip >/dev/null 2>&1; then - gz_status="$(curl $rdev_curl_retry_flags -fsS -o /dev/null -w "%%{http_code}" %s"/${asset}.gz" 2>/dev/null || true)" - fi - if [ "$gz_status" = "200" ]; then - tmp_gz="$out.gz" - curl $rdev_curl_retry_flags -fsSL %s"/${asset}.gz" -o "$tmp_gz" - gzip -dc "$tmp_gz" > "$out" - rm -f "$tmp_gz" - else - http_status="$(curl $rdev_curl_retry_flags -fsS -o /dev/null -w "%%{http_code}" %s"/${asset}" 2>/dev/null || true)" - if [ "$http_status" != "200" ]; then - echo "rdev helper binary not available at gateway (HTTP $http_status) — the gateway may still be starting. Wait a moment and retry." >&2 - exit 127 - fi - curl $rdev_curl_retry_flags -fsSL %s"/${asset}" -o "$out" - fi - rdev_preconnect "verifying-helper" "verifying downloaded helper" - actual="$(shasum -a 256 "$out" | awk '{print $1}')" - if [ "$actual" != "$expected" ]; then - echo "rdev helper SHA-256 mismatch" >&2 - rm -f "$out" - exit 127 - fi - cp "$out" "$cache_path" - chmod 700 "$out" - rdev_cmd="$out" - fi +case "$arch" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) echo "unsupported architecture: $arch" >&2; exit 127 ;; +esac +case "$os" in + darwin|linux) ;; + *) echo "unsupported operating system: $os" >&2; exit 127 ;; +esac +bootstrap_asset="rdev-bootstrap-$os-$arch" +bootstrap_url="$bootstrap_base/$bootstrap_asset" +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/rdev-bootstrap.XXXXXX")" +chmod 700 "$work_dir" +trap 'rm -rf "$work_dir"' EXIT HUP INT TERM +bootstrap_path="$work_dir/$bootstrap_asset" +expected="$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 10 --retry 3 "$bootstrap_url.sha256" | awk 'NR == 1 { print $1 }')" +case "$expected" in + [0-9a-fA-F][0-9a-fA-F]*) ;; + *) echo "rdev-bootstrap checksum is invalid." >&2; exit 78 ;; +esac +curl --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 10 --retry 3 "$bootstrap_url" -o "$bootstrap_path" +if command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$bootstrap_path" | awk '{print $1}')" +elif command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$bootstrap_path" | awk '{print $1}')" else - rdev_preconnect "using-installed-helper" "using installed rdev helper" - rdev_cmd="$(command -v rdev)" + echo "SHA-256 verification tool is required." >&2 + exit 127 fi -rdev_identity_base="${XDG_STATE_HOME:-}" -if [ -z "$rdev_identity_base" ]; then - if [ -n "${HOME:-}" ]; then - rdev_identity_base="$HOME/.local/state" - else - rdev_identity_base="${TMPDIR:-/tmp}" - fi +if [ "$(printf '%%s' "$actual" | tr '[:upper:]' '[:lower:]')" != "$(printf '%%s' "$expected" | tr '[:upper:]' '[:lower:]')" ]; then + echo "rdev-bootstrap SHA-256 mismatch." >&2 + exit 78 fi -rdev_identity_dir="$rdev_identity_base/remote-dev-skillkit" -mkdir -p "$rdev_identity_dir" -rdev_identity_store="$rdev_identity_dir/host-identity.json" -echo "Starting visible Remote Dev Skillkit host session..." -echo "[rdev] Persistent support identity: $rdev_identity_store" -rdev_preconnect "starting-full-helper" "starting verified full helper" -# Prevent idle/display/system sleep while the rdev session is active when the -# platform exposes a standard inhibitor. This does not bypass lock-screen -# policy or enterprise security controls. Kill the inhibitor when the runner -# exits. -rdev_caffeinate_pid="" -rdev_inhibit_pid="" -if [ "$os" = "darwin" ] && command -v caffeinate >/dev/null 2>&1; then - caffeinate -dimsu & - rdev_caffeinate_pid=$! - echo "[rdev] Sleep prevention enabled via caffeinate (pid $rdev_caffeinate_pid)" -elif [ "$os" = "linux" ] && command -v systemd-inhibit >/dev/null 2>&1; then - systemd-inhibit --what=sleep:idle --why="Remote Dev Skillkit host session is active" --mode=block sleep infinity & - rdev_inhibit_pid=$! - echo "[rdev] Sleep prevention enabled via systemd-inhibit (pid $rdev_inhibit_pid)" -else - echo "[rdev] Sleep prevention unavailable — keep this visible session active to avoid disconnection" -fi -rdev_permanent_exit=`+strconv.Itoa(permanentHostFailureExitCode)+` -rdev_max_retries=5 - rdev_retry_delay=5 - rdev_attempt=0 - while true; do - if "$rdev_cmd" host serve --manifest-url %s%s --transport long-poll --once=false --max-tasks 0 --identity-store "$rdev_identity_store"; then - rdev_exit=0 - else - rdev_exit=$? - fi - rdev_attempt=$((rdev_attempt + 1)) - echo "[rdev] host process exited with code $rdev_exit" - if [ "$rdev_exit" -eq 0 ] || [ "$rdev_exit" -eq "$rdev_permanent_exit" ] || [ "$rdev_attempt" -gt "$rdev_max_retries" ]; then - break - fi - echo "[rdev] Retrying (attempt $rdev_attempt of $rdev_max_retries) after ${rdev_retry_delay}s..." - sleep $rdev_retry_delay -done -if [ -n "$rdev_caffeinate_pid" ]; then - kill "$rdev_caffeinate_pid" 2>/dev/null || true -fi -if [ -n "$rdev_inhibit_pid" ]; then - kill "$rdev_inhibit_pid" 2>/dev/null || true -fi -exit $rdev_exit - `, preconnectURL, ticketCode, assetBase, assetBase, assetBase, assetBase, assetBase, shellQuote(manifestURL), rootArg) +chmod 700 "$bootstrap_path" +cache_base="${XDG_CACHE_HOME:-${HOME:?HOME is required}/.cache}" +cache_dir="$cache_base/RemoteDevSkillkit/cache" +state_base="${XDG_STATE_HOME:-${HOME}/.local/state}" +identity_dir="$state_base/RemoteDevSkillkit" +mkdir -p "$cache_dir" "$identity_dir" +chmod 700 "$cache_dir" "$identity_dir" +identity_store="$identity_dir/host-identity.json" + +set +e +"$bootstrap_path" layered-run \ + --manifest-url "$layered_manifest_url" \ + --root-public-key "$release_root" \ + --expected-release-version "$release_version" \ + --platform "$os/$arch" \ + --cache-dir "$cache_dir" \ + --mode temporary \ + -- \ + --mode temporary \ + --gateway "$gateway_url" \ + --manifest-url "$join_manifest_url" \ + --manifest-root-public-key "$join_manifest_root" \ + --transport auto \ + --once=false \ + --max-tasks 0 \ + --identity-store "$identity_store" +status=$? +set -e +exit "$status" +`, shellQuote(strings.TrimRight(baseURL, "/")+"/assets"), shellQuote(strings.TrimRight(baseURL, "/")+layeredAssetManifestHTTPPath), shellQuote(s.Assets.LayeredReleaseRootPublicKey), shellQuote(s.Assets.LayeredReleaseVersion), shellQuote(manifestURL), shellQuote(manifestRootPublicKey), shellQuote(baseURL)) } func (s Server) writePowerShellBootstrap(w http.ResponseWriter, ticketCode, baseURL, manifestURL, manifestRootPublicKey string) { s.setTicketBootstrapHeaders(w, ticketCode) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - rootArg := "" - if strings.TrimSpace(manifestRootPublicKey) != "" { - rootArg = " --manifest-root-public-key '" + powerShellSingleQuoteValue(manifestRootPublicKey) + "'" - } - assetBase := powerShellSingleQuoteValue(strings.TrimRight(baseURL, "/") + "/assets") - preconnectURL := powerShellSingleQuoteValue(strings.TrimRight(baseURL, "/") + "/v1/support-session/preconnect") _, _ = fmt.Fprintf(w, `$ErrorActionPreference = 'Stop' -$asset = '' -$preconnectUrl = '%s' -function Send-RdevPreconnect([string]$phase, [string]$message) { - try { - $body = @{ - ticket_code = '%s' - phase = $phase - os = 'windows' - arch = 'amd64' - asset = $asset - source = 'rdev-bootstrap-preconnect' - message = $message - } | ConvertTo-Json -Compress - Invoke-WebRequest -Uri $preconnectUrl -Method Post -Body $body -ContentType 'application/json' -UseBasicParsing -TimeoutSec 3 | Out-Null - } catch { - Write-Host "[rdev] preconnect status update skipped: $($_.Exception.Message)" - } -} -function Invoke-RdevWebRequestWithRetry([string]$Uri, [string]$OutFile = '', [int]$MaxAttempts = 3, [int]$DelaySeconds = 2) { - if ([string]::IsNullOrWhiteSpace($OutFile)) { - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - try { - return Invoke-WebRequest -Uri $Uri -UseBasicParsing -ErrorAction Stop -TimeoutSec 30 - } catch { - if ($attempt -ge $MaxAttempts) { throw } - Write-Host "[rdev] download attempt $attempt failed: $($_.Exception.Message). Retrying..." - Start-Sleep -Seconds $DelaySeconds - } - } - return - } - $partialPath = $OutFile + '.part' - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - try { - $offset = [int64]0 - if (Test-Path -LiteralPath $partialPath) { - $offset = (Get-Item -LiteralPath $partialPath).Length - } - $request = [System.Net.HttpWebRequest]::Create($Uri) - $request.Method = 'GET' - $request.Timeout = 30000 - $request.ReadWriteTimeout = 30000 - $request.AutomaticDecompression = [System.Net.DecompressionMethods]::None - if ($offset -gt 0) { $request.AddRange($offset) } - $response = $request.GetResponse() - try { - $append = $offset -gt 0 -and $response.StatusCode -eq [System.Net.HttpStatusCode]::PartialContent - $mode = [System.IO.FileMode]::Create - if ($append) { $mode = [System.IO.FileMode]::Append } - $inputStream = $response.GetResponseStream() - try { - $outputStream = [System.IO.File]::Open($partialPath, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) - try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() } - } finally { $inputStream.Dispose() } - } finally { $response.Dispose() } - Move-Item -Force -LiteralPath $partialPath -Destination $OutFile - return - } catch { - if ($attempt -ge $MaxAttempts) { throw } - Write-Host "[rdev] download attempt $attempt failed: $($_.Exception.Message). Retrying..." - Start-Sleep -Seconds $DelaySeconds - } +$bootstrapBase = '%s' +$layeredManifestUrl = '%s' +$releaseRoot = '%s' +$releaseVersion = '%s' +$joinManifestUrl = '%s' +$joinManifestRoot = '%s' +$gatewayUrl = '%s' + +foreach ($rawUrl in @($bootstrapBase, $layeredManifestUrl, $joinManifestUrl, $gatewayUrl)) { + $uri = [Uri]$rawUrl + if ($uri.Scheme -ne 'https' -or -not [string]::IsNullOrEmpty($uri.UserInfo) -or -not [string]::IsNullOrEmpty($uri.Query) -or -not [string]::IsNullOrEmpty($uri.Fragment)) { + throw 'Connection Entry requires HTTPS URLs without credentials, query strings, or fragments.' } } -$rdevCmd = Get-Command rdev -ErrorAction SilentlyContinue -if ($rdevCmd) { - Send-RdevPreconnect 'using-installed-helper' 'using installed rdev helper' - $rdevPath = $rdevCmd.Source -} else { - if (-not [Environment]::Is64BitOperatingSystem) { - throw "unsupported Windows architecture: 32-bit" - } - $asset = "rdev-windows-amd64.exe" - $expected = (Invoke-RdevWebRequestWithRetry -Uri ('%s/' + $asset + '.sha256')).Content.Trim() - $cacheBase = [Environment]::GetFolderPath('LocalApplicationData') - if ([string]::IsNullOrWhiteSpace($cacheBase)) { $cacheBase = $env:TEMP } - $cacheDir = Join-Path $cacheBase "RemoteDevSkillkit\cache\helpers" - New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null - $cachePath = Join-Path $cacheDir $asset - if (Test-Path -LiteralPath $cachePath) { - Send-RdevPreconnect 'verifying-helper' 'checking cached verified helper' - $cacheActual = (Get-FileHash -Algorithm SHA256 -Path $cachePath).Hash.ToLowerInvariant() - if ($cacheActual -eq $expected.ToLowerInvariant()) { - Send-RdevPreconnect 'using-cached-helper' 'using cached verified helper' - $rdevPath = $cachePath - } else { - Remove-Item -Force $cachePath -ErrorAction SilentlyContinue - } - } - if ([string]::IsNullOrWhiteSpace($rdevPath)) { - Send-RdevPreconnect 'downloading-helper' 'downloading verified helper' - $dir = Join-Path $env:TEMP "rdev-connection-entry" - New-Item -ItemType Directory -Force -Path $dir | Out-Null - $rdevPath = Join-Path $dir "rdev.exe" - Write-Host "Downloading verified rdev helper $asset..." - $compressedPath = $rdevPath + ".gz" - $usedCompressed = $false - try { - Invoke-RdevWebRequestWithRetry -Uri ('%s/' + $asset + '.gz') -OutFile $compressedPath - $inputStream = [System.IO.File]::OpenRead($compressedPath) - try { - $outputStream = [System.IO.File]::Create($rdevPath) - try { - $gzipStream = [System.IO.Compression.GzipStream]::new($inputStream, [System.IO.Compression.CompressionMode]::Decompress) - try { - $gzipStream.CopyTo($outputStream) - } finally { - $gzipStream.Dispose() - } - } finally { - $outputStream.Dispose() - } - } finally { - $inputStream.Dispose() - } - Remove-Item -Force $compressedPath -ErrorAction SilentlyContinue - $usedCompressed = $true - } catch { - Remove-Item -Force $compressedPath -ErrorAction SilentlyContinue - Remove-Item -Force ($compressedPath + '.part') -ErrorAction SilentlyContinue - Write-Host "Compressed rdev helper unavailable; falling back to uncompressed download." - } - if (-not $usedCompressed) { - try { - Invoke-RdevWebRequestWithRetry -Uri ('%s/' + $asset) -OutFile $rdevPath - } catch { - $errMsg = $_.Exception.Message - throw ("Failed to download rdev helper from gateway. The asset binary may not be configured yet — ensure the gateway has finished starting, then run the command again. Detail: " + $errMsg) - } - } - Send-RdevPreconnect 'verifying-helper' 'verifying downloaded helper' - $actual = (Get-FileHash -Algorithm SHA256 -Path $rdevPath).Hash.ToLowerInvariant() - if ($actual -ne $expected.ToLowerInvariant()) { - Remove-Item -Force $rdevPath -ErrorAction SilentlyContinue - throw "rdev helper SHA-256 mismatch" - } - Copy-Item -Force -Path $rdevPath -Destination $cachePath - } -} -Write-Host "Starting visible Remote Dev Skillkit host session..." -$identityBase = [Environment]::GetFolderPath('LocalApplicationData') -if ([string]::IsNullOrWhiteSpace($identityBase)) { $identityBase = $env:TEMP } -$identityDir = Join-Path $identityBase "RemoteDevSkillkit" -New-Item -ItemType Directory -Force -Path $identityDir | Out-Null -$identityStore = Join-Path $identityDir "host-identity.json" -Write-Host "[rdev] Persistent support identity: $identityStore" -Send-RdevPreconnect 'starting-full-helper' 'starting verified full helper' -# Prevent Windows idle sleep/display sleep while rdev is running. This does not -# bypass lock-screen policy or enterprise security controls. -# SetThreadExecutionState keeps the session awake so the runner can continue to -# poll for and execute session tasks. +if ([string]::IsNullOrWhiteSpace($releaseRoot) -or [string]::IsNullOrWhiteSpace($releaseVersion) -or [string]::IsNullOrWhiteSpace($joinManifestRoot)) { + throw 'Signed Connection Entry release metadata is unavailable.' +} +if (-not [Environment]::Is64BitOperatingSystem) { + throw 'Unsupported Windows architecture.' +} +$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'amd64' } +$asset = "rdev-bootstrap-windows-$arch.exe" +$bootstrapUrl = "$bootstrapBase/$asset" +$cacheDir = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'RemoteDevSkillkit\cache' +$downloadRoot = Join-Path $cacheDir 'bootstrap-download' +$downloadDir = Join-Path $downloadRoot ([Guid]::NewGuid().ToString('N')) +$bootstrapPath = Join-Path $downloadDir 'rdev-bootstrap.exe' +$attemptDir = Join-Path (Join-Path $cacheDir 'attempts') ([Guid]::NewGuid().ToString('N')) + +function Protect-RdevPath([string]$Path) { + $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $icacls = Join-Path $env:SystemRoot 'System32\icacls.exe' + & $icacls $Path '/inheritance:r' '/grant:r' "*$userSid:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to protect Connection Entry path: $Path" } +} + +[IO.Directory]::CreateDirectory($cacheDir) | Out-Null +[IO.Directory]::CreateDirectory($downloadRoot) | Out-Null +[IO.Directory]::CreateDirectory($downloadDir) | Out-Null +Protect-RdevPath $cacheDir +Protect-RdevPath $downloadRoot +Protect-RdevPath $downloadDir + +$handler = [System.Net.Http.HttpClientHandler]::new() +$handler.AllowAutoRedirect = $false +$handler.UseDefaultCredentials = $false +$client = [System.Net.Http.HttpClient]::new($handler) +$client.Timeout = [TimeSpan]::FromSeconds(30) try { - Add-Type -TypeDefinition @' -using System.Runtime.InteropServices; -public static class RdevSleepPrevention { - [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint f); - public const uint ES_CONTINUOUS = 0x80000000u; - public const uint ES_SYSTEM_REQUIRED = 0x00000001u; - public const uint ES_DISPLAY_REQUIRED = 0x00000002u; -} -'@ -ErrorAction SilentlyContinue - [void][RdevSleepPrevention]::SetThreadExecutionState([RdevSleepPrevention]::ES_CONTINUOUS -bor [RdevSleepPrevention]::ES_SYSTEM_REQUIRED -bor [RdevSleepPrevention]::ES_DISPLAY_REQUIRED) - Write-Host "[rdev] Sleep prevention enabled (SetThreadExecutionState)" -} catch { - Write-Host "[rdev] Sleep prevention unavailable — keep this window active to avoid disconnection" -} -$rdevPermanentExitCode = `+strconv.Itoa(permanentHostFailureExitCode)+` -$rdevMaxRetries = 5 -$rdevRetryDelaySec = 5 -$rdevAttempt = 0 -do { - if ($rdevAttempt -gt 0) { - Write-Host ("[rdev] Retrying host registration (attempt $($rdevAttempt + 1) of $($rdevMaxRetries + 1)) after ${rdevRetryDelaySec}s...") - Start-Sleep -Seconds $rdevRetryDelaySec - } - & $rdevPath host serve --manifest-url '%s'%s --transport long-poll --once=false --max-tasks 0 --identity-store $identityStore - $rdevExitCode = $LASTEXITCODE - $rdevAttempt++ - Write-Host "[rdev] host process exited with code $rdevExitCode" -} while ($rdevExitCode -ne 0 -and $rdevExitCode -ne $rdevPermanentExitCode -and $rdevAttempt -le $rdevMaxRetries) -# Restore normal sleep policy before exiting. -try { [void][RdevSleepPrevention]::SetThreadExecutionState([RdevSleepPrevention]::ES_CONTINUOUS) } catch { } -exit $rdevExitCode - `, preconnectURL, powerShellSingleQuoteValue(ticketCode), assetBase, assetBase, assetBase, powerShellSingleQuoteValue(manifestURL), rootArg) + $checksumResponse = $client.GetAsync("$bootstrapUrl.sha256").GetAwaiter().GetResult() + if (-not $checksumResponse.IsSuccessStatusCode) { throw 'rdev-bootstrap checksum download failed.' } + $expected = ($checksumResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() -split '\s+')[0].ToLowerInvariant() + if ($expected -notmatch '^[0-9a-f]{64}$') { throw 'rdev-bootstrap checksum is invalid.' } + + $binaryResponse = $client.GetAsync($bootstrapUrl).GetAwaiter().GetResult() + if (-not $binaryResponse.IsSuccessStatusCode) { throw 'rdev-bootstrap download failed.' } + $bytes = $binaryResponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() + [IO.File]::WriteAllBytes($bootstrapPath, $bytes) + $actual = (Get-FileHash -LiteralPath $bootstrapPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { throw 'rdev-bootstrap SHA-256 mismatch.' } + Protect-RdevPath $bootstrapPath + + & $bootstrapPath layered-run attempt-check --attempt-dir $attemptDir --launcher powershell --create + if ($LASTEXITCODE -ne 0) { throw 'rdev-bootstrap attempt initialization failed.' } + $bootstrapArgs = @( + 'layered-run', + '--manifest-url', $layeredManifestUrl, + '--root-public-key', $releaseRoot, + '--expected-release-version', $releaseVersion, + '--platform', "windows/$arch", + '--cache-dir', $cacheDir, + '--attempt-dir', $attemptDir, + '--launcher', 'powershell', + '--mode', 'temporary', + '--', + '--mode', 'temporary', + '--gateway', $gatewayUrl, + '--manifest-url', $joinManifestUrl, + '--manifest-root-public-key', $joinManifestRoot, + '--transport', 'auto', + '--once=false', + '--max-tasks', '0' + ) + & $bootstrapPath @bootstrapArgs + exit $LASTEXITCODE +} finally { + $client.Dispose() + $handler.Dispose() + Remove-Item -LiteralPath $downloadDir -Recurse -Force -ErrorAction SilentlyContinue +} +`, powerShellSingleQuoteValue(strings.TrimRight(baseURL, "/")+"/assets"), powerShellSingleQuoteValue(strings.TrimRight(baseURL, "/")+layeredAssetManifestHTTPPath), powerShellSingleQuoteValue(s.Assets.LayeredReleaseRootPublicKey), powerShellSingleQuoteValue(s.Assets.LayeredReleaseVersion), powerShellSingleQuoteValue(manifestURL), powerShellSingleQuoteValue(manifestRootPublicKey), powerShellSingleQuoteValue(baseURL)) } func (s Server) asset(w http.ResponseWriter, r *http.Request) { + const windowsHostAssetPath = "/assets/rdev-host-windows-amd64.exe" + if strings.HasPrefix(r.URL.Path, windowsHostAssetPath) { + s.rdevHostWindowsAMD64Asset(w, r) + return + } name := strings.Trim(strings.TrimPrefix(r.URL.Path, "/assets/"), "/") if name == "" || strings.Contains(name, "/") || strings.Contains(name, `\`) { writeError(w, http.StatusNotFound, "unknown asset") @@ -1596,7 +1513,7 @@ func (s Server) asset(w http.ResponseWriter, r *http.Request) { } sum, err := fileSHA256(path) if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, http.StatusInternalServerError, "asset is unavailable") return } if shaOnly { @@ -1612,10 +1529,72 @@ func (s Server) asset(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, path) } +func (s Server) layeredAssetManifest(w http.ResponseWriter, r *http.Request) { + if !exactAssetRequest(r, layeredAssetManifestHTTPPath) { + writeError(w, http.StatusNotFound, "unknown asset") + return + } + path, ok := configuredAssetPath(s.Assets.LayeredAssetManifestPath) + if !ok { + writeError(w, http.StatusNotFound, "asset is not configured") + return + } + file, err := os.Open(path) + if err != nil { + writeError(w, http.StatusInternalServerError, "asset is unavailable") + return + } + defer file.Close() + info, err := file.Stat() + if err != nil || info.IsDir() { + writeError(w, http.StatusInternalServerError, "asset is unavailable") + return + } + w.Header().Set("Content-Type", "application/json") + http.ServeContent(w, r, layeredAssetManifestFileName, info.ModTime(), file) +} + +func exactAssetRequest(r *http.Request, path string) bool { + return r.URL.EscapedPath() == path && + r.URL.RawQuery == "" && + !r.URL.ForceQuery && + r.URL.Fragment == "" +} + +func (s Server) rdevHostWindowsAMD64Asset(w http.ResponseWriter, r *http.Request) { + const assetPath = "/assets/rdev-host-windows-amd64.exe" + shaOnly := false + switch { + case exactAssetRequest(r, assetPath): + case exactAssetRequest(r, assetPath+".sha256"): + shaOnly = true + default: + writeError(w, http.StatusNotFound, "unknown asset") + return + } + path, ok := configuredAssetPath(s.Assets.RdevHostWindowsAMD64Path) + if !ok { + writeError(w, http.StatusNotFound, "asset is not configured") + return + } + sum, err := fileSHA256(path) + if err != nil { + writeError(w, http.StatusInternalServerError, "asset is unavailable") + return + } + if shaOnly { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(w, sum) + return + } + http.ServeFile(w, r, path) +} + func (s Server) serveGzipAsset(w http.ResponseWriter, path string) { file, err := os.Open(path) if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, http.StatusInternalServerError, "asset is unavailable") return } defer file.Close() @@ -1631,16 +1610,18 @@ func (s Server) serveGzipAsset(w http.ResponseWriter, path string) { func (s Server) assetPath(name string) (string, bool) { switch name { - case "rdev-windows-amd64.exe": - return configuredAssetPath(s.Assets.RdevWindowsAMD64Path) - case "rdev-darwin-arm64": - return configuredAssetPath(s.Assets.RdevDarwinARM64Path) - case "rdev-darwin-amd64": - return configuredAssetPath(s.Assets.RdevDarwinAMD64Path) - case "rdev-linux-amd64": - return configuredAssetPath(s.Assets.RdevLinuxAMD64Path) - case "rdev-linux-arm64": - return configuredAssetPath(s.Assets.RdevLinuxARM64Path) + case "rdev-bootstrap-windows-amd64.exe": + return configuredAssetPath(s.Assets.RdevBootstrapWindowsAMD64Path) + case "rdev-bootstrap-windows-arm64.exe": + return configuredAssetPath(s.Assets.RdevBootstrapWindowsARM64Path) + case "rdev-bootstrap-darwin-arm64": + return configuredAssetPath(s.Assets.RdevBootstrapDarwinARM64Path) + case "rdev-bootstrap-darwin-amd64": + return configuredAssetPath(s.Assets.RdevBootstrapDarwinAMD64Path) + case "rdev-bootstrap-linux-amd64": + return configuredAssetPath(s.Assets.RdevBootstrapLinuxAMD64Path) + case "rdev-bootstrap-linux-arm64": + return configuredAssetPath(s.Assets.RdevBootstrapLinuxARM64Path) default: return "", false } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index b4ef1af..27c028a 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -24,7 +24,6 @@ import ( "github.com/EitanWong/remote-dev-skillkit/internal/controlplane" "github.com/EitanWong/remote-dev-skillkit/internal/gateway" - "github.com/EitanWong/remote-dev-skillkit/internal/hostcmd" "github.com/EitanWong/remote-dev-skillkit/internal/model" "github.com/EitanWong/remote-dev-skillkit/internal/operatorauth" "github.com/EitanWong/remote-dev-skillkit/internal/tunnel" @@ -148,8 +147,9 @@ func TestProbingTicketPowerShellBootstrapUsesAuthoritativePublicCandidate(t *tes body := rec.Body.String() for _, want := range []string{ publicBase + "/assets", - publicBase + "/v1/support-session/preconnect", + publicBase + "/layered-assets.json", publicBase + "/v1/tickets/" + ticket.Code + "/manifest", + "rdev-bootstrap", } { if !strings.Contains(body, want) { t.Errorf("bootstrap omitted authoritative public base fragment %q", want) @@ -420,11 +420,11 @@ func TestJoinPageAndBootstrapScripts(t *testing.T) { accept string contains []string }{ - {path: "/join/" + ticket.Code, contains: []string{"Connect This Machine", "Recommended Entry", "Agent Package Catalog", "rdev.connection-entry.package-catalog.v1", "planned-release-asset-required", "rdev-connection-entry-windows-amd64.zip", "bootstrap.sh", "bootstrap.ps1"}}, + {path: "/join/" + ticket.Code, contains: []string{"Connect This Machine", "Recommended Entry", "Agent Package Catalog", "rdev.connection-entry.package-catalog.v1", "planned-release-asset-required", "rdev-connection-entry-windows-amd64.zip", "bootstrap.sh", "bootstrap.ps1", "signed core"}}, {path: "/join/" + ticket.Code + "?lang=zh-CN", contains: []string{"连接这台机器", "推荐入口", "Agent 包目录", "接下来会发生什么", "bootstrap.ps1"}}, {path: "/join/" + ticket.Code, accept: "pt-PT,pt;q=0.9", contains: []string{"Conectar Esta Maquina", "O que acontece depois"}}, - {path: "/join/" + ticket.Code + "/bootstrap.sh", contains: []string{"host serve", "Downloading verified rdev helper", ".gz", "gzip -dc", ".sha256", "--manifest-url", "--manifest-root-public-key", "--transport long-poll", "--once=false", "--max-tasks 0", "--identity-store", "host-identity.json", "caffeinate -dimsu", "systemd-inhibit --what=sleep:idle", "does not bypass lock-screen"}}, - {path: "/join/" + ticket.Code + "/bootstrap.ps1", contains: []string{"host serve", "Downloading verified rdev helper", ".gz", "GzipStream", ".sha256", "--manifest-url", "--manifest-root-public-key", "--transport long-poll", "--once=false", "--max-tasks 0", "--identity-store", "host-identity.json", "ES_DISPLAY_REQUIRED", "does not", "bypass lock-screen policy"}}, + {path: "/join/" + ticket.Code + "/bootstrap.sh", contains: []string{"rdev-bootstrap", "layered-run", ".sha256", "--root-public-key", "--expected-release-version", "--manifest-url", "--manifest-root-public-key", "--transport auto", "--once=false", "--max-tasks 0", "--identity-store", "host-identity.json"}}, + {path: "/join/" + ticket.Code + "/bootstrap.ps1", contains: []string{"rdev-bootstrap", "layered-run", "attempt-check", ".sha256", "Get-FileHash", "LocalApplicationData", "icacls.exe", "--root-public-key", "--expected-release-version", "--manifest-url", "--manifest-root-public-key", "--transport", "'auto'", "--once=false", "--max-tasks"}}, } { req := httptest.NewRequest(http.MethodGet, tc.path, nil) if tc.accept != "" { @@ -441,13 +441,14 @@ func TestJoinPageAndBootstrapScripts(t *testing.T) { } } if strings.Contains(rec.Body.String(), "ExecutionPolicy Bypass") || - strings.Contains(rec.Body.String(), "rdev is required") { + strings.Contains(rec.Body.String(), "rdev is required") || + strings.Contains(rec.Body.String(), "checks for rdev") { t.Fatalf("%s should not require preinstalled rdev or bypass execution policy:\n%s", tc.path, rec.Body.String()) } } } -func TestBootstrapScriptsReportPreconnectBeforeHelperDownload(t *testing.T) { +func TestBootstrapScriptsRemainSmallAndSkipLegacyPreconnect(t *testing.T) { server := NewServer(gateway.NewMemoryGateway()) handler := server.Handler() ticket := createTicket(t, handler) @@ -463,9 +464,12 @@ func TestBootstrapScriptsReportPreconnectBeforeHelperDownload(t *testing.T) { t.Fatalf("%s expected 200, got %d: %s", path, rec.Code, rec.Body.String()) } body := rec.Body.String() - for _, want := range []string{"/v1/support-session/preconnect", "downloading-helper", "rdev-bootstrap-preconnect"} { - if !strings.Contains(body, want) { - t.Fatalf("%s expected %q in bootstrap body:\n%s", path, want, body) + if !strings.Contains(body, "rdev-bootstrap") || !strings.Contains(body, "layered-run") { + t.Fatalf("%s must hand off through rdev-bootstrap:\n%s", path, body) + } + for _, forbidden := range []string{"/v1/support-session/preconnect", "downloading-helper", "rdev-bootstrap-preconnect"} { + if strings.Contains(body, forbidden) { + t.Fatalf("%s retained legacy preconnect marker %q:\n%s", path, forbidden, body) } } if rec.Body.Len() >= 1<<20 { @@ -474,7 +478,7 @@ func TestBootstrapScriptsReportPreconnectBeforeHelperDownload(t *testing.T) { } } -func TestBootstrapScriptsUseVerifiedHelperCache(t *testing.T) { +func TestBootstrapScriptsVerifyBootstrapAndUseProtectedCache(t *testing.T) { server := NewServer(gateway.NewMemoryGateway()) handler := server.Handler() ticket := createTicket(t, handler) @@ -489,14 +493,13 @@ func TestBootstrapScriptsUseVerifiedHelperCache(t *testing.T) { name: "shell", path: "/join/" + ticket.Code + "/bootstrap.sh", contains: []string{ - "remote-dev-skillkit/helpers", - "using-cached-helper", - "verifying-helper", - "starting-full-helper", + "rdev-bootstrap-$os-$arch", + "RemoteDevSkillkit/cache", "shasum -a 256", - "cp \"$out\" \"$cache_path\"", + "chmod 700", + "layered-run", }, - forbidden: []string{"systemctl enable", "launchctl load", "sudo "}, + forbidden: []string{"systemctl enable", "launchctl load", "sudo ", "starting-full-helper"}, }, { name: "powershell", @@ -504,14 +507,13 @@ func TestBootstrapScriptsUseVerifiedHelperCache(t *testing.T) { contains: []string{ "RemoteDevSkillkit", "cache", - "helpers", - "using-cached-helper", - "verifying-helper", - "starting-full-helper", + "rdev-bootstrap-windows-$arch.exe", "Get-FileHash", - "Copy-Item -Force -Path $rdevPath -Destination $cachePath", + "AllowAutoRedirect = $false", + "Protect-RdevPath", + "layered-run", }, - forbidden: []string{"New-Service", "sc.exe", "Set-Service"}, + forbidden: []string{"New-Service", "sc.exe", "Set-Service", "starting-full-helper"}, }, } { req := httptest.NewRequest(http.MethodGet, tc.path, nil) @@ -534,7 +536,38 @@ func TestBootstrapScriptsUseVerifiedHelperCache(t *testing.T) { } } -func TestShellBootstrapRetryLoopSurvivesSetE(t *testing.T) { +func TestGeneratedConnectionBootstrapUsesRdevBootstrapOnly(t *testing.T) { + server := NewServer(gateway.NewMemoryGateway()) + ticket := createTicket(t, server.Handler()) + for _, path := range []string{ + "/join/" + ticket.Code + "/bootstrap.sh", + "/join/" + ticket.Code + "/bootstrap.ps1", + } { + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("%s expected 200, got %d", path, rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "rdev-bootstrap") { + t.Fatalf("%s must invoke rdev-bootstrap:\n%s", path, body) + } + for _, forbidden := range []string{ + "command -v rdev", + "Get-Command rdev", + " host serve", + "rdev-host", + "Downloading verified rdev helper", + "rdev-bootstrap upgrade", + } { + if strings.Contains(body, forbidden) { + t.Fatalf("%s contains legacy connection path %q:\n%s", path, forbidden, body) + } + } + } +} + +func TestShellBootstrapInvokesCoreOnceThroughBootstrap(t *testing.T) { server := NewServer(gateway.NewMemoryGateway()) handler := server.Handler() ticket := createTicket(t, handler) @@ -549,64 +582,35 @@ func TestShellBootstrapRetryLoopSurvivesSetE(t *testing.T) { if !strings.Contains(body, "set -eu") { t.Fatalf("expected strict shell mode in bootstrap body:\n%s", body) } - if !strings.Contains(body, "if \"$rdev_cmd\" host serve ") || - !strings.Contains(body, "rdev_exit=0") || - !strings.Contains(body, "rdev_exit=$?") { - t.Fatalf("expected retry loop to capture host serve failures without set -e aborting:\n%s", body) + if strings.Count(body, "\"$bootstrap_path\" layered-run") != 1 || + !strings.Contains(body, "status=$?") { + t.Fatalf("expected exactly one layered bootstrap invocation with propagated status:\n%s", body) } - if strings.Contains(body, "\n \"$rdev_cmd\" host serve ") { - t.Fatalf("bootstrap must not run host serve as a bare command under set -e:\n%s", body) + for _, forbidden := range []string{"host serve", "rdev_max_retries", "rdev_attempt"} { + if strings.Contains(body, forbidden) { + t.Fatalf("bootstrap retained legacy retry path %q:\n%s", forbidden, body) + } } } -func TestBootstrapRetryLoopsStopOnPermanentHostFailureAndKeepSixAttemptLimit(t *testing.T) { +func TestPowerShellBootstrapUsesOneSharedAttemptState(t *testing.T) { server := NewServer(gateway.NewMemoryGateway()) handler := server.Handler() ticket := createTicket(t, handler) - permanentCode := strconv.Itoa(hostcmd.PermanentJoinFailureExitCode) - - tests := []struct { - name string - path string - contains []string - }{ - { - name: "shell", - path: "/join/" + ticket.Code + "/bootstrap.sh", - contains: []string{ - "rdev_permanent_exit=" + permanentCode, - "rdev_max_retries=5", - `[ "$rdev_exit" -eq "$rdev_permanent_exit" ]`, - `[ "$rdev_attempt" -gt "$rdev_max_retries" ]`, - }, - }, - { - name: "powershell", - path: "/join/" + ticket.Code + "/bootstrap.ps1", - contains: []string{ - "$rdevPermanentExitCode = " + permanentCode, - "$rdevMaxRetries = 5", - "$rdevExitCode -ne $rdevPermanentExitCode", - "$rdevAttempt -le $rdevMaxRetries", - }, - }, + req := httptest.NewRequest(http.MethodGet, "/join/"+ticket.Code+"/bootstrap.ps1", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - body := rec.Body.String() - for _, want := range tc.contains { - if !strings.Contains(body, want) { - t.Fatalf("bootstrap must contain %q:\n%s", want, body) - } - } - }) + body := rec.Body.String() + for _, want := range []string{"$attemptDir", "attempt-check", "--attempt-dir", "--launcher", "'powershell'"} { + if !strings.Contains(body, want) { + t.Fatalf("PowerShell bootstrap must contain %q:\n%s", want, body) + } + } + if strings.Count(body, "& $bootstrapPath @bootstrapArgs") != 1 { + t.Fatalf("PowerShell bootstrap must launch layered-run exactly once:\n%s", body) } } @@ -630,7 +634,7 @@ func TestUnexpectedControlPlaneErrorIsRecoverableAndDoesNotLeakDetails(t *testin } } -func TestBootstrapHelperDownloadsUseRetryBackoff(t *testing.T) { +func TestBootstrapDownloadsUseTLSAndChecksumVerification(t *testing.T) { server := NewServer(gateway.NewMemoryGateway()) handler := server.Handler() ticket := createTicket(t, handler) @@ -642,15 +646,9 @@ func TestBootstrapHelperDownloadsUseRetryBackoff(t *testing.T) { t.Fatalf("expected shell bootstrap 200, got %d: %s", rec.Code, rec.Body.String()) } shellBody := rec.Body.String() - for _, want := range []string{ - "rdev_curl_retry_flags=\"--retry 3 --retry-delay 2 --connect-timeout 10\"", - "curl $rdev_curl_retry_flags -fsSL", - "\"/${asset}.gz\" -o \"$tmp_gz\"", - "\"/${asset}\" -o \"$out\"", - "\"/${asset}.sha256\"", - } { + for _, want := range []string{"--proto '=https'", "--tlsv1.2", "--retry 3", ".sha256", "SHA-256 mismatch", "rdev-bootstrap"} { if !strings.Contains(shellBody, want) { - t.Fatalf("expected shell helper download retry/backoff fragment %q in bootstrap body:\n%s", want, shellBody) + t.Fatalf("expected shell bootstrap verification fragment %q in bootstrap body:\n%s", want, shellBody) } } @@ -671,21 +669,9 @@ func TestBootstrapHelperDownloadsUseRetryBackoff(t *testing.T) { t.Fatalf("PowerShell bootstrap ticket hash header = %q, want opaque hash %q", got, expectedTicketHash) } powerShellBody := rec.Body.String() - for _, want := range []string{ - "function Invoke-RdevWebRequestWithRetry", - "[int]$MaxAttempts = 3", - "Start-Sleep -Seconds", - "HttpWebRequest", - "AddRange", - ".part", - "PartialContent", - "Invoke-RdevWebRequestWithRetry -Uri ('", - "+ $asset + '.gz') -OutFile $compressedPath", - "+ $asset) -OutFile $rdevPath", - "+ $asset + '.sha256')", - } { + for _, want := range []string{"AllowAutoRedirect = $false", "Get-FileHash", ".sha256", "SHA-256 mismatch", "attempt-check", "--transport', 'auto'"} { if !strings.Contains(powerShellBody, want) { - t.Fatalf("expected PowerShell helper download retry/backoff fragment %q in bootstrap body:\n%s", want, powerShellBody) + t.Fatalf("expected PowerShell bootstrap verification fragment %q in bootstrap body:\n%s", want, powerShellBody) } } } @@ -695,7 +681,7 @@ func TestSupportSessionStatusIncludesPreconnectEvents(t *testing.T) { handler := server.Handler() ticket := createTicket(t, handler) - preconnectBody := bytes.NewBufferString(`{"ticket_code":"` + ticket.Code + `","phase":"downloading-helper","os":"windows","arch":"amd64","asset":"rdev-windows-amd64.exe","source":"rdev-bootstrap-preconnect","message":"downloading helper"}`) + preconnectBody := bytes.NewBufferString(`{"ticket_code":"` + ticket.Code + `","phase":"downloading-core","os":"windows","arch":"amd64","asset":"rdev-host-windows-amd64.exe","source":"rdev-bootstrap-layered","message":"downloading verified core runtime"}`) preconnectReq := httptest.NewRequest(http.MethodPost, "/v1/support-session/preconnect", preconnectBody) preconnectReq.Header.Set("Content-Type", "application/json") preconnectRec := httptest.NewRecorder() @@ -747,17 +733,17 @@ func TestSupportSessionStatusIncludesPreconnectEvents(t *testing.T) { } event := statusPayload.TargetPreconnects[0] if event.TicketCode != ticket.Code || - event.Phase != "downloading-helper" || + event.Phase != "downloading-core" || event.OS != "windows" || event.Arch != "amd64" || - event.Asset != "rdev-windows-amd64.exe" || - event.Source != "rdev-bootstrap-preconnect" || + event.Asset != "rdev-host-windows-amd64.exe" || + event.Source != "rdev-bootstrap-layered" || event.SeenCount != 1 { t.Fatalf("unexpected preconnect event: %#v", event) } if statusPayload.TargetPreconnectSummary.Status != "target-downloading" || - statusPayload.TargetPreconnectSummary.Phase != "downloading-helper" || - statusPayload.TargetPreconnectSummary.CountByPhase["downloading-helper"] != 1 || + statusPayload.TargetPreconnectSummary.Phase != "downloading-core" || + statusPayload.TargetPreconnectSummary.CountByPhase["downloading-core"] != 1 || !strings.Contains(statusPayload.TargetPreconnectSummary.AgentInterpretation, "not disconnected") { t.Fatalf("unexpected preconnect summary: %#v", statusPayload.TargetPreconnectSummary) } @@ -951,45 +937,109 @@ func TestSupportSessionStatusUnknownTicketDoesNotExposeHosts(t *testing.T) { func TestJoinAssetsServeConfiguredBinaryAndHash(t *testing.T) { dir := t.TempDir() - binaryPath := filepath.Join(dir, "rdev.exe") - if err := os.WriteFile(binaryPath, []byte("fake rdev binary\n"), 0o600); err != nil { + binaryPath := filepath.Join(dir, "rdev-bootstrap-windows-amd64.exe") + if err := os.WriteFile(binaryPath, []byte("fake bootstrap binary\n"), 0o600); err != nil { t.Fatal(err) } server := NewServer(gateway.NewMemoryGateway()) - server.Assets.RdevWindowsAMD64Path = binaryPath + server.Assets.RdevBootstrapWindowsAMD64Path = binaryPath handler := server.Handler() - req := httptest.NewRequest(http.MethodGet, "/assets/rdev-windows-amd64.exe.sha256", nil) + req := httptest.NewRequest(http.MethodGet, "/assets/rdev-bootstrap-windows-amd64.exe.sha256", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } - sum := sha256.Sum256([]byte("fake rdev binary\n")) + sum := sha256.Sum256([]byte("fake bootstrap binary\n")) if strings.TrimSpace(rec.Body.String()) != hex.EncodeToString(sum[:]) { t.Fatalf("unexpected sha body: %q", rec.Body.String()) } - req = httptest.NewRequest(http.MethodGet, "/assets/rdev-windows-amd64.exe", nil) + req = httptest.NewRequest(http.MethodGet, "/assets/rdev-bootstrap-windows-amd64.exe", nil) rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "fake rdev binary") { + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "fake bootstrap binary") { t.Fatalf("expected configured binary, got %d: %s", rec.Code, rec.Body.String()) } } +func TestRdevHostWindowsAMD64AssetServesExactBinaryAndHashOnly(t *testing.T) { + dir := t.TempDir() + binaryContent := []byte("fake Windows host core runtime\n") + binaryPath := filepath.Join(dir, "rdev-host-windows-amd64.exe") + if err := os.WriteFile(binaryPath, binaryContent, 0o600); err != nil { + t.Fatal(err) + } + server := NewServer(gateway.NewMemoryGateway()) + server.Assets.RdevHostWindowsAMD64Path = binaryPath + handler := server.Handler() + + req := httptest.NewRequest(http.MethodGet, "/assets/rdev-host-windows-amd64.exe", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), binaryContent) { + t.Fatalf("expected configured Windows host runtime, got %d: %q", rec.Code, rec.Body.Bytes()) + } + + req = httptest.NewRequest(http.MethodGet, "/assets/rdev-host-windows-amd64.exe.sha256", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + sum := sha256.Sum256(binaryContent) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != hex.EncodeToString(sum[:]) { + t.Fatalf("expected Windows host runtime checksum, got %d: %q", rec.Code, rec.Body.String()) + } + + for _, requestPath := range []string{ + "/assets/rdev-host-windows-arm64.exe", + "/assets/rdev-host-windows-amd64.exe.extra", + "/assets/nested/rdev-host-windows-amd64.exe", + "/assets/../rdev-host-windows-amd64.exe", + "/assets/rdev-host-windows-amd64.exe.gz", + "/assets/rdev-host-windows-amd64.exe.gz.sha256", + "/assets/rdev-host-windows-amd64.exe/", + "/assets/rdev-host-windows-amd64.exe.sha256/", + "/assets/rdev-host-windows-amd64.exe?download=1", + "/assets/rdev-host-windows-amd64.exe.sha256?download=1", + "/assets/%2Frdev-host-windows-amd64.exe", + "/assets/%2Frdev-host-windows-amd64.exe.sha256", + } { + req = httptest.NewRequest(http.MethodGet, requestPath, nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code == http.StatusOK || bytes.Contains(rec.Body.Bytes(), binaryContent) { + t.Fatalf("unexpected Windows host asset exposure for %q: %d %q", requestPath, rec.Code, rec.Body.Bytes()) + } + } +} + +func TestAssetErrorsDoNotExposeConfiguredFilesystemPath(t *testing.T) { + privatePath := filepath.Join(t.TempDir(), "private", "rdev-host-windows-amd64.exe") + rec := httptest.NewRecorder() + NewServer(gateway.NewMemoryGateway()).serveGzipAsset(rec, privatePath) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected asset read failure, got %d: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), privatePath) || strings.Contains(rec.Body.String(), filepath.Dir(privatePath)) { + t.Fatalf("asset error exposed configured filesystem path: %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"error":"asset is unavailable"`) { + t.Fatalf("expected generic asset error, got %s", rec.Body.String()) + } +} + func TestJoinAssetsServeGzipBinary(t *testing.T) { dir := t.TempDir() - binaryPath := filepath.Join(dir, "rdev.exe") - content := bytes.Repeat([]byte("fake rdev binary\n"), 1024) + binaryPath := filepath.Join(dir, "rdev-bootstrap-windows-amd64.exe") + content := bytes.Repeat([]byte("fake bootstrap binary\n"), 1024) if err := os.WriteFile(binaryPath, content, 0o600); err != nil { t.Fatal(err) } server := NewServer(gateway.NewMemoryGateway()) - server.Assets.RdevWindowsAMD64Path = binaryPath + server.Assets.RdevBootstrapWindowsAMD64Path = binaryPath handler := server.Handler() - req := httptest.NewRequest(http.MethodGet, "/assets/rdev-windows-amd64.exe.gz", nil) + req := httptest.NewRequest(http.MethodGet, "/assets/rdev-bootstrap-windows-amd64.exe.gz", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -1010,14 +1060,14 @@ func TestJoinAssetsServeGzipBinary(t *testing.T) { t.Fatal(err) } if !bytes.Equal(got, content) { - t.Fatal("gzip helper body did not round trip") + t.Fatal("gzip bootstrap body did not round trip") } } func TestTicketManifestPackageCatalogIncludesHelperAssetMirrors(t *testing.T) { dir := t.TempDir() - windowsHelper := filepath.Join(dir, "rdev-windows-amd64.exe") - helperContent := []byte("signed helper mirror contract\n") + windowsHelper := filepath.Join(dir, "rdev-bootstrap-windows-amd64.exe") + helperContent := []byte("signed bootstrap mirror contract\n") if err := os.WriteFile(windowsHelper, helperContent, 0o600); err != nil { t.Fatal(err) } @@ -1026,7 +1076,7 @@ func TestTicketManifestPackageCatalogIncludesHelperAssetMirrors(t *testing.T) { gw := gateway.NewMemoryGateway() server := NewServer(gw) - server.Assets.RdevWindowsAMD64Path = windowsHelper + server.Assets.RdevBootstrapWindowsAMD64Path = windowsHelper handler := server.Handler() ticket := createTicket(t, handler) @@ -1055,15 +1105,15 @@ func TestTicketManifestPackageCatalogIncludesHelperAssetMirrors(t *testing.T) { if candidate.ID == "" { t.Fatalf("windows-amd64 candidate missing: %#v", payload.Manifest.PackageCatalog.Candidates) } - if candidate.HelperAsset.Name != "rdev-windows-amd64.exe" || + if candidate.HelperAsset.Name != "rdev-bootstrap-windows-amd64.exe" || candidate.HelperAsset.ExpectedSHA256 != expectedSHA || - candidate.HelperAsset.SHA256URL != "https://gateway.example.test/assets/rdev-windows-amd64.exe.sha256" || + candidate.HelperAsset.SHA256URL != "https://gateway.example.test/assets/rdev-bootstrap-windows-amd64.exe.sha256" || len(candidate.HelperAsset.Mirrors) < 2 { t.Fatalf("expected helper asset mirror contract, got %#v", candidate.HelperAsset) } - if candidate.HelperAsset.Mirrors[0].URL != "https://gateway.example.test/assets/rdev-windows-amd64.exe.gz" || + if candidate.HelperAsset.Mirrors[0].URL != "https://gateway.example.test/assets/rdev-bootstrap-windows-amd64.exe.gz" || candidate.HelperAsset.Mirrors[0].Compression != "gzip" || - candidate.HelperAsset.Mirrors[1].URL != "https://gateway.example.test/assets/rdev-windows-amd64.exe" || + candidate.HelperAsset.Mirrors[1].URL != "https://gateway.example.test/assets/rdev-bootstrap-windows-amd64.exe" || candidate.HelperAsset.Mirrors[1].Compression != "" { t.Fatalf("unexpected helper mirrors: %#v", candidate.HelperAsset.Mirrors) } diff --git a/internal/httpapi/session_test.go b/internal/httpapi/session_test.go index 0578e76..8123c35 100644 --- a/internal/httpapi/session_test.go +++ b/internal/httpapi/session_test.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -199,6 +200,119 @@ func TestHTTPSessionCreateJoinEventsAndSnapshot(t *testing.T) { } } +func TestHTTPSessionEventsLongPollWaitsForNewEvent(t *testing.T) { + gw := gateway.NewMemoryGateway() + session, err := gw.CreateSession(controlplane.SessionSpec{Reason: "long poll fixture"}) + if err != nil { + t.Fatal(err) + } + _, endpoint, lease, _, err := gw.JoinSessionByCode(session.JoinCode, controlplane.EndpointSpec{ + Role: controlplane.EndpointRoleTarget, + Transport: controlplane.TransportLongPoll, + }) + if err != nil { + t.Fatal(err) + } + joined, err := gw.Session(session.ID) + if err != nil { + t.Fatal(err) + } + handler := NewServer(gw).Handler() + request := httptest.NewRequest(http.MethodGet, "/v1/sessions/"+url.PathEscape(session.ID)+"/events?endpoint_id="+url.QueryEscape(endpoint.ID)+"&after_seq="+fmt.Sprint(joined.LastSeq)+"&wait_ms=300", nil) + request.Header.Set("Authorization", "Bearer "+lease.Secret) + recorder := httptest.NewRecorder() + started := time.Now() + done := make(chan struct{}) + go func() { + handler.ServeHTTP(recorder, request) + close(done) + }() + time.Sleep(40 * time.Millisecond) + appended, err := gw.AppendSessionEvent(session.ID, controlplane.Event{ + Type: controlplane.EventType("status"), + ToEndpointID: endpoint.ID, + IdempotencyKey: "long-poll-event", + }) + if err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("long-poll event request did not finish") + } + if recorder.Code != http.StatusOK { + t.Fatalf("long-poll status = %d body=%s", recorder.Code, recorder.Body.String()) + } + if elapsed := time.Since(started); elapsed < 30*time.Millisecond || elapsed >= 300*time.Millisecond { + t.Fatalf("long-poll elapsed = %v, want response after appended event", elapsed) + } + var response struct { + Events []controlplane.Event `json:"events"` + Lease controlplane.Lease `json:"lease"` + } + decodeHTTP(t, recorder, &response) + if len(response.Events) != 1 || response.Events[0].Seq != appended.Seq { + t.Fatalf("long-poll events = %#v, want appended event", response.Events) + } + if response.Lease.Generation != lease.Generation+1 { + t.Fatalf("long-poll lease generation = %d, want exactly one renewal from %d", response.Lease.Generation, lease.Generation) + } +} + +func TestHTTPSessionEventsLongPollIgnoresInvisibleEvent(t *testing.T) { + gw := gateway.NewMemoryGateway() + session, err := gw.CreateSession(controlplane.SessionSpec{Reason: "long poll visibility fixture"}) + if err != nil { + t.Fatal(err) + } + _, endpoint, lease, _, err := gw.JoinSessionByCode(session.JoinCode, controlplane.EndpointSpec{ + Role: controlplane.EndpointRoleTarget, + Transport: controlplane.TransportLongPoll, + }) + if err != nil { + t.Fatal(err) + } + joined, err := gw.Session(session.ID) + if err != nil { + t.Fatal(err) + } + handler := NewServer(gw).Handler() + request := httptest.NewRequest(http.MethodGet, "/v1/sessions/"+url.PathEscape(session.ID)+"/events?endpoint_id="+url.QueryEscape(endpoint.ID)+"&after_seq="+fmt.Sprint(joined.LastSeq)+"&wait_ms=180", nil) + request.Header.Set("Authorization", "Bearer "+lease.Secret) + recorder := httptest.NewRecorder() + started := time.Now() + done := make(chan struct{}) + go func() { + handler.ServeHTTP(recorder, request) + close(done) + }() + time.Sleep(30 * time.Millisecond) + if _, err := gw.AppendSessionEvent(session.ID, controlplane.Event{ + Type: controlplane.EventType("status"), + ToEndpointID: "different-endpoint", + IdempotencyKey: "invisible-long-poll-event", + }); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("long-poll visibility request did not finish") + } + if elapsed := time.Since(started); elapsed < 150*time.Millisecond { + t.Fatalf("invisible event ended long-poll early after %v", elapsed) + } + var response struct { + Events []controlplane.Event `json:"events"` + Lease controlplane.Lease `json:"lease"` + } + decodeHTTP(t, recorder, &response) + if len(response.Events) != 0 || response.Lease.Generation != lease.Generation+1 { + t.Fatalf("invisible long-poll response events=%#v generation=%d", response.Events, response.Lease.Generation) + } +} + func TestHTTPAppendSessionEventRequiresLeaseOrOperatorForReservedSource(t *testing.T) { gw := gateway.NewMemoryGateway() session, err := gw.CreateSession(controlplane.SessionSpec{JoinPolicy: "single-target"}) diff --git a/internal/release/bundle.go b/internal/release/bundle.go index 9733196..c14a483 100644 --- a/internal/release/bundle.go +++ b/internal/release/bundle.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package release import ( @@ -21,6 +23,8 @@ const ( type Bundle struct { SchemaVersion string `json:"schema_version"` + Version string `json:"version,omitempty"` + TargetPlatform string `json:"target_platform,omitempty"` GeneratedAt time.Time `json:"generated_at"` SigningAlg string `json:"signing_alg"` SigningKeyID string `json:"signing_key_id"` @@ -41,6 +45,8 @@ type BundleArtifact struct { type BundleOptions struct { Dir string + Version string + TargetPlatform string ArtifactPaths []string RequiredArtifacts []string Key signing.Key @@ -49,6 +55,8 @@ type BundleOptions struct { type BundleVerification struct { SchemaVersion string `json:"schema_version"` + Version string `json:"version,omitempty"` + TargetPlatform string `json:"target_platform,omitempty"` BundlePath string `json:"bundle_path"` RootKeyID string `json:"root_key_id"` GeneratedAt time.Time `json:"generated_at"` @@ -134,6 +142,12 @@ func CreateBundle(opts BundleOptions) (Bundle, error) { if err := manifest.VerifyArtifact(artifactPath, root); err != nil { return Bundle{}, err } + if manifest.ReleaseVersion != strings.TrimSpace(opts.Version) { + return Bundle{}, fmt.Errorf("release manifest version %q does not match bundle version %q", manifest.ReleaseVersion, strings.TrimSpace(opts.Version)) + } + if manifest.TargetPlatform != strings.TrimSpace(opts.TargetPlatform) { + return Bundle{}, fmt.Errorf("release manifest target platform %q does not match bundle target platform %q", manifest.TargetPlatform, strings.TrimSpace(opts.TargetPlatform)) + } if manifest.ArtifactName != filepath.Base(rel) { return Bundle{}, fmt.Errorf("release manifest artifact name %q does not match %q", manifest.ArtifactName, filepath.Base(rel)) } @@ -157,6 +171,8 @@ func CreateBundle(opts BundleOptions) (Bundle, error) { } bundle := Bundle{ SchemaVersion: BundleSchemaVersion, + Version: strings.TrimSpace(opts.Version), + TargetPlatform: strings.TrimSpace(opts.TargetPlatform), GeneratedAt: now.UTC(), SigningAlg: model.SigningAlgEd25519, SigningKeyID: opts.Key.ID, @@ -224,10 +240,12 @@ func VerifyBundle(bundlePath string, root model.TrustBundle, requiredArtifacts [ } baseDir := filepath.Dir(abs) verification := BundleVerification{ - SchemaVersion: BundleVerificationSchemaVersion, - BundlePath: abs, - RootKeyID: root.SigningKeyID, - GeneratedAt: time.Now().UTC(), + SchemaVersion: BundleVerificationSchemaVersion, + Version: bundle.Version, + TargetPlatform: bundle.TargetPlatform, + BundlePath: abs, + RootKeyID: root.SigningKeyID, + GeneratedAt: time.Now().UTC(), } add := func(name string, passed bool, detail string) { verification.Checks = append(verification.Checks, BundleCheck{Name: name, Passed: passed, Detail: detail}) @@ -249,7 +267,7 @@ func VerifyBundle(bundlePath string, root model.TrustBundle, requiredArtifacts [ } seen[id] = true } - verification.Artifacts = append(verification.Artifacts, verifyBundleArtifact(baseDir, artifact, root)) + verification.Artifacts = append(verification.Artifacts, verifyBundleArtifact(baseDir, artifact, root, bundle.Version, bundle.TargetPlatform)) } add("bundle_artifact_ids_unique", duplicate == "", duplicate) missing := missingBundleRequiredArtifacts(seen, append(cleanStringList(bundle.RequiredArtifacts), cleanStringList(requiredArtifacts)...)) @@ -286,7 +304,7 @@ func WriteBundle(path string, bundle Bundle) error { return os.WriteFile(path, content, 0o644) } -func verifyBundleArtifact(baseDir string, artifact BundleArtifact, root model.TrustBundle) BundleArtifactVerification { +func verifyBundleArtifact(baseDir string, artifact BundleArtifact, root model.TrustBundle, version, targetPlatform string) BundleArtifactVerification { result := BundleArtifactVerification{ Name: firstNonEmpty(artifact.Name, filepath.Base(artifact.Path)), Artifact: artifact.Path, @@ -316,6 +334,8 @@ func verifyBundleArtifact(baseDir string, artifact BundleArtifact, root model.Tr manifest, manifestErr := ReadManifest(manifestPath) add("manifest_readable", manifestErr == nil, errorDetail(manifestErr)) add("manifest_artifact_name_matches", manifestErr == nil && manifest.ArtifactName == filepath.Base(artifact.Path), manifest.ArtifactName) + add("manifest_release_version_matches_bundle", manifestErr == nil && manifest.ReleaseVersion == version, manifest.ReleaseVersion) + add("manifest_target_platform_matches_bundle", manifestErr == nil && manifest.TargetPlatform == targetPlatform, manifest.TargetPlatform) verifyErr := manifestErr if verifyErr == nil { verifyErr = manifest.VerifyArtifact(artifactPath, root) diff --git a/internal/release/bundle_test.go b/internal/release/bundle_test.go index 02ee53b..47d459f 100644 --- a/internal/release/bundle_test.go +++ b/internal/release/bundle_test.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package release import ( @@ -112,6 +114,69 @@ func TestVerifyBundleRejectsMissingRequiredArtifact(t *testing.T) { } } +func TestCreateBundleBindsVersionAndTargetPlatform(t *testing.T) { + dir := t.TempDir() + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + for _, name := range []string{"rdev-host.exe", "rdev-verify.exe"} { + artifactPath := filepath.Join(dir, name) + if err := os.WriteFile(artifactPath, []byte(name+"-binary"), 0o644); err != nil { + t.Fatal(err) + } + manifest, err := SignArtifactForRelease(artifactPath, key, now, "v0.2.0", "windows/amd64") + if err != nil { + t.Fatal(err) + } + if err := WriteManifest(artifactPath+".rdev-release.json", manifest); err != nil { + t.Fatal(err) + } + } + + bundle, err := CreateBundle(BundleOptions{ + Dir: dir, + Version: "v0.2.0", + TargetPlatform: "windows/amd64", + ArtifactPaths: []string{"rdev-host.exe", "rdev-verify.exe"}, + RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + if bundle.Version != "v0.2.0" || bundle.TargetPlatform != "windows/amd64" { + t.Fatalf("bundle omitted signed release metadata: %#v", bundle) + } + bundlePath := filepath.Join(dir, "release-bundle.json") + if err := WriteBundle(bundlePath, bundle); err != nil { + t.Fatal(err) + } + root := model.NewTrustBundle(key.ID, key.PublicKey) + verification, err := VerifyBundle(bundlePath, root, nil) + if err != nil { + t.Fatal(err) + } + if !verification.OK() || verification.Version != bundle.Version || verification.TargetPlatform != bundle.TargetPlatform { + t.Fatalf("verified bundle did not expose signed release metadata: %#v", verification) + } + + tampered := bundle + tampered.TargetPlatform = "linux/amd64" + if err := WriteBundle(bundlePath, tampered); err != nil { + t.Fatal(err) + } + verification, err = VerifyBundle(bundlePath, root, nil) + if err != nil { + t.Fatal(err) + } + if verification.OK() || !bundleCheckFailed(verification, "bundle_signature_valid") { + t.Fatalf("tampered signed target platform should fail bundle verification: %#v", verification) + } +} + func signReleaseArtifactForTest(t *testing.T, dir, name, content string) signing.Key { t.Helper() key, err := signing.Generate("release-root") diff --git a/internal/release/candidate.go b/internal/release/candidate.go index 5cb862a..8354f09 100644 --- a/internal/release/candidate.go +++ b/internal/release/candidate.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package release import ( @@ -25,11 +27,18 @@ const CandidateSchemaVersion = "rdev.release-candidate.v1" const CandidateVerificationSchemaVersion = "rdev.release-candidate-verification.v1" const ConnectionEntryReleasePackageSchemaVersion = "rdev.connection-entry-release-package.v1" const connectionEntryReleaseArchivePath = "connection-entry-release.zip" +const connectionEntryReleaseBundlePath = "connection-entry-release-bundle.json" +const layeredAssetManifestValidity = 30 * 24 * time.Hour +const layeredAssetManifestPath = "layered-assets.json" + +const rdevHostWindowsAMD64AssetName = "rdev-host-windows-amd64.exe" +const windowsAMD64TargetPlatform = "windows/amd64" type CandidateOptions struct { SourceRoot string OutDir string Version string + TargetPlatform string GatewayURL string ArtifactPaths []string RequiredArtifacts []string @@ -38,23 +47,25 @@ type CandidateOptions struct { } type Candidate struct { - SchemaVersion string `json:"schema_version"` - Version string `json:"version"` - GeneratedAt time.Time `json:"generated_at"` - OutDir string `json:"out_dir"` - RootPublicKey string `json:"root_public_key"` - ReleaseBundlePath string `json:"release_bundle_path"` - SkillkitPath string `json:"skillkit_path"` - SBOMPath string `json:"sbom_path"` - ProvenancePath string `json:"provenance_path"` - ConnectionEntryPath string `json:"connection_entry_path"` - ChecksumsPath string `json:"checksums_path"` - Artifacts []CandidateArtifact `json:"artifacts"` - SkillkitVerification skillkit.VerificationReport `json:"skillkit_verification"` - BundleVerification BundleVerification `json:"bundle_verification"` - Checks []CandidateCheck `json:"checks"` - Files []CandidateFile `json:"files"` - RecommendedActions []string `json:"recommended_actions,omitempty"` + SchemaVersion string `json:"schema_version"` + Version string `json:"version"` + TargetPlatform string `json:"target_platform,omitempty"` + GeneratedAt time.Time `json:"generated_at"` + OutDir string `json:"out_dir"` + RootPublicKey string `json:"root_public_key"` + ReleaseBundlePath string `json:"release_bundle_path"` + SkillkitPath string `json:"skillkit_path"` + SBOMPath string `json:"sbom_path"` + ProvenancePath string `json:"provenance_path"` + ConnectionEntryPath string `json:"connection_entry_path"` + LayeredAssetManifestPath string `json:"layered_asset_manifest_path,omitempty"` + ChecksumsPath string `json:"checksums_path"` + Artifacts []CandidateArtifact `json:"artifacts"` + SkillkitVerification skillkit.VerificationReport `json:"skillkit_verification"` + BundleVerification BundleVerification `json:"bundle_verification"` + Checks []CandidateCheck `json:"checks"` + Files []CandidateFile `json:"files"` + RecommendedActions []string `json:"recommended_actions,omitempty"` } type CandidateArtifact struct { @@ -201,24 +212,41 @@ func PrepareCandidate(opts CandidateOptions) (Candidate, error) { now = time.Now() } candidate := Candidate{ - SchemaVersion: CandidateSchemaVersion, - Version: opts.Version, - GeneratedAt: now.UTC(), - OutDir: ".", - RootPublicKey: encodeReleaseRoot(opts.Key), - SkillkitPath: "skillkit", + SchemaVersion: CandidateSchemaVersion, + Version: opts.Version, + TargetPlatform: strings.TrimSpace(opts.TargetPlatform), + GeneratedAt: now.UTC(), + OutDir: ".", + RootPublicKey: encodeReleaseRoot(opts.Key), + SkillkitPath: "skillkit", } add := func(name string, passed bool, detail string) { candidate.Checks = append(candidate.Checks, CandidateCheck{Name: name, Passed: passed, Detail: detail}) } - artifacts, artifactIDs, err := stageAndSignCandidateArtifacts(outDir, opts.ArtifactPaths, opts.Key, now) + artifacts, artifactIDs, err := stageAndSignCandidateArtifacts(outDir, opts.ArtifactPaths, opts.Key, opts.Version, candidate.TargetPlatform, now) if err != nil { return Candidate{}, err } candidate.Artifacts = artifacts add("artifacts_staged", len(artifacts) == len(opts.ArtifactPaths), fmt.Sprintf("%d", len(artifacts))) add("artifact_manifests_written", artifactManifestsWritten(artifacts), "") + if candidate.TargetPlatform == windowsAMD64TargetPlatform && !candidateHasExplicitWindowsCoreRuntime(candidate) { + staged, err := stageWindowsAMD64CoreAsset(outDir, candidate) + if err != nil { + return Candidate{}, err + } + add("windows_core_asset_staged", staged, "assets/"+rdevHostWindowsAMD64AssetName) + } + if candidateHasWindowsCoreRuntime(candidate) { + candidate.LayeredAssetManifestPath = layeredAssetManifestPath + layeredAssetPath := filepath.Join(outDir, candidate.LayeredAssetManifestPath) + layeredAssetEntry, err := WriteLayeredAssetManifest(layeredAssetPath, candidate, opts.Key, now) + if err != nil { + return Candidate{}, err + } + add("layered_asset_manifest_written", pathExists(layeredAssetPath), layeredAssetEntry.Path) + } required := cleanStringList(opts.RequiredArtifacts) if len(required) == 0 { @@ -226,6 +254,8 @@ func PrepareCandidate(opts CandidateOptions) (Candidate, error) { } bundle, err := CreateBundle(BundleOptions{ Dir: outDir, + Version: candidate.Version, + TargetPlatform: candidate.TargetPlatform, ArtifactPaths: artifactIDs, RequiredArtifacts: required, Key: opts.Key, @@ -250,6 +280,27 @@ func PrepareCandidate(opts CandidateOptions) (Candidate, error) { candidate.BundleVerification = publicCandidateBundleVerification(candidate.BundleVerification) add("release_bundle_verified", bundleVerification.OK(), failedBundleCheckNames(bundleVerification)) + connectionEntryArtifactPaths := connectionEntryReleaseArtifactPaths(candidate) + if connectionEntryBootstrapArtifactCount(candidate) != 1 || connectionEntryVerifierArtifactCount(candidate) != 1 { + return Candidate{}, fmt.Errorf("connection entry release requires exactly one rdev-bootstrap and one rdev-verify artifact") + } + connectionEntryBundle, err := CreateBundle(BundleOptions{ + Dir: outDir, + Version: candidate.Version, + TargetPlatform: candidate.TargetPlatform, + ArtifactPaths: connectionEntryArtifactPaths, + RequiredArtifacts: connectionEntryRequiredReleaseArtifacts(candidate), + Key: opts.Key, + Now: now, + }) + if err != nil { + return Candidate{}, err + } + if err := WriteBundle(filepath.Join(outDir, connectionEntryReleaseBundlePath), connectionEntryBundle); err != nil { + return Candidate{}, err + } + add("connection_entry_release_bundle_written", pathExists(filepath.Join(outDir, connectionEntryReleaseBundlePath)), connectionEntryReleaseBundlePath) + skillManifest, err := skillkit.Export(skillkit.ExportOptions{ SourceRoot: sourceRootAbs, OutDir: filepath.Join(outDir, candidate.SkillkitPath), @@ -389,7 +440,23 @@ func VerifyCandidate(opts CandidateVerifyOptions) (CandidateVerification, error) verification.BundleVerification = bundleVerification verification.BundleVerification = publicCandidateBundleVerification(verification.BundleVerification) add("release_bundle_verified", bundleVerification.OK(), failedBundleCheckNames(bundleVerification)) + if bundleVerification.OK() { + versionMatches := bundleVerification.Version == "" || bundleVerification.Version == candidate.Version + targetMatches := bundleVerification.TargetPlatform == "" || bundleVerification.TargetPlatform == candidate.TargetPlatform + add("signed_bundle_version_matches_candidate", versionMatches, bundleVerification.Version) + add("signed_bundle_target_platform_matches_candidate", targetMatches, bundleVerification.TargetPlatform) + signedWindowsCore, signedWindowsCoreCount := verifiedBundleWindowsCoreRuntime(bundleVerification, bundleVerification.TargetPlatform) + add("signed_bundle_windows_core_unique", signedWindowsCoreCount <= 1, fmt.Sprintf("%d", signedWindowsCoreCount)) + switch { + case signedWindowsCoreCount == 1: + verification.Checks = append(verification.Checks, verifyCandidateLayeredAssetManifest(candidateDir, candidate, root, now, signedWindowsCore)...) + case candidate.LayeredAssetManifestPath != "": + add("layered_asset_manifest_has_signed_windows_core", false, candidate.LayeredAssetManifestPath) + } + } } + } else if candidate.LayeredAssetManifestPath != "" { + add("layered_asset_manifest_verified", false, "candidate root public key is invalid") } skillkitDir := filepath.Join(candidateDir, "skillkit") @@ -447,7 +514,7 @@ func VerifyCandidate(opts CandidateVerifyOptions) (CandidateVerification, error) return verification, nil } -func stageAndSignCandidateArtifacts(outDir string, sources []string, key signing.Key, now time.Time) ([]CandidateArtifact, []string, error) { +func stageAndSignCandidateArtifacts(outDir string, sources []string, key signing.Key, version, targetPlatform string, now time.Time) ([]CandidateArtifact, []string, error) { seen := map[string]bool{} var artifacts []CandidateArtifact var artifactIDs []string @@ -468,7 +535,7 @@ func stageAndSignCandidateArtifacts(outDir string, sources []string, key signing if err := copyCandidateFile(sourceAbs, target); err != nil { return nil, nil, err } - manifest, err := SignArtifact(target, key, now) + manifest, err := SignArtifactForRelease(target, key, now, version, targetPlatform) if err != nil { return nil, nil, err } @@ -536,6 +603,40 @@ func artifactManifestsWritten(artifacts []CandidateArtifact) bool { return true } +func stageWindowsAMD64CoreAsset(outDir string, candidate Candidate) (bool, error) { + artifact, count := candidateArtifact(candidate, "rdev-host.exe") + if count == 0 { + return false, fmt.Errorf("target %s requires rdev-host.exe", windowsAMD64TargetPlatform) + } + if count != 1 { + return false, fmt.Errorf("target %s has %d rdev-host.exe artifacts", windowsAMD64TargetPlatform, count) + } + sourcePath := filepath.Join(outDir, filepath.FromSlash(artifact.ArtifactPath)) + sha, size, err := fileDigest(sourcePath) + if err != nil { + return false, err + } + if sha != artifact.SHA256 || size != artifact.SizeBytes { + return false, fmt.Errorf("staged artifact metadata mismatch for rdev-host.exe") + } + assetDir := filepath.Join(outDir, "assets") + if err := os.Mkdir(assetDir, 0o700); err != nil { + return false, err + } + assetPath := filepath.Join(assetDir, rdevHostWindowsAMD64AssetName) + if err := copyCandidateFile(sourcePath, assetPath); err != nil { + return false, err + } + assetSHA, assetSize, err := fileDigest(assetPath) + if err != nil { + return false, err + } + if assetSHA != sha || assetSize != size { + return false, fmt.Errorf("staged Windows core asset differs from rdev-host.exe") + } + return true, nil +} + func collectCandidateFiles(root string, skip map[string]bool) ([]CandidateFile, error) { var files []CandidateFile err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { @@ -573,12 +674,16 @@ func candidateFileKind(path string) string { switch { case path == "release-bundle.json": return "release-bundle" + case path == layeredAssetManifestPath: + return "layered-asset-manifest" case path == "sbom.spdx.json": return "sbom" case path == "provenance.json": return "provenance" case path == connectionEntryReleaseArchivePath: return "connection-entry-release-archive" + case path == "assets/"+rdevHostWindowsAMD64AssetName: + return "layered-asset" case strings.HasSuffix(path, ".rdev-release.json"): return "release-manifest" case strings.HasPrefix(path, "skillkit/"): @@ -588,6 +693,207 @@ func candidateFileKind(path string) string { } } +func WriteLayeredAssetManifest(path string, candidate Candidate, key signing.Key, now time.Time) (CandidateFile, error) { + if candidate.RootPublicKey != encodeReleaseRoot(key) { + return CandidateFile{}, fmt.Errorf("candidate root public key does not match layered asset signing key") + } + artifact, assetPath, count := candidateWindowsCoreRuntime(candidate) + if count != 1 { + return CandidateFile{}, fmt.Errorf("candidate must contain exactly one Windows amd64 core runtime source") + } + sha, size, err := fileDigest(filepath.Join(filepath.Dir(path), filepath.FromSlash(assetPath))) + if err != nil { + return CandidateFile{}, err + } + if artifact.SHA256 != sha || artifact.SizeBytes != size { + return CandidateFile{}, fmt.Errorf("staged artifact metadata mismatch for %s", rdevHostWindowsAMD64AssetName) + } + sourcePath := filepath.Join(filepath.Dir(path), filepath.FromSlash(artifact.ArtifactPath)) + sourceSHA, sourceSize, err := fileDigest(sourcePath) + if err != nil { + return CandidateFile{}, err + } + if sourceSHA != sha || sourceSize != size { + return CandidateFile{}, fmt.Errorf("staged Windows core asset differs from signed source artifact %s", artifact.ArtifactPath) + } + signedManifest, err := ReadManifest(filepath.Join(filepath.Dir(path), filepath.FromSlash(artifact.ManifestPath))) + if err != nil { + return CandidateFile{}, err + } + if err := signedManifest.VerifyArtifact(sourcePath, model.NewTrustBundle(key.ID, key.PublicKey)); err != nil { + return CandidateFile{}, fmt.Errorf("verify signed Windows core source artifact: %w", err) + } + assets := []LayeredAsset{{ + ID: "rdev-host-windows-amd64", + Platform: windowsAMD64TargetPlatform, + Kind: layeredAssetKindCoreRuntime, + RelativePath: "assets/" + rdevHostWindowsAMD64AssetName, + SHA256: "sha256:" + sha, + SizeBytes: size, + }} + manifest, err := SignLayeredAssetManifest(LayeredAssetManifest{ + SchemaVersion: LayeredAssetManifestSchemaVersion, + Version: candidate.Version, + GeneratedAt: now.UTC(), + ExpiresAt: now.UTC().Add(layeredAssetManifestValidity), + Assets: assets, + }, key) + if err != nil { + return CandidateFile{}, err + } + content, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return CandidateFile{}, err + } + content = append(content, '\n') + if err := os.WriteFile(path, content, 0o644); err != nil { + return CandidateFile{}, err + } + sha, size, err = fileDigest(path) + if err != nil { + return CandidateFile{}, err + } + return CandidateFile{ + Path: layeredAssetManifestPath, + SHA256: "sha256:" + sha, + SizeBytes: size, + Kind: "layered-asset-manifest", + }, nil +} + +func candidateHasWindowsCoreRuntime(candidate Candidate) bool { + _, _, count := candidateWindowsCoreRuntime(candidate) + return count == 1 +} + +func candidateHasExplicitWindowsCoreRuntime(candidate Candidate) bool { + _, count := candidateArtifact(candidate, rdevHostWindowsAMD64AssetName) + return count > 0 +} + +func candidateArtifact(candidate Candidate, name string) (CandidateArtifact, int) { + var match CandidateArtifact + count := 0 + for _, artifact := range candidate.Artifacts { + if artifact.Name == name && artifact.ArtifactPath == name { + match = artifact + count++ + } + } + return match, count +} + +func candidateWindowsCoreRuntime(candidate Candidate) (CandidateArtifact, string, int) { + if explicit, count := candidateArtifact(candidate, rdevHostWindowsAMD64AssetName); count > 0 { + return explicit, rdevHostWindowsAMD64AssetName, count + } + if candidate.TargetPlatform != windowsAMD64TargetPlatform { + return CandidateArtifact{}, "", 0 + } + normal, count := candidateArtifact(candidate, "rdev-host.exe") + return normal, "assets/" + rdevHostWindowsAMD64AssetName, count +} + +func verifyCandidateLayeredAssetManifest(candidateDir string, candidate Candidate, root model.TrustBundle, now time.Time, signedCore BundleArtifactVerification) []CandidateCheck { + var checks []CandidateCheck + add := func(name string, passed bool, detail string) { + checks = append(checks, CandidateCheck{Name: name, Passed: passed, Detail: detail}) + } + pathValid := candidate.LayeredAssetManifestPath == layeredAssetManifestPath + add("layered_asset_manifest_path", pathValid, candidate.LayeredAssetManifestPath) + listed := 0 + listedWithKind := false + for _, file := range candidate.Files { + if file.Path == layeredAssetManifestPath { + listed++ + listedWithKind = listedWithKind || file.Kind == "layered-asset-manifest" + } + } + add("layered_asset_manifest_listed", listed == 1 && listedWithKind, fmt.Sprintf("%d", listed)) + if !pathValid { + return checks + } + + content, err := os.ReadFile(filepath.Join(candidateDir, layeredAssetManifestPath)) + add("layered_asset_manifest_exists", err == nil, layeredAssetManifestPath) + if err != nil { + return checks + } + manifest, err := DecodeLayeredAssetManifest(content) + add("layered_asset_manifest_json_valid", err == nil, errorDetail(err)) + if err != nil { + return checks + } + err = VerifyLayeredAssetManifest(manifest, root, now) + add("layered_asset_manifest_verified", err == nil, errorDetail(err)) + if err != nil { + return checks + } + add("layered_asset_manifest_version_matches_candidate", manifest.Version == candidate.Version, manifest.Version) + add("layered_asset_manifest_time_matches_candidate", manifest.GeneratedAt.Equal(candidate.GeneratedAt), manifest.GeneratedAt.UTC().Format(time.RFC3339Nano)) + checks = append(checks, verifyCandidateLayeredWindowsCore(candidateDir, candidate, manifest, signedCore)...) + return checks +} + +func verifyCandidateLayeredWindowsCore(candidateDir string, candidate Candidate, manifest LayeredAssetManifest, signedCore BundleArtifactVerification) []CandidateCheck { + var checks []CandidateCheck + add := func(name string, passed bool, detail string) { + checks = append(checks, CandidateCheck{Name: name, Passed: passed, Detail: detail}) + } + asset, err := SelectLayeredAsset(manifest, windowsAMD64TargetPlatform, layeredAssetKindCoreRuntime, nil) + contractValid := len(manifest.Assets) == 1 && err == nil && asset.ID == "rdev-host-windows-amd64" && asset.RelativePath == "assets/"+rdevHostWindowsAMD64AssetName + add("layered_asset_manifest_windows_core_contract", contractValid, firstNonEmpty(errorDetail(err), fmt.Sprintf("assets=%d", len(manifest.Assets)))) + if !contractValid { + return checks + } + artifact, assetPath, artifactCount := candidateWindowsCoreRuntime(candidate) + add("layered_asset_manifest_windows_core_artifact", artifactCount == 1, fmt.Sprintf("%d", artifactCount)) + if artifactCount != 1 { + return checks + } + sha, size, err := fileDigest(filepath.Join(candidateDir, filepath.FromSlash(assetPath))) + add("layered_asset_manifest_windows_core_readable", err == nil, publicCandidateDetail(errorDetail(err), candidateDir)) + if err != nil { + return checks + } + sourceSHA, sourceSize, sourceErr := fileDigest(filepath.Join(candidateDir, filepath.FromSlash(artifact.ArtifactPath))) + add("layered_asset_manifest_windows_core_source_readable", sourceErr == nil, publicCandidateDetail(errorDetail(sourceErr), candidateDir)) + if sourceErr != nil { + return checks + } + matches := asset.SHA256 == "sha256:"+sha && + asset.SizeBytes == size && + artifact.SHA256 == sha && + artifact.SizeBytes == size && + sourceSHA == sha && + sourceSize == size && + signedCore.ArtifactSHA == sha && + signedCore.Artifact == artifact.ArtifactPath + add("layered_asset_manifest_windows_core_matches_staged", matches, rdevHostWindowsAMD64AssetName) + return checks +} + +func verifiedBundleWindowsCoreRuntime(verification BundleVerification, targetPlatform string) (BundleArtifactVerification, int) { + var match BundleArtifactVerification + count := 0 + for _, artifact := range verification.Artifacts { + if artifact.Name == rdevHostWindowsAMD64AssetName && artifact.Artifact == rdevHostWindowsAMD64AssetName { + match = artifact + count++ + } + } + if count > 0 || targetPlatform != windowsAMD64TargetPlatform { + return match, count + } + for _, artifact := range verification.Artifacts { + if artifact.Name == "rdev-host.exe" && artifact.Artifact == "rdev-host.exe" { + match = artifact + count++ + } + } + return match, count +} + func candidateHasFile(files []CandidateFile, path string) bool { for _, file := range files { if file.Path == path { @@ -654,6 +960,9 @@ func WriteConnectionEntryReleaseArchive(path, candidateDir string, candidate Can } func buildConnectionEntryReleaseEntries(candidateDir string, candidate Candidate, generatedAt time.Time) ([]connectionEntryArchiveEntry, error) { + if connectionEntryBootstrapArtifactCount(candidate) != 1 || connectionEntryVerifierArtifactCount(candidate) != 1 { + return nil, fmt.Errorf("connection entry release requires exactly one rdev-bootstrap and one rdev-verify artifact") + } var entries []connectionEntryArchiveEntry addContent := func(path, kind string, content []byte) { entries = append(entries, connectionEntryArchiveEntry{Path: path, Kind: kind, Content: append([]byte(nil), content...)}) @@ -671,6 +980,9 @@ func buildConnectionEntryReleaseEntries(candidateDir string, candidate Candidate } for _, artifact := range candidate.Artifacts { + if !connectionEntryReleaseArtifactAllowed(artifact) { + continue + } if err := addCandidateFile(artifact.ArtifactPath, "bin/"+filepath.Base(artifact.ArtifactPath), "artifact"); err != nil { return nil, err } @@ -683,15 +995,29 @@ func buildConnectionEntryReleaseEntries(candidateDir string, candidate Candidate target string kind string }{ - {candidate.ReleaseBundlePath, "release/release-bundle.json", "release-bundle"}, - {candidate.SBOMPath, "release/sbom.spdx.json", "sbom"}, - {candidate.ProvenancePath, "release/provenance.json", "provenance"}, + {connectionEntryReleaseBundlePath, "bin/release-bundle.json", "release-bundle"}, } { if err := addCandidateFile(item.source, item.target, item.kind); err != nil { return nil, err } } + connectionEntryCandidate := candidate + connectionEntryCandidate.Artifacts = connectionEntryReleaseArtifacts(candidate) + connectionEntryCandidate.Files = nil + sbomContent, err := json.MarshalIndent(BuildCandidateSBOM(candidate.Version, connectionEntryCandidate.Artifacts, generatedAt), "", " ") + if err != nil { + return nil, err + } + addContent("release/sbom.spdx.json", "sbom", append(sbomContent, '\n')) + provenanceContent, err := json.MarshalIndent(BuildCandidateProvenance(connectionEntryCandidate, nil, generatedAt), "", " ") + if err != nil { + return nil, err + } + addContent("release/provenance.json", "provenance", append(provenanceContent, '\n')) + if _, err := verifiedLayeredWindowsEntryAvailable(candidateDir, candidate); err != nil { + return nil, err + } addContent("CONNECTION_ENTRY_RELEASE.md", "documentation", []byte(renderConnectionEntryReleaseReadme(candidate))) addContent("connection-entry-runner.template.json", "runner-template", []byte(renderConnectionEntryRunnerTemplate(candidate, generatedAt))) if connectionEntryReleaseUsesWindows(candidate) { @@ -715,6 +1041,54 @@ func buildConnectionEntryReleaseEntries(candidateDir string, candidate Candidate return entries, nil } +func verifiedLayeredWindowsEntryAvailable(candidateDir string, candidate Candidate) (bool, error) { + manifestDeclared := strings.TrimSpace(candidate.LayeredAssetManifestPath) != "" + bootstrap, bootstrapCount := candidateArtifact(candidate, "rdev-bootstrap.exe") + if !manifestDeclared || bootstrapCount == 0 { + return false, nil + } + if candidate.LayeredAssetManifestPath != layeredAssetManifestPath { + return false, fmt.Errorf("unexpected layered asset manifest path %q", candidate.LayeredAssetManifestPath) + } + if bootstrapCount != 1 { + return false, fmt.Errorf("candidate must contain exactly one rdev-bootstrap.exe artifact") + } + root, err := parseCandidateRootPublicKey(candidate.RootPublicKey) + if err != nil { + return false, fmt.Errorf("parse layered Windows release root: %w", err) + } + manifest, err := readLayeredAssetManifest(filepath.Join(candidateDir, layeredAssetManifestPath)) + if err != nil { + return false, fmt.Errorf("read layered Windows asset manifest: %w", err) + } + if err := VerifyLayeredAssetManifest(manifest, root, candidate.GeneratedAt); err != nil { + return false, fmt.Errorf("verify layered Windows asset manifest: %w", err) + } + if manifest.Version != candidate.Version { + return false, fmt.Errorf("layered Windows asset manifest version does not match candidate") + } + if _, err := SelectLayeredAsset(manifest, windowsAMD64TargetPlatform, layeredAssetKindCoreRuntime, nil); err != nil { + return false, fmt.Errorf("select layered Windows core runtime: %w", err) + } + bootstrapManifest, err := ReadManifest(filepath.Join(candidateDir, filepath.FromSlash(bootstrap.ManifestPath))) + if err != nil { + return false, fmt.Errorf("read Windows bootstrap release manifest: %w", err) + } + bootstrapPath := filepath.Join(candidateDir, filepath.FromSlash(bootstrap.ArtifactPath)) + if err := bootstrapManifest.VerifyArtifact(bootstrapPath, root); err != nil { + return false, fmt.Errorf("verify Windows bootstrap release artifact: %w", err) + } + return true, nil +} + +func readLayeredAssetManifest(path string) (LayeredAssetManifest, error) { + content, err := os.ReadFile(path) + if err != nil { + return LayeredAssetManifest{}, err + } + return DecodeLayeredAssetManifest(content) +} + func buildConnectionEntryReleasePackage(candidate Candidate, files []ConnectionEntryReleasePackageFile, generatedAt time.Time) ConnectionEntryReleasePackage { var artifacts []ConnectionEntryReleasePackageFile var releaseMetadata []ConnectionEntryReleasePackageFile @@ -748,7 +1122,7 @@ func buildConnectionEntryReleasePackage(candidate Candidate, files []ConnectionE Launchers: launchers, ChecksumsPath: "connection-entry-checksums.txt", AgentRules: []string{ - "Run the visible launcher; it verifies release/release-bundle.json with the packaged rdev-verify binary before running any packaged rdev binary.", + "Run the visible launcher; it verifies bin/release-bundle.json before invoking the packaged rdev-bootstrap.", "Verify connection-entry-checksums.txt before redistributing this archive.", "Do not ask humans to assemble ticket, gateway, transport, or root-key values by hand.", "Use rdev support-session connect or rdev connection-entry plan to produce the runtime invite data before execution.", @@ -784,17 +1158,16 @@ func renderConnectionEntryArchiveChecksums(files []ConnectionEntryReleasePackage } func connectionEntryReleaseUsesWindows(candidate Candidate) bool { - for _, artifact := range candidate.Artifacts { - if strings.EqualFold(filepath.Base(artifact.ArtifactPath), "rdev.exe") { - return true - } - } - return false + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(candidate.TargetPlatform)), "windows/") || + strings.EqualFold(filepath.Ext(connectionEntryArtifactBase(candidate, "rdev-bootstrap")), ".exe") } func connectionEntryRequiredReleaseArtifacts(candidate Candidate) []string { var required []string for _, artifact := range candidate.Artifacts { + if !connectionEntryReleaseArtifactAllowed(artifact) { + continue + } name := strings.TrimSpace(artifact.Name) if name == "" { name = filepath.Base(artifact.ArtifactPath) @@ -806,6 +1179,63 @@ func connectionEntryRequiredReleaseArtifacts(candidate Candidate) []string { return cleanStringList(required) } +func connectionEntryReleaseArtifactAllowed(artifact CandidateArtifact) bool { + name := strings.ToLower(filepath.Base(firstNonEmpty(artifact.Name, artifact.ArtifactPath))) + name = strings.TrimSuffix(name, ".exe") + return name == "rdev-bootstrap" || name == "rdev-verify" +} + +func connectionEntryReleaseArtifacts(candidate Candidate) []CandidateArtifact { + artifacts := make([]CandidateArtifact, 0, len(candidate.Artifacts)) + for _, artifact := range candidate.Artifacts { + if connectionEntryReleaseArtifactAllowed(artifact) { + artifacts = append(artifacts, artifact) + } + } + return artifacts +} + +func connectionEntryReleaseArtifactPaths(candidate Candidate) []string { + artifacts := connectionEntryReleaseArtifacts(candidate) + paths := make([]string, 0, len(artifacts)) + for _, artifact := range artifacts { + paths = append(paths, artifact.ArtifactPath) + } + return paths +} + +func connectionEntryAllowedArtifactCount(candidate Candidate) int { + count := 0 + for _, artifact := range candidate.Artifacts { + if connectionEntryReleaseArtifactAllowed(artifact) { + count++ + } + } + return count +} + +func connectionEntryBootstrapArtifactCount(candidate Candidate) int { + count := 0 + for _, artifact := range candidate.Artifacts { + name := strings.ToLower(filepath.Base(firstNonEmpty(artifact.Name, artifact.ArtifactPath))) + if strings.TrimSuffix(name, ".exe") == "rdev-bootstrap" { + count++ + } + } + return count +} + +func connectionEntryVerifierArtifactCount(candidate Candidate) int { + count := 0 + for _, artifact := range candidate.Artifacts { + name := strings.ToLower(filepath.Base(firstNonEmpty(artifact.Name, artifact.ArtifactPath))) + if strings.TrimSuffix(name, ".exe") == "rdev-verify" { + count++ + } + } + return count +} + func connectionEntryArtifactBase(candidate Candidate, preferred string) string { for _, artifact := range candidate.Artifacts { base := filepath.Base(artifact.ArtifactPath) @@ -889,10 +1319,10 @@ Agent flow: 3. Materialize a real connection-entry-runner.json from the signed invite or join manifest. 4. Run the visible launcher from launchers/. The launcher first verifies - release/release-bundle.json with the packaged rdev-verify binary and pinned - release root before it runs packaged rdev: + bin/release-bundle.json with the packaged rdev-verify binary and pinned + release root before it invokes packaged rdev-bootstrap: - rdev-verify --bundle release/release-bundle.json --root-public-key --require-artifacts + rdev-verify --bundle bin/release-bundle.json --root-public-key --require-artifacts Humans should receive only the final link, visible command, or package selected by the Agent. They should not hand-assemble ticket, gateway, transport, release, @@ -917,7 +1347,7 @@ func renderConnectionEntryRunnerTemplate(candidate Candidate, generatedAt time.T "standard_tools": []string{ "rdev support-session connect", "rdev connection-entry plan", - "rdev connection-entry run --runner-manifest connection-entry-runner.json", + "rdev-bootstrap layered-run --runner-manifest connection-entry-runner.json", }, "private_parameter_policy": "release archive must stay generic; runtime values come from signed invite or join manifest metadata", } @@ -926,16 +1356,16 @@ func renderConnectionEntryRunnerTemplate(candidate Candidate, generatedAt time.T } func renderConnectionEntryShellLauncher(candidate Candidate) string { - rdevName := connectionEntryArtifactBase(candidate, "rdev") + bootstrapName := connectionEntryArtifactBase(candidate, "rdev-bootstrap") verifyName := connectionEntryArtifactBase(candidate, "rdev-verify") return fmt.Sprintf(`#!/bin/sh set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) PACKAGE_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) MANIFEST="${1:-$PACKAGE_DIR/connection-entry-runner.json}" -RDEV="$PACKAGE_DIR/bin/%s" +BOOTSTRAP="$PACKAGE_DIR/bin/%s" VERIFY="$PACKAGE_DIR/bin/%s" -BUNDLE="$PACKAGE_DIR/release/release-bundle.json" +BUNDLE="$PACKAGE_DIR/bin/release-bundle.json" ROOT_PUBLIC_KEY=%s REQUIRED_ARTIFACTS=%s if [ ! -f "$MANIFEST" ]; then @@ -943,8 +1373,8 @@ if [ ! -f "$MANIFEST" ]; then echo "Ask your Agent to run rdev support-session connect or rdev connection-entry plan first." >&2 exit 2 fi -if [ ! -x "$RDEV" ]; then - echo "Missing packaged rdev binary: $RDEV" >&2 +if [ ! -x "$BOOTSTRAP" ]; then + echo "Missing packaged rdev-bootstrap binary: $BOOTSTRAP" >&2 exit 2 fi if [ ! -x "$VERIFY" ]; then @@ -956,25 +1386,167 @@ if [ ! -f "$BUNDLE" ]; then exit 2 fi "$VERIFY" --bundle "$BUNDLE" --root-public-key "$ROOT_PUBLIC_KEY" --require-artifacts "$REQUIRED_ARTIFACTS" >/dev/null -exec "$RDEV" connection-entry run --runner-manifest "$MANIFEST" -`, rdevName, verifyName, singleQuoteShell(candidate.RootPublicKey), singleQuoteShell(connectionEntryRequiredArtifactsCSV(candidate))) +cache_base="${XDG_CACHE_HOME:-${HOME:?HOME is required}/.cache}" +cache_dir="$cache_base/RemoteDevSkillkit/cache" +mkdir -p "$cache_dir" +chmod 700 "$cache_dir" +exec "$BOOTSTRAP" layered-run --runner-manifest "$MANIFEST" --root-public-key "$ROOT_PUBLIC_KEY" --expected-release-version %s --cache-dir "$cache_dir" --mode temporary +`, bootstrapName, verifyName, singleQuoteShell(candidate.RootPublicKey), singleQuoteShell(connectionEntryRequiredArtifactsCSV(candidate)), singleQuoteShell(candidate.Version)) } func renderConnectionEntryPowerShellLauncher(candidate Candidate) string { - return fmt.Sprintf(`$ErrorActionPreference = 'Stop' + bootstrap, _ := candidateArtifact(candidate, "rdev-bootstrap.exe") + platform := windowsAMD64TargetPlatform + if candidate.TargetPlatform == "windows/arm64" { + platform = candidate.TargetPlatform + } + bootstrapCommand := fmt.Sprintf(`function Assert-NoReparsePoint([string] $Path) { + if ($Path.StartsWith('\\')) { + throw "UNC paths are not allowed for layered bootstrap files: $Path" + } + $cursor = [IO.Path]::GetFullPath($Path) + while ($true) { + $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Reparse points are not allowed in the layered bootstrap path: $cursor" + } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Protect-PrivatePath([string] $Path) { + Assert-NoReparsePoint $Path + $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $systemSid = 'S-1-5-18' + $administratorsSid = 'S-1-5-32-544' + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + $permissions = if ($item.PSIsContainer) { '(OI)(CI)F' } else { 'F' } + $icacls = Join-Path $env:SystemRoot 'System32\icacls.exe' + & $icacls $Path '/inheritance:r' '/grant:r' "*${userSid}:$permissions" "*${systemSid}:$permissions" "*${administratorsSid}:$permissions" | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to secure layered bootstrap ACL: $Path" + } + $trustedSids = @($userSid, $systemSid, $administratorsSid) + $acl = Get-Acl -LiteralPath $Path + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + if ($trustedSids -notcontains $ownerSid) { + throw "Layered bootstrap path owner is not trusted: $Path" + } + foreach ($rule in $acl.Access) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { + continue + } + $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + if ($trustedSids -notcontains $sid) { + throw "Layered bootstrap ACL grants access to an untrusted identity: $Path" + } + } +} + +$Bootstrap = Join-Path $PackageDir 'bin\rdev-bootstrap.exe' +$CacheDir = Join-Path (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'RemoteDevSkillkit') 'cache' +$ExpectedBootstrapSHA256 = '%s' +$ExpectedBootstrapSize = %d +if (-not (Test-Path -LiteralPath $Bootstrap -PathType Leaf)) { + Write-Error 'Verified archive is missing rdev-bootstrap.exe.' + exit 2 +} +$privateRunDir = $null +$privateBootstrap = $null +try { + Assert-NoReparsePoint $Bootstrap + $Runner = Get-Content -Raw -LiteralPath $ManifestPath | ConvertFrom-Json + $JoinManifestUri = [Uri]$Runner.manifest_url + $LayeredManifestURL = if ($Runner.layered_assets_manifest_url) { [string]$Runner.layered_assets_manifest_url } else { [Uri]::new($JoinManifestUri, '/layered-assets.json').AbsoluteUri } + [IO.Directory]::CreateDirectory($CacheDir) | Out-Null + Protect-PrivatePath $CacheDir + $executionRoot = Join-Path $CacheDir 'bootstrap-exec' + [IO.Directory]::CreateDirectory($executionRoot) | Out-Null + Protect-PrivatePath $executionRoot + $privateRunDir = Join-Path $executionRoot ([Guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($privateRunDir) | Out-Null + Protect-PrivatePath $privateRunDir + $privateBootstrap = Join-Path $privateRunDir 'rdev-bootstrap.exe' + $bootstrapLock = [IO.File]::Open($Bootstrap, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $sha = [Security.Cryptography.SHA256]::Create() + try { + $actualBootstrapSHA256 = ([BitConverter]::ToString($sha.ComputeHash($bootstrapLock)) -replace '-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + } + if ($actualBootstrapSHA256 -ne $ExpectedBootstrapSHA256 -or $bootstrapLock.Length -ne $ExpectedBootstrapSize) { + throw 'Packaged rdev-bootstrap.exe failed SHA-256 or size verification.' + } + $bootstrapLock.Position = 0 + $privateBootstrapWriter = [IO.File]::Open($privateBootstrap, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $bootstrapLock.CopyTo($privateBootstrapWriter) + $privateBootstrapWriter.Flush() + } finally { + $privateBootstrapWriter.Dispose() + } + } finally { + $bootstrapLock.Dispose() + } + Protect-PrivatePath $privateBootstrap + $privateBootstrapLock = [IO.File]::Open($privateBootstrap, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $privateSHA = [Security.Cryptography.SHA256]::Create() + try { + $actualPrivateSHA256 = ([BitConverter]::ToString($privateSHA.ComputeHash($privateBootstrapLock)) -replace '-', '').ToLowerInvariant() + } finally { + $privateSHA.Dispose() + } + if ($actualPrivateSHA256 -ne $ExpectedBootstrapSHA256 -or $privateBootstrapLock.Length -ne $ExpectedBootstrapSize) { + throw 'Private rdev-bootstrap.exe copy failed SHA-256 or size verification.' + } + $attemptDir = Join-Path (Join-Path $CacheDir 'attempts') ([Guid]::NewGuid().ToString('N')) + & $privateBootstrap layered-run attempt-check --attempt-dir $attemptDir --launcher powershell --create + if ($LASTEXITCODE -ne 0) { throw 'rdev-bootstrap attempt initialization failed' } + & $privateBootstrap layered-run --manifest-url $LayeredManifestURL --root-public-key '%s' --expected-release-version '%s' --platform '%s' --cache-dir $CacheDir --attempt-dir $attemptDir --launcher powershell --mode temporary -- --mode temporary --gateway $Runner.gateway_url --manifest-url $Runner.manifest_url --manifest-root-public-key $Runner.manifest_root_public_key --transport auto --once=false + $layeredExitCode = $LASTEXITCODE + } finally { + $privateBootstrapLock.Dispose() + } + if ($layeredExitCode -eq 0) { + exit 0 + } + Write-Warning 'Layered bootstrap failed verification or execution.' + Write-Warning 'Run the signed rdev-bootstrap archive recovery profile explicitly.' + exit $layeredExitCode +} catch { + Write-Warning 'Layered bootstrap preparation failed.' + Write-Warning 'Run the signed rdev-bootstrap archive recovery profile explicitly.' + exit 1 +} finally { + if ($privateBootstrap -and (Test-Path -LiteralPath $privateBootstrap -PathType Leaf)) { + Remove-Item -LiteralPath $privateBootstrap -Force -ErrorAction SilentlyContinue + } + if ($privateRunDir -and (Test-Path -LiteralPath $privateRunDir -PathType Container)) { + Remove-Item -LiteralPath $privateRunDir -Force -ErrorAction SilentlyContinue + } +} +`, bootstrap.SHA256, bootstrap.SizeBytes, strings.ReplaceAll(candidate.RootPublicKey, "'", "''"), strings.ReplaceAll(candidate.Version, "'", "''"), platform) + return fmt.Sprintf(`param( + [Parameter(Position=0)] [string] $RunnerManifestPath = '' +) +$ErrorActionPreference = 'Stop' $PackageDir = Split-Path -Parent $PSScriptRoot -$ManifestPath = if ($args.Count -gt 0) { $args[0] } else { Join-Path $PackageDir 'connection-entry-runner.json' } -$Rdev = Join-Path $PackageDir 'bin\rdev.exe' +$ManifestPath = if ($RunnerManifestPath) { $RunnerManifestPath } else { Join-Path $PackageDir 'connection-entry-runner.json' } +$Bootstrap = Join-Path $PackageDir 'bin\rdev-bootstrap.exe' $Verifier = Join-Path $PackageDir 'bin\rdev-verify.exe' -$Bundle = Join-Path $PackageDir 'release\release-bundle.json' +$Bundle = Join-Path $PackageDir 'bin\release-bundle.json' $RootPublicKey = '%s' $RequiredArtifacts = '%s' if (-not (Test-Path -LiteralPath $ManifestPath)) { Write-Error 'Missing runtime connection-entry-runner.json. Ask your Agent to run rdev support-session connect or rdev connection-entry plan first.' exit 2 } -if (-not (Test-Path -LiteralPath $Rdev)) { - Write-Error "Missing packaged rdev binary: $Rdev" +if (-not (Test-Path -LiteralPath $Bootstrap)) { + Write-Error "Missing packaged rdev-bootstrap binary: $Bootstrap" exit 2 } if (-not (Test-Path -LiteralPath $Verifier)) { @@ -987,12 +1559,11 @@ if (-not (Test-Path -LiteralPath $Bundle)) { } & $Verifier --bundle $Bundle --root-public-key $RootPublicKey --require-artifacts $RequiredArtifacts | Out-Null if ($LASTEXITCODE -ne 0) { - Write-Error 'Release bundle verification failed; refusing to run packaged rdev.' + Write-Error 'Release bundle verification failed; packaged bootstrap was not started.' exit $LASTEXITCODE } -& $Rdev connection-entry run --runner-manifest $ManifestPath -exit $LASTEXITCODE -`, strings.ReplaceAll(candidate.RootPublicKey, "'", "''"), strings.ReplaceAll(connectionEntryRequiredArtifactsCSV(candidate), "'", "''")) +%s +`, strings.ReplaceAll(candidate.RootPublicKey, "'", "''"), strings.ReplaceAll(connectionEntryRequiredArtifactsCSV(candidate), "'", "''"), bootstrapCommand) } func publicCandidateBundleVerification(verification BundleVerification) BundleVerification { @@ -1157,6 +1728,8 @@ func verifyCandidateChecksumFile(candidateDir string, files []CandidateFile) []C } listed := map[string]string{} var malformed []string + var duplicate []string + seen := map[string]bool{} lines := strings.Split(strings.TrimSpace(string(content)), "\n") for _, line := range lines { line = strings.TrimSpace(line) @@ -1168,6 +1741,11 @@ func verifyCandidateChecksumFile(candidateDir string, files []CandidateFile) []C malformed = append(malformed, line) continue } + if seen[parts[1]] { + duplicate = append(duplicate, parts[1]) + continue + } + seen[parts[1]] = true listed[parts[1]] = parts[0] } var missing []string @@ -1189,11 +1767,13 @@ func verifyCandidateChecksumFile(candidateDir string, files []CandidateFile) []C } } sort.Strings(malformed) + sort.Strings(duplicate) sort.Strings(missing) sort.Strings(mismatch) sort.Strings(unexpected) checks = append(checks, CandidateCheck{Name: "checksums_lines_valid", Passed: len(malformed) == 0, Detail: strings.Join(malformed, ",")}, + CandidateCheck{Name: "checksums_paths_unique", Passed: len(duplicate) == 0, Detail: strings.Join(duplicate, ",")}, CandidateCheck{Name: "checksums_cover_candidate_files", Passed: len(missing) == 0, Detail: strings.Join(missing, ",")}, CandidateCheck{Name: "checksums_match_candidate_files", Passed: len(mismatch) == 0, Detail: strings.Join(mismatch, ",")}, CandidateCheck{Name: "checksums_have_no_unexpected_files", Passed: len(unexpected) == 0, Detail: strings.Join(unexpected, ",")}, @@ -1242,7 +1822,7 @@ func VerifyConnectionEntryReleaseArchive(candidateDir string, candidate Candidat "connection-entry-release.json", "connection-entry-runner.template.json", "connection-entry-checksums.txt", - "release/release-bundle.json", + "bin/release-bundle.json", "release/sbom.spdx.json", "release/provenance.json", } @@ -1258,6 +1838,9 @@ func VerifyConnectionEntryReleaseArchive(candidateDir string, candidate Candidat } } for _, artifact := range candidate.Artifacts { + if !connectionEntryReleaseArtifactAllowed(artifact) { + continue + } for _, path := range []string{ "bin/" + filepath.Base(artifact.ArtifactPath), "bin/" + filepath.Base(artifact.ManifestPath), @@ -1288,7 +1871,7 @@ func VerifyConnectionEntryReleaseArchive(candidateDir string, candidate Candidat CandidateCheck{Name: "connection_entry_release_requires_runtime_invite", Passed: manifest.ExecutionMode == "runtime-invite-required" && len(manifest.RequiredRuntimeData) > 0, Detail: manifest.ExecutionMode}, CandidateCheck{Name: "connection_entry_release_required_artifacts_present", Passed: len(manifest.RequiredReleaseArtifacts) > 0, Detail: strings.Join(manifest.RequiredReleaseArtifacts, ",")}, CandidateCheck{Name: "connection_entry_release_launchers_present", Passed: len(manifest.Launchers) >= 1, Detail: fmt.Sprintf("%d", len(manifest.Launchers))}, - CandidateCheck{Name: "connection_entry_release_artifacts_present", Passed: len(manifest.Artifacts) >= len(candidate.Artifacts), Detail: fmt.Sprintf("%d", len(manifest.Artifacts))}, + CandidateCheck{Name: "connection_entry_release_artifacts_present", Passed: len(manifest.Artifacts) >= connectionEntryAllowedArtifactCount(candidate)*2, Detail: fmt.Sprintf("%d", len(manifest.Artifacts))}, ) checks = append(checks, verifyConnectionEntryArchiveChecksums(entries)...) @@ -1399,7 +1982,7 @@ func verifyConnectionEntryLaunchersVerifyBundle(candidate Candidate, entries map required := connectionEntryRequiredArtifactsCSV(candidate) return []CandidateCheck{ {Name: "connection_entry_launchers_use_packaged_verifier", Passed: strings.Contains(joined, "rdev-verify"), Detail: "rdev-verify"}, - {Name: "connection_entry_launchers_verify_release_bundle", Passed: strings.Contains(joined, "--bundle") && (strings.Contains(joined, "release/release-bundle.json") || strings.Contains(joined, "release\\release-bundle.json")), Detail: "release-bundle.json"}, + {Name: "connection_entry_launchers_verify_release_bundle", Passed: strings.Contains(joined, "--bundle") && (strings.Contains(joined, "bin/release-bundle.json") || strings.Contains(joined, "bin\\release-bundle.json")), Detail: "release-bundle.json"}, {Name: "connection_entry_launchers_pin_release_root", Passed: strings.Contains(joined, "--root-public-key") && strings.Contains(joined, candidate.RootPublicKey), Detail: candidate.RootPublicKey}, {Name: "connection_entry_launchers_require_release_artifacts", Passed: strings.Contains(joined, "--require-artifacts") && required != "" && strings.Contains(joined, required), Detail: required}, } diff --git a/internal/release/candidate_test.go b/internal/release/candidate_test.go index 2511086..2e03c4c 100644 --- a/internal/release/candidate_test.go +++ b/internal/release/candidate_test.go @@ -1,7 +1,10 @@ +//go:build !rdev_bootstrap_focused + package release import ( "archive/zip" + "bytes" "encoding/json" "io" "os" @@ -29,7 +32,7 @@ func TestPrepareCandidateStagesSignedReleaseAndSkillkit(t *testing.T) { OutDir: out, Version: "v0.1.0", GatewayURL: "https://api.example.com/v1", - ArtifactPaths: []string{rdev, host, verify}, + ArtifactPaths: withBootstrapArtifactForTest(t, input, rdev, host, verify), RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, Key: key, Now: time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC), @@ -43,12 +46,19 @@ func TestPrepareCandidateStagesSignedReleaseAndSkillkit(t *testing.T) { if candidate.SchemaVersion != CandidateSchemaVersion { t.Fatalf("unexpected schema %q", candidate.SchemaVersion) } + if candidate.LayeredAssetManifestPath != "" { + t.Fatalf("nonmatching Windows artifact unexpectedly produced a layered manifest: %q", candidate.LayeredAssetManifestPath) + } + if _, err := os.Stat(filepath.Join(out, layeredAssetManifestPath)); !os.IsNotExist(err) { + t.Fatalf("nonmatching Windows artifact should not write %s: %v", layeredAssetManifestPath, err) + } for _, path := range []string{ "release-candidate.json", "release-bundle.json", "sbom.spdx.json", "provenance.json", "connection-entry-release.zip", + "connection-entry-release-bundle.json", "checksums.txt", "rdev", "rdev.rdev-release.json", @@ -85,14 +95,12 @@ func TestPrepareCandidateStagesSignedReleaseAndSkillkit(t *testing.T) { "connection-entry-release.json", "connection-entry-runner.template.json", "connection-entry-checksums.txt", - "release/release-bundle.json", + "bin/release-bundle.json", "release/sbom.spdx.json", "release/provenance.json", "launchers/start-connection-entry.sh", - "bin/rdev", - "bin/rdev.rdev-release.json", - "bin/rdev-host.exe", - "bin/rdev-host.exe.rdev-release.json", + "bin/rdev-bootstrap", + "bin/rdev-bootstrap.rdev-release.json", "bin/rdev-verify.exe", "bin/rdev-verify.exe.rdev-release.json", } { @@ -107,9 +115,9 @@ func TestPrepareCandidateStagesSignedReleaseAndSkillkit(t *testing.T) { if entryPackage.SchemaVersion != ConnectionEntryReleasePackageSchemaVersion || !entryPackage.NoPrivateParameters || entryPackage.ExecutionMode != "runtime-invite-required" || - strings.Join(entryPackage.RequiredReleaseArtifacts, ",") != "rdev,rdev-host.exe,rdev-verify.exe" || + strings.Join(entryPackage.RequiredReleaseArtifacts, ",") != "rdev-bootstrap,rdev-verify.exe" || len(entryPackage.Launchers) < 1 || - len(entryPackage.Artifacts) < len(candidate.Artifacts) { + len(entryPackage.Artifacts) != connectionEntryAllowedArtifactCount(candidate)*2 { t.Fatalf("unexpected Connection Entry release package: %#v", entryPackage) } launcher := string(archiveEntries["launchers/start-connection-entry.sh"]) @@ -119,12 +127,24 @@ func TestPrepareCandidateStagesSignedReleaseAndSkillkit(t *testing.T) { "--root-public-key", "--require-artifacts", candidate.RootPublicKey, - "rdev,rdev-host.exe,rdev-verify.exe", + "rdev-bootstrap,rdev-verify.exe", + "rdev-bootstrap", + "layered-run", } { if !strings.Contains(launcher, want) { t.Fatalf("expected Connection Entry launcher to contain %q, got %s", want, launcher) } } + for path, content := range archiveEntries { + if path == "bin/rdev" || strings.HasPrefix(path, "bin/rdev-host") { + t.Fatalf("Connection Entry archive retained legacy artifact %s", path) + } + for _, forbidden := range []string{"host serve", "connection-entry run", "rdev-bootstrap upgrade"} { + if strings.Contains(string(content), forbidden) { + t.Fatalf("Connection Entry archive retained legacy path %q in %s", forbidden, path) + } + } + } if strings.Contains(string(archiveEntries["connection-entry-release.json"]), filepath.Dir(out)) || strings.Contains(string(archiveEntries["CONNECTION_ENTRY_RELEASE.md"]), "192.168.") { t.Fatalf("Connection Entry archive leaked private/local metadata") @@ -158,6 +178,785 @@ func TestPrepareCandidateStagesSignedReleaseAndSkillkit(t *testing.T) { } } +func TestGeneratedConnectionLaunchersUseBootstrapOnly(t *testing.T) { + candidate := Candidate{ + Version: "v2.0.0", + RootPublicKey: "release-root:key", + Artifacts: []CandidateArtifact{ + {Name: "rdev-bootstrap.exe", ArtifactPath: "rdev-bootstrap.exe", ManifestPath: "rdev-bootstrap.exe.rdev-release.json", SHA256: "sha256:" + strings.Repeat("a", 64), SizeBytes: 10}, + {Name: "rdev.exe", ArtifactPath: "rdev.exe", ManifestPath: "rdev.exe.rdev-release.json", SHA256: "sha256:" + strings.Repeat("b", 64), SizeBytes: 10}, + }, + } + for name, content := range map[string]string{ + "shell": renderConnectionEntryShellLauncher(candidate), + "powershell": renderConnectionEntryPowerShellLauncher(candidate), + } { + if !strings.Contains(content, "rdev-bootstrap") || !strings.Contains(content, "layered-run") { + t.Fatalf("%s launcher must invoke rdev-bootstrap:\n%s", name, content) + } + for _, forbidden := range []string{"host serve", "rdev-host", "Get-Command rdev", "command -v rdev", "rdev-bootstrap upgrade", "RDEV=", "$RDEV connection-entry", "& $Rdev connection-entry"} { + if strings.Contains(content, forbidden) { + t.Fatalf("%s launcher contains legacy path %q:\n%s", name, forbidden, content) + } + } + } +} + +func TestWindowsARM64ConnectionLauncherUsesCandidatePlatform(t *testing.T) { + candidate := Candidate{ + Version: "v2.0.0", + TargetPlatform: "windows/arm64", + RootPublicKey: "release-root:key", + Artifacts: []CandidateArtifact{ + {Name: "rdev-bootstrap.exe", ArtifactPath: "rdev-bootstrap.exe", SHA256: "sha256:" + strings.Repeat("a", 64), SizeBytes: 10}, + }, + } + launcher := renderConnectionEntryPowerShellLauncher(candidate) + if !strings.Contains(launcher, "--platform 'windows/arm64'") || strings.Contains(launcher, "--platform 'windows/amd64'") { + t.Fatalf("Windows ARM64 launcher used the wrong layered platform:\n%s", launcher) + } +} + +func TestPrepareCandidateWritesSignedWindowsLayeredAssets(t *testing.T) { + input := t.TempDir() + out := filepath.Join(t.TempDir(), "candidate") + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + core := writeCandidateArtifactForTest(t, input, "rdev-host-windows-amd64.exe", "windows-core-runtime") + helper := writeCandidateArtifactForTest(t, input, "rdev-verify.exe", "verify-binary") + nearMatch := writeCandidateArtifactForTest(t, input, "rdev-host-windows-amd64.exe.bak", "not-a-core-runtime") + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + ArtifactPaths: withWindowsBootstrapArtifactForTest(t, input, core, helper, nearMatch), + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + if candidate.LayeredAssetManifestPath != layeredAssetManifestPath { + t.Fatalf("unexpected layered asset manifest path %q", candidate.LayeredAssetManifestPath) + } + + manifestContent, err := os.ReadFile(filepath.Join(out, layeredAssetManifestPath)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(manifestContent), input) || strings.Contains(string(manifestContent), out) { + t.Fatalf("layered asset manifest leaked a private input or output path: %s", manifestContent) + } + var manifest LayeredAssetManifest + if err := json.Unmarshal(manifestContent, &manifest); err != nil { + t.Fatal(err) + } + root, err := parseCandidateRootPublicKey(candidate.RootPublicKey) + if err != nil { + t.Fatal(err) + } + if err := VerifyLayeredAssetManifest(manifest, root, now); err != nil { + t.Fatalf("layered asset manifest did not verify with candidate root: %v", err) + } + if len(manifest.Assets) != 1 { + t.Fatalf("expected only the exact Windows host artifact in layered manifest, got %#v", manifest.Assets) + } + selected, err := SelectLayeredAsset(manifest, "windows/amd64", layeredAssetKindCoreRuntime, nil) + if err != nil { + t.Fatal(err) + } + if selected.ID != "rdev-host-windows-amd64" || + selected.Platform != "windows/amd64" || + selected.RelativePath != "assets/rdev-host-windows-amd64.exe" || + selected.Kind != layeredAssetKindCoreRuntime { + t.Fatalf("unexpected Windows core runtime selection: %#v", selected) + } + var coreSHA256 string + for _, artifact := range candidate.Artifacts { + if artifact.Name == "rdev-host-windows-amd64.exe" { + coreSHA256 = artifact.SHA256 + break + } + } + if coreSHA256 == "" || selected.SHA256 != "sha256:"+coreSHA256 { + t.Fatalf("selected runtime digest does not match staged core runtime: %q", selected.SHA256) + } + + var manifestFile CandidateFile + manifestFileCount := 0 + for _, file := range candidate.Files { + if file.Path == layeredAssetManifestPath { + manifestFile = file + manifestFileCount++ + } + } + if manifestFileCount != 1 || manifestFile.Kind != "layered-asset-manifest" || manifestFile.SHA256 == "" || manifestFile.SizeBytes <= 0 { + t.Fatalf("candidate files missing layered manifest metadata: %#v", manifestFile) + } + checksums, err := os.ReadFile(filepath.Join(out, candidate.ChecksumsPath)) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(checksums), " "+layeredAssetManifestPath+"\n") != 1 { + t.Fatalf("candidate checksums omitted layered manifest: %s", checksums) + } + + archiveEntries := readConnectionEntryArchiveForTest(t, filepath.Join(out, candidate.ConnectionEntryPath)) + bundleContent := readReleaseCandidateTestFile(t, filepath.Join(out, candidate.ReleaseBundlePath)) + sbomContent := readReleaseCandidateTestFile(t, filepath.Join(out, candidate.SBOMPath)) + for _, artifact := range []string{ + "rdev-host-windows-amd64.exe", + "rdev-host-windows-amd64.exe.bak", + "rdev-verify.exe", + } { + if !strings.Contains(bundleContent, `"name": "`+artifact+`"`) || !strings.Contains(sbomContent, `"fileName": "./`+artifact+`"`) { + t.Fatalf("bundle or SBOM omitted staged artifact %s", artifact) + } + } + for _, archivePath := range []string{ + "bin/rdev-bootstrap.exe", + "bin/rdev-bootstrap.exe.rdev-release.json", + "bin/rdev-verify.exe", + "bin/rdev-verify.exe.rdev-release.json", + } { + if _, ok := archiveEntries[archivePath]; !ok { + t.Fatalf("connection archive omitted bootstrap boundary artifact %s", archivePath) + } + } + for archivePath := range archiveEntries { + if strings.HasPrefix(archivePath, "bin/rdev-host") { + t.Fatalf("connection archive published legacy full helper artifact %s", archivePath) + } + } + if _, err := os.Stat(filepath.Join(out, rdevHostWindowsAMD64AssetName)); err != nil { + t.Fatalf("core runtime moved from existing root staging layout: %v", err) + } + if _, err := os.Stat(filepath.Join(out, "assets", rdevHostWindowsAMD64AssetName)); !os.IsNotExist(err) { + t.Fatalf("public asset URL path should not duplicate the staged runtime: %v", err) + } + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if !verification.OK() { + t.Fatalf("candidate with layered Windows runtime did not verify: %#v", verification.Checks) + } + + manifest.Assets[0].RelativePath = "assets/rdev-host-windows-amd64.exe.bak" + tamperedManifest, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(out, layeredAssetManifestPath) + if err := os.WriteFile(manifestPath, append(tamperedManifest, '\n'), 0o644); err != nil { + t.Fatal(err) + } + manifestSHA, manifestSize, err := fileDigest(manifestPath) + if err != nil { + t.Fatal(err) + } + filesForChecksums := make([]CandidateFile, 0, len(candidate.Files)-1) + for index := range candidate.Files { + switch candidate.Files[index].Path { + case layeredAssetManifestPath: + candidate.Files[index].SHA256 = "sha256:" + manifestSHA + candidate.Files[index].SizeBytes = manifestSize + case candidate.ChecksumsPath: + continue + } + filesForChecksums = append(filesForChecksums, candidate.Files[index]) + } + checksumEntry, err := writeCandidateChecksums(filepath.Join(out, candidate.ChecksumsPath), filesForChecksums) + if err != nil { + t.Fatal(err) + } + for index := range candidate.Files { + if candidate.Files[index].Path == candidate.ChecksumsPath { + candidate.Files[index] = checksumEntry + break + } + } + if err := writeCandidate(filepath.Join(out, "release-candidate.json"), candidate); err != nil { + t.Fatal(err) + } + tamperedVerification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if tamperedVerification.OK() { + t.Fatal("expected candidate verification to reject a tampered layered manifest even when unsigned checksums were updated") + } + if !strings.Contains(failedCandidateVerificationNames(tamperedVerification), "layered_asset_manifest_verified") { + t.Fatalf("expected layered signature failure, got %s", failedCandidateVerificationNames(tamperedVerification)) + } + + wrongRoot := candidate + wrongRoot.RootPublicKey = "other-root:" + strings.SplitN(candidate.RootPublicKey, ":", 2)[1] + if _, err := WriteLayeredAssetManifest(filepath.Join(out, "wrong-root-layered-assets.json"), wrongRoot, key, now); err == nil { + t.Fatal("expected layered manifest writer to reject a candidate root that does not match the signing key") + } + if err := os.WriteFile(filepath.Join(out, rdevHostWindowsAMD64AssetName), []byte("tampered-runtime"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := WriteLayeredAssetManifest(filepath.Join(out, "tampered-layered-assets.json"), candidate, key, now); err == nil { + t.Fatal("expected layered manifest writer to reject staged runtime metadata mismatch") + } +} + +func TestPrepareCandidateStagesWindowsAMD64CoreFromNormalHostArtifact(t *testing.T) { + input := t.TempDir() + out := filepath.Join(t.TempDir(), "candidate") + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + hostContent := "normal-windows-host-runtime" + host := writeCandidateArtifactForTest(t, input, "rdev-host.exe", hostContent) + + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + TargetPlatform: "windows/amd64", + ArtifactPaths: withWindowsBootstrapArtifactForTest(t, input, + host, + writeCandidateArtifactForTest(t, input, "rdev-verify.exe", "verify-binary"), + ), + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + + root, err := parseCandidateRootPublicKey(candidate.RootPublicKey) + if err != nil { + t.Fatal(err) + } + hostManifest, err := ReadManifest(filepath.Join(out, "rdev-host.exe.rdev-release.json")) + if err != nil { + t.Fatal(err) + } + if err := hostManifest.VerifyArtifact(filepath.Join(out, "rdev-host.exe"), root); err != nil { + t.Fatalf("normal Windows host artifact is not signed by the candidate root: %v", err) + } + + stagedAssetPath := filepath.Join(out, "assets", rdevHostWindowsAMD64AssetName) + stagedContent, err := os.ReadFile(stagedAssetPath) + if err != nil { + t.Fatal(err) + } + if string(stagedContent) != hostContent { + t.Fatalf("staged Windows core differs from normal host artifact: %q", stagedContent) + } + inputContent, err := os.ReadFile(host) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stagedContent, inputContent) { + t.Fatal("staged Windows core must be a byte-identical copy of rdev-host.exe") + } + + if candidate.LayeredAssetManifestPath != layeredAssetManifestPath { + t.Fatalf("unexpected layered asset manifest path %q", candidate.LayeredAssetManifestPath) + } + manifest := readLayeredAssetManifestForTest(t, filepath.Join(out, candidate.LayeredAssetManifestPath)) + if err := VerifyLayeredAssetManifest(manifest, root, now); err != nil { + t.Fatalf("layered asset manifest did not verify with candidate root: %v", err) + } + selected, err := SelectLayeredAsset(manifest, "windows/amd64", layeredAssetKindCoreRuntime, nil) + if err != nil { + t.Fatal(err) + } + assetSHA, assetSize, err := fileDigest(stagedAssetPath) + if err != nil { + t.Fatal(err) + } + if selected.ID != "rdev-host-windows-amd64" || + selected.Platform != "windows/amd64" || + selected.RelativePath != "assets/"+rdevHostWindowsAMD64AssetName || + selected.SHA256 != "sha256:"+assetSHA || + selected.SizeBytes != assetSize { + t.Fatalf("unexpected selected Windows core runtime: %#v", selected) + } + + stagedFileCount := 0 + for _, file := range candidate.Files { + if file.Path == "assets/"+rdevHostWindowsAMD64AssetName { + stagedFileCount++ + if file.SHA256 != "sha256:"+assetSHA || file.SizeBytes != assetSize { + t.Fatalf("staged candidate file metadata differs from the asset: %#v", file) + } + } + } + if stagedFileCount != 1 { + t.Fatalf("expected one staged canonical Windows core file, got %d", stagedFileCount) + } + for _, artifact := range candidate.Artifacts { + if artifact.Name == rdevHostWindowsAMD64AssetName || artifact.ArtifactPath == "assets/"+rdevHostWindowsAMD64AssetName { + t.Fatalf("staged canonical Windows core must not become a second full-archive artifact: %#v", artifact) + } + } + + archiveEntries := readConnectionEntryArchiveForTest(t, filepath.Join(out, candidate.ConnectionEntryPath)) + if _, ok := archiveEntries["bin/rdev-bootstrap.exe"]; !ok { + t.Fatal("connection archive omitted rdev-bootstrap.exe") + } + for path := range archiveEntries { + if strings.HasPrefix(path, "bin/rdev-host") { + t.Fatalf("connection archive published a full helper artifact at %q", path) + } + } + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if !verification.OK() { + t.Fatalf("platform-staged Windows candidate did not verify: %#v", verification.Checks) + } +} + +func TestVerifyCandidatePrefersExplicitWindowsCoreOverGenericHost(t *testing.T) { + input := t.TempDir() + out := filepath.Join(t.TempDir(), "candidate") + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + explicit := writeCandidateArtifactForTest(t, input, rdevHostWindowsAMD64AssetName, "explicit-windows-core") + generic := writeCandidateArtifactForTest(t, input, "rdev-host.exe", "generic-archive-host") + verifier := writeCandidateArtifactForTest(t, input, "rdev-verify.exe", "verify-binary") + + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + TargetPlatform: windowsAMD64TargetPlatform, + ArtifactPaths: withWindowsBootstrapArtifactForTest(t, input, explicit, generic, verifier), + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + manifest := readLayeredAssetManifestForTest(t, filepath.Join(out, candidate.LayeredAssetManifestPath)) + asset, err := SelectLayeredAsset(manifest, windowsAMD64TargetPlatform, layeredAssetKindCoreRuntime, nil) + if err != nil { + t.Fatal(err) + } + explicitArtifact, count := candidateArtifact(candidate, rdevHostWindowsAMD64AssetName) + if count != 1 || asset.SHA256 != "sha256:"+explicitArtifact.SHA256 { + t.Fatalf("layered manifest did not select the explicit Windows core: asset=%#v artifact=%#v", asset, explicitArtifact) + } + + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if !verification.OK() { + t.Fatalf("candidate verifier disagreed with explicit core selection: %s", failedCandidateVerificationNames(verification)) + } +} + +func TestWindowsConnectionEntryUsesBootstrapOnly(t *testing.T) { + input := t.TempDir() + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + rdev := writeCandidateArtifactForTest(t, input, "rdev.exe", "cli-binary") + host := writeCandidateArtifactForTest(t, input, "rdev-host.exe", "host-binary") + verify := writeCandidateArtifactForTest(t, input, "rdev-verify.exe", "verify-binary") + bootstrap := writeCandidateArtifactForTest(t, input, "rdev-bootstrap.exe", "bootstrap-binary") + + t.Run("layered prerequisites invoke the verified bootstrap", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "candidate") + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + TargetPlatform: windowsAMD64TargetPlatform, + ArtifactPaths: []string{rdev, host, verify, bootstrap}, + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + entries := readConnectionEntryArchiveForTest(t, filepath.Join(out, candidate.ConnectionEntryPath)) + launcher := string(entries["launchers/Start-ConnectionEntry.ps1"]) + for _, want := range []string{ + "rdev-bootstrap.exe", + "layered-run", + "layered-assets.json", + candidate.RootPublicKey, + "--expected-release-version", + candidate.Version, + "windows/amd64", + "LocalApplicationData", + "archive recovery profile", + "exit $layeredExitCode", + } { + if !strings.Contains(launcher, want) { + t.Fatalf("layered Windows launcher missing %q:\n%s", want, launcher) + } + } + for _, forbidden := range []string{"connection-entry run", "host serve", "rdev-host", "Get-Command rdev"} { + if strings.Contains(launcher, forbidden) { + t.Fatalf("Windows launcher retained legacy connection path %q:\n%s", forbidden, launcher) + } + } + if strings.Contains(launcher, "ArchiveFallback") { + t.Fatalf("release launcher retained a legacy archive fallback switch:\n%s", launcher) + } + for _, want := range []string{ + "[Guid]::NewGuid", + "[IO.FileMode]::CreateNew", + "CopyTo($privateBootstrapWriter)", + "Protect-PrivatePath $privateBootstrap", + "& $privateBootstrap layered-run", + } { + if !strings.Contains(launcher, want) { + t.Fatalf("archive launcher must execute a reverified private bootstrap copy; missing %q:\n%s", want, launcher) + } + } + if strings.Contains(launcher, "& $Bootstrap layered-run") { + t.Fatalf("archive launcher must not execute the original package path after verification:\n%s", launcher) + } + + extracted := t.TempDir() + bundlePath := "" + for archivePath, content := range entries { + path := filepath.Join(extracted, filepath.FromSlash(archivePath)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + if filepath.Base(archivePath) == "release-bundle.json" { + bundlePath = path + } + } + if bundlePath == "" { + t.Fatal("archive fallback omitted release-bundle.json") + } + root, err := parseCandidateRootPublicKey(candidate.RootPublicKey) + if err != nil { + t.Fatal(err) + } + verification, err := VerifyBundle(bundlePath, root, nil) + if err != nil { + t.Fatal(err) + } + if !verification.OK() { + t.Fatalf("archive fallback bundle does not verify from its packaged layout: %s", failedBundleCheckNames(verification)) + } + }) + + t.Run("missing bootstrap fails closed", func(t *testing.T) { + _, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: filepath.Join(t.TempDir(), "candidate"), + Version: "v0.2.0", + TargetPlatform: windowsAMD64TargetPlatform, + ArtifactPaths: []string{rdev, host, verify}, + Key: key, + Now: now, + }) + if err == nil || !strings.Contains(err.Error(), "exactly one rdev-bootstrap") { + t.Fatalf("missing bootstrap must fail closed, got %v", err) + } + }) + + t.Run("runner manifest supplies layered asset location", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "candidate") + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + ArtifactPaths: []string{rdev, host, verify, bootstrap}, + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + entries := readConnectionEntryArchiveForTest(t, filepath.Join(out, candidate.ConnectionEntryPath)) + launcher := string(entries["launchers/Start-ConnectionEntry.ps1"]) + if !strings.Contains(launcher, "layered-run") || strings.Contains(launcher, "connection-entry run") { + t.Fatalf("Windows release launcher must remain bootstrap-only:\n%s", launcher) + } + }) +} + +func TestVerifyCandidateRequiresLayeredManifestForSignedWindowsCore(t *testing.T) { + out, candidate, _, now := prepareWindowsLayeredCandidateForTest(t) + root, err := parseCandidateRootPublicKey(candidate.RootPublicKey) + if err != nil { + t.Fatal(err) + } + bundleVerification, err := VerifyBundle(filepath.Join(out, candidate.ReleaseBundlePath), root, nil) + if err != nil || !bundleVerification.OK() { + t.Fatalf("signed release bundle should verify: %#v, %v", bundleVerification.Checks, err) + } + signedCorePresent := false + for _, artifact := range bundleVerification.Artifacts { + if artifact.Name == rdevHostWindowsAMD64AssetName && artifact.Artifact == rdevHostWindowsAMD64AssetName { + signedCorePresent = true + break + } + } + if !signedCorePresent { + t.Fatal("test fixture signed bundle does not contain the Windows core runtime") + } + + downgraded := candidate + downgraded.LayeredAssetManifestPath = "" + downgraded.Files = make([]CandidateFile, 0, len(candidate.Files)-1) + for _, file := range candidate.Files { + if file.Path != layeredAssetManifestPath { + downgraded.Files = append(downgraded.Files, file) + } + } + if err := os.Remove(filepath.Join(out, layeredAssetManifestPath)); err != nil { + t.Fatal(err) + } + rewriteCandidateChecksumsForTest(t, out, &downgraded) + + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if verification.OK() { + t.Fatal("expected signed Windows core bundle to require its layered manifest despite unsigned metadata removal") + } + if !strings.Contains(failedCandidateVerificationNames(verification), "layered_asset_manifest_path") { + t.Fatalf("expected missing layered manifest path failure, got %s", failedCandidateVerificationNames(verification)) + } +} + +func TestVerifyCandidateRejectsWindowsLayeredDowngradeWithUnsignedMetadataCleared(t *testing.T) { + input := t.TempDir() + out := filepath.Join(t.TempDir(), "candidate") + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + TargetPlatform: "windows/amd64", + ArtifactPaths: withWindowsBootstrapArtifactForTest(t, input, + writeCandidateArtifactForTest(t, input, "rdev-host.exe", "windows-core-runtime"), + writeCandidateArtifactForTest(t, input, "rdev-verify.exe", "verify-binary"), + ), + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + bundle, err := ReadBundle(filepath.Join(out, candidate.ReleaseBundlePath)) + if err != nil { + t.Fatal(err) + } + if bundle.Version != candidate.Version || bundle.TargetPlatform != candidate.TargetPlatform { + t.Fatalf("prepared candidate bundle omitted signed version/platform: %#v", bundle) + } + for _, artifact := range candidate.Artifacts { + manifest, err := ReadManifest(filepath.Join(out, artifact.ManifestPath)) + if err != nil { + t.Fatal(err) + } + if manifest.ReleaseVersion != candidate.Version || manifest.TargetPlatform != candidate.TargetPlatform { + t.Fatalf("artifact %s omitted signed version/platform: %#v", artifact.Name, manifest) + } + } + + downgraded := candidate + downgraded.TargetPlatform = "" + downgraded.LayeredAssetManifestPath = "" + if err := writeCandidate(filepath.Join(out, "release-candidate.json"), downgraded); err != nil { + t.Fatal(err) + } + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if verification.OK() { + t.Fatal("clearing unsigned candidate target/layered metadata bypassed signed Windows layered enforcement") + } + failed := failedCandidateVerificationNames(verification) + if !strings.Contains(failed, "signed_bundle_target_platform_matches_candidate") || + !strings.Contains(failed, "layered_asset_manifest_path") { + t.Fatalf("expected signed target and layered path downgrade checks, got %s", failed) + } +} + +func TestVerifyCandidateRejectsLayeredDeclarationWithoutSignedWindowsCore(t *testing.T) { + input := t.TempDir() + out := filepath.Join(t.TempDir(), "candidate") + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + ArtifactPaths: withBootstrapArtifactForTest(t, input, + writeCandidateArtifactForTest(t, input, "rdev-host.exe", "legacy-host-runtime"), + writeCandidateArtifactForTest(t, input, "rdev-verify", "verify-binary"), + ), + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + declared := candidate + declared.LayeredAssetManifestPath = layeredAssetManifestPath + if err := writeCandidate(filepath.Join(out, "release-candidate.json"), declared); err != nil { + t.Fatal(err) + } + + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if verification.OK() { + t.Fatal("expected layered declaration without a signed Windows core artifact to fail verification") + } + if !strings.Contains(failedCandidateVerificationNames(verification), "layered_asset_manifest_has_signed_windows_core") { + t.Fatalf("expected signed Windows core requirement failure, got %s", failedCandidateVerificationNames(verification)) + } +} + +func TestVerifyCandidateRejectsSignedLayeredManifestWithExtraAsset(t *testing.T) { + out, candidate, key, now := prepareWindowsLayeredCandidateForTest(t) + manifest := readLayeredAssetManifestForTest(t, filepath.Join(out, layeredAssetManifestPath)) + var helper CandidateArtifact + for _, artifact := range candidate.Artifacts { + if artifact.Name == "rdev-verify.exe" { + helper = artifact + break + } + } + manifest.Assets = append(manifest.Assets, LayeredAsset{ + ID: "rdev-verify-windows-amd64", + Platform: "windows/amd64", + Kind: layeredAssetKindOptionalHelper, + RelativePath: "assets/rdev-verify.exe", + SHA256: "sha256:" + helper.SHA256, + SizeBytes: helper.SizeBytes, + }) + signed, err := SignLayeredAssetManifest(manifest, key) + if err != nil { + t.Fatal(err) + } + rewritten := candidate + rewritten.Files = append([]CandidateFile(nil), candidate.Files...) + writeLayeredAssetManifestForTest(t, filepath.Join(out, layeredAssetManifestPath), signed) + refreshCandidateFileForTest(t, out, &rewritten, layeredAssetManifestPath) + rewriteCandidateChecksumsForTest(t, out, &rewritten) + + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if verification.OK() { + t.Fatal("expected candidate verification to reject a signed layered manifest with an extra asset") + } + if !strings.Contains(failedCandidateVerificationNames(verification), "layered_asset_manifest_windows_core_contract") { + t.Fatalf("expected exact layered manifest contract failure, got %s", failedCandidateVerificationNames(verification)) + } +} + +func TestVerifyCandidateRejectsLayeredManifestJSONAmbiguity(t *testing.T) { + for _, name := range []string{"unknown field", "duplicate key"} { + t.Run(name, func(t *testing.T) { + out, candidate, _, now := prepareWindowsLayeredCandidateForTest(t) + manifestPath := filepath.Join(out, layeredAssetManifestPath) + content, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + content = bytes.TrimSpace(content) + switch name { + case "unknown field": + content = append(append([]byte(nil), content[:len(content)-1]...), []byte(`,"unsigned_field":true}`)...) + case "duplicate key": + content = append([]byte(`{"version":"v0.2.0",`), content[1:]...) + } + if err := os.WriteFile(manifestPath, append(content, '\n'), 0o644); err != nil { + t.Fatal(err) + } + rewritten := candidate + rewritten.Files = append([]CandidateFile(nil), candidate.Files...) + refreshCandidateFileForTest(t, out, &rewritten, layeredAssetManifestPath) + rewriteCandidateChecksumsForTest(t, out, &rewritten) + + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if verification.OK() || !strings.Contains(failedCandidateVerificationNames(verification), "layered_asset_manifest_json_valid") { + t.Fatalf("expected ambiguous layered manifest JSON failure, got %s", failedCandidateVerificationNames(verification)) + } + }) + } +} + +func TestVerifyCandidateRejectsDuplicateLayeredManifestChecksum(t *testing.T) { + out, candidate, _, now := prepareWindowsLayeredCandidateForTest(t) + checksumsPath := filepath.Join(out, candidate.ChecksumsPath) + content, err := os.ReadFile(checksumsPath) + if err != nil { + t.Fatal(err) + } + var layeredLine string + for _, line := range strings.Split(strings.TrimSpace(string(content)), "\n") { + if strings.HasSuffix(line, " "+layeredAssetManifestPath) { + layeredLine = line + break + } + } + if layeredLine == "" { + t.Fatal("layered manifest checksum line missing from fixture") + } + if err := os.WriteFile(checksumsPath, append(content, []byte(layeredLine+"\n")...), 0o644); err != nil { + t.Fatal(err) + } + rewritten := candidate + rewritten.Files = append([]CandidateFile(nil), candidate.Files...) + refreshCandidateFileForTest(t, out, &rewritten, candidate.ChecksumsPath) + if err := writeCandidate(filepath.Join(out, "release-candidate.json"), rewritten); err != nil { + t.Fatal(err) + } + + verification, err := VerifyCandidate(CandidateVerifyOptions{CandidatePath: out, GeneratedAt: now}) + if err != nil { + t.Fatal(err) + } + if verification.OK() { + t.Fatal("expected duplicate layered manifest checksum line to fail verification") + } + if !strings.Contains(failedCandidateVerificationNames(verification), "checksums_paths_unique") { + t.Fatalf("expected duplicate checksum path failure, got %s", failedCandidateVerificationNames(verification)) + } +} + func TestVerifyCandidatePassesAfterDirectoryMove(t *testing.T) { input := t.TempDir() root := t.TempDir() @@ -176,7 +975,7 @@ func TestVerifyCandidatePassesAfterDirectoryMove(t *testing.T) { OutDir: out, Version: "v0.1.0", GatewayURL: "https://api.example.com/v1", - ArtifactPaths: []string{rdev, host, verify}, + ArtifactPaths: withBootstrapArtifactForTest(t, input, rdev, host, verify), RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, Key: key, Now: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC), @@ -220,7 +1019,7 @@ func TestVerifyCandidateDetectsTamperedArtifact(t *testing.T) { OutDir: out, Version: "v0.1.0", GatewayURL: "https://api.example.com/v1", - ArtifactPaths: []string{rdev, host, verify}, + ArtifactPaths: withBootstrapArtifactForTest(t, input, rdev, host, verify), RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, Key: key, Now: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC), @@ -264,7 +1063,7 @@ func TestVerifyCandidateRejectsUnlistedFiles(t *testing.T) { OutDir: out, Version: "v0.1.0", GatewayURL: "https://api.example.com/v1", - ArtifactPaths: []string{rdev, host, verify}, + ArtifactPaths: withBootstrapArtifactForTest(t, input, rdev, host, verify), RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, Key: key, Now: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC), @@ -304,7 +1103,7 @@ func TestVerifyCandidateDetectsTamperedSBOM(t *testing.T) { OutDir: out, Version: "v0.1.0", GatewayURL: "https://api.example.com/v1", - ArtifactPaths: []string{rdev, host, verify}, + ArtifactPaths: withBootstrapArtifactForTest(t, input, rdev, host, verify), RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, Key: key, Now: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC), @@ -351,7 +1150,7 @@ func TestVerifyCandidateDetectsTamperedProvenance(t *testing.T) { OutDir: out, Version: "v0.1.0", GatewayURL: "https://api.example.com/v1", - ArtifactPaths: []string{rdev, host, verify}, + ArtifactPaths: withBootstrapArtifactForTest(t, input, rdev, host, verify), RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, Key: key, Now: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC), @@ -398,7 +1197,7 @@ func TestVerifyCandidateDetectsTamperedConnectionEntryReleaseArchive(t *testing. OutDir: out, Version: "v0.1.0", GatewayURL: "https://api.example.com/v1", - ArtifactPaths: []string{rdev, host, verify}, + ArtifactPaths: withBootstrapArtifactForTest(t, input, rdev, host, verify), RequiredArtifacts: []string{"rdev-host.exe", "rdev-verify.exe"}, Key: key, Now: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC), @@ -473,6 +1272,96 @@ func failedCandidateVerificationNames(verification CandidateVerification) string return strings.Join(failed, ",") } +func prepareWindowsLayeredCandidateForTest(t *testing.T) (string, Candidate, signing.Key, time.Time) { + t.Helper() + input := t.TempDir() + out := filepath.Join(t.TempDir(), "candidate") + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + candidate, err := PrepareCandidate(CandidateOptions{ + SourceRoot: filepath.Join("..", ".."), + OutDir: out, + Version: "v0.2.0", + ArtifactPaths: withWindowsBootstrapArtifactForTest(t, input, + writeCandidateArtifactForTest(t, input, rdevHostWindowsAMD64AssetName, "windows-core-runtime"), + writeCandidateArtifactForTest(t, input, "rdev-verify.exe", "verify-binary"), + ), + Key: key, + Now: now, + }) + if err != nil { + t.Fatal(err) + } + return out, candidate, key, now +} + +func readLayeredAssetManifestForTest(t *testing.T, path string) LayeredAssetManifest { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var manifest LayeredAssetManifest + if err := json.Unmarshal(content, &manifest); err != nil { + t.Fatal(err) + } + return manifest +} + +func writeLayeredAssetManifestForTest(t *testing.T, path string, manifest LayeredAssetManifest) { + t.Helper() + content, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(content, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} + +func refreshCandidateFileForTest(t *testing.T, out string, candidate *Candidate, path string) { + t.Helper() + sha, size, err := fileDigest(filepath.Join(out, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + for index := range candidate.Files { + if candidate.Files[index].Path == path { + candidate.Files[index].SHA256 = "sha256:" + sha + candidate.Files[index].SizeBytes = size + return + } + } + t.Fatalf("candidate file %s not found", path) +} + +func rewriteCandidateChecksumsForTest(t *testing.T, out string, candidate *Candidate) { + t.Helper() + files := make([]CandidateFile, 0, len(candidate.Files)-1) + for _, file := range candidate.Files { + if file.Path != candidate.ChecksumsPath { + files = append(files, file) + } + } + checksumEntry, err := writeCandidateChecksums(filepath.Join(out, candidate.ChecksumsPath), files) + if err != nil { + t.Fatal(err) + } + for index := range candidate.Files { + if candidate.Files[index].Path == candidate.ChecksumsPath { + candidate.Files[index] = checksumEntry + if err := writeCandidate(filepath.Join(out, "release-candidate.json"), *candidate); err != nil { + t.Fatal(err) + } + return + } + } + t.Fatal("candidate checksums file metadata not found") +} + func writeCandidateArtifactForTest(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) @@ -482,6 +1371,18 @@ func writeCandidateArtifactForTest(t *testing.T, dir, name, content string) stri return path } +func withBootstrapArtifactForTest(t *testing.T, dir string, paths ...string) []string { + t.Helper() + bootstrap := writeCandidateArtifactForTest(t, dir, "rdev-bootstrap", "bootstrap-binary") + return append(append([]string(nil), paths...), bootstrap) +} + +func withWindowsBootstrapArtifactForTest(t *testing.T, dir string, paths ...string) []string { + t.Helper() + bootstrap := writeCandidateArtifactForTest(t, dir, "rdev-bootstrap.exe", "bootstrap-binary") + return append(append([]string(nil), paths...), bootstrap) +} + func readReleaseCandidateTestFile(t *testing.T, path string) string { t.Helper() content, err := os.ReadFile(path) diff --git a/internal/release/canonical_time_other.go b/internal/release/canonical_time_other.go new file mode 100644 index 0000000..61e23bc --- /dev/null +++ b/internal/release/canonical_time_other.go @@ -0,0 +1,12 @@ +//go:build !rdev_bootstrap_focused + +package release + +import "time" + +// IsCanonicalUTCTimestamp reports whether value is the canonical UTC form used +// by signed layered manifests and local layered-attempt state. +func IsCanonicalUTCTimestamp(value string) bool { + parsed, err := time.Parse(time.RFC3339Nano, value) + return err == nil && parsed.UTC().Format(time.RFC3339Nano) == value +} diff --git a/internal/release/focused_errors.go b/internal/release/focused_errors.go new file mode 100644 index 0000000..f7ed81e --- /dev/null +++ b/internal/release/focused_errors.go @@ -0,0 +1,10 @@ +//go:build rdev_bootstrap_focused + +package release + +import "errors" + +var ( + ErrManifestInvalid = errors.New("release manifest invalid") + ErrManifestSignature = errors.New("release manifest signature invalid") +) diff --git a/internal/release/layered_asset_path.go b/internal/release/layered_asset_path.go new file mode 100644 index 0000000..6f662a6 --- /dev/null +++ b/internal/release/layered_asset_path.go @@ -0,0 +1,24 @@ +//go:build !rdev_bootstrap_focused + +package release + +import ( + "net/url" + "path" + "strings" +) + +func validRelativeAssetPath(value string) bool { + u, err := url.Parse(value) + if err != nil || value == "" || u.IsAbs() || path.IsAbs(u.Path) || u.RawQuery != "" || u.Fragment != "" || u.ForceQuery || strings.Contains(value, "#") { + return false + } + if strings.Contains(value, "\\") || strings.HasPrefix(value, "/") { + return false + } + clean := path.Clean(value) + decodedClean := path.Clean(u.Path) + return clean == value && clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") && + decodedClean == u.Path && decodedClean != "." && decodedClean != ".." && + !strings.HasPrefix(decodedClean, "../") && !strings.Contains(u.Path, "\\") +} diff --git a/internal/release/layered_asset_path_focused.go b/internal/release/layered_asset_path_focused.go new file mode 100644 index 0000000..25e8dc8 --- /dev/null +++ b/internal/release/layered_asset_path_focused.go @@ -0,0 +1,15 @@ +//go:build rdev_bootstrap_focused + +package release + +import ( + "path" + "strings" +) + +func validRelativeAssetPath(value string) bool { + clean := path.Clean(value) + return value != "" && clean == value && clean != "." && clean != ".." && + !strings.HasPrefix(clean, "../") && !strings.HasPrefix(value, "/") && + !strings.ContainsAny(value, "\\?#:%") +} diff --git a/internal/release/layered_assets.go b/internal/release/layered_assets.go new file mode 100644 index 0000000..08f266c --- /dev/null +++ b/internal/release/layered_assets.go @@ -0,0 +1,218 @@ +package release + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + "time" +) + +const LayeredAssetManifestSchemaVersion = "rdev.layered-assets.v1" + +const ( + layeredAssetKindCoreRuntime = "core-runtime" + layeredAssetKindOptionalHelper = "optional-helper" +) + +type LayeredAssetManifest struct { + SchemaVersion string `json:"schema_version"` + Version string `json:"version"` + GeneratedAt time.Time `json:"generated_at"` + ExpiresAt time.Time `json:"expires_at"` + SigningKeyID string `json:"signing_key_id"` + Assets []LayeredAsset `json:"assets"` + Signature string `json:"signature"` +} + +type LayeredAsset struct { + ID string `json:"id"` + Platform string `json:"platform"` + Kind string `json:"kind"` + RelativePath string `json:"relative_path"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + Capabilities []string `json:"capabilities,omitempty"` +} + +type LayeredTrustRoot struct { + SigningKeyID string + PublicKey ed25519.PublicKey +} + +func ParseLayeredTrustRoot(value string) (LayeredTrustRoot, error) { + parts := strings.SplitN(strings.TrimSpace(value), ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return LayeredTrustRoot{}, fmt.Errorf("trust root public key must be formatted key_id:base64url_public_key") + } + publicKey, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return LayeredTrustRoot{}, fmt.Errorf("invalid trust root public key") + } + return LayeredTrustRoot{SigningKeyID: parts[0], PublicKey: append(ed25519.PublicKey(nil), publicKey...)}, nil +} + +func VerifyLayeredAssetManifestRoot(manifest LayeredAssetManifest, root LayeredTrustRoot, now time.Time) error { + if err := validateLayeredAssetManifest(manifest); err != nil { + return err + } + if len(root.PublicKey) != ed25519.PublicKeySize { + return fmt.Errorf("%w: layered asset manifest trust root is invalid", ErrManifestInvalid) + } + return verifyLayeredAssetManifest(manifest, root, now) +} + +func verifyLayeredAssetManifest(manifest LayeredAssetManifest, root LayeredTrustRoot, now time.Time) error { + if manifest.Signature == "" { + return fmt.Errorf("%w: layered asset manifest signature is required", ErrManifestSignature) + } + if now.IsZero() { + return fmt.Errorf("%w: layered asset manifest verification time is required", ErrManifestInvalid) + } + verificationTime := now.UTC() + if manifest.GeneratedAt.After(verificationTime) { + return fmt.Errorf("%w: layered asset manifest generated_at is in the future", ErrManifestInvalid) + } + if !verificationTime.Before(manifest.ExpiresAt) { + return fmt.Errorf("%w: layered asset manifest expired", ErrManifestInvalid) + } + if root.SigningKeyID != manifest.SigningKeyID { + return fmt.Errorf("%w: layered asset manifest trust root key id mismatch", ErrManifestInvalid) + } + signature, err := base64.RawURLEncoding.DecodeString(manifest.Signature) + if err != nil || len(signature) != ed25519.SignatureSize { + return fmt.Errorf("%w: malformed layered asset manifest signature", ErrManifestSignature) + } + message, err := canonicalUnsignedLayeredAssetManifest(manifest) + if err != nil { + return err + } + if !ed25519.Verify(root.PublicKey, message, signature) { + return fmt.Errorf("%w: layered asset manifest signature mismatch", ErrManifestSignature) + } + return nil +} + +func SelectLayeredAsset(manifest LayeredAssetManifest, platform, kind string, capabilities []string) (LayeredAsset, error) { + if err := validateLayeredAssetManifest(manifest); err != nil { + return LayeredAsset{}, err + } + if strings.TrimSpace(platform) == "" || !validLayeredAssetKind(kind) { + return LayeredAsset{}, fmt.Errorf("%w: invalid layered asset selection", ErrManifestInvalid) + } + + matches := make([]LayeredAsset, 0, 1) + for _, asset := range manifest.Assets { + if asset.Platform == platform && asset.Kind == kind && hasLayeredAssetCapabilities(asset, capabilities) { + matches = append(matches, cloneLayeredAsset(asset)) + } + } + if len(matches) != 1 { + return LayeredAsset{}, fmt.Errorf("%w: expected one matching layered asset, found %d", ErrManifestInvalid, len(matches)) + } + return matches[0], nil +} + +func validateLayeredAssetManifest(manifest LayeredAssetManifest) error { + if manifest.SchemaVersion != LayeredAssetManifestSchemaVersion { + return fmt.Errorf("%w: unsupported layered asset manifest schema version", ErrManifestInvalid) + } + if strings.TrimSpace(manifest.Version) == "" { + return fmt.Errorf("%w: layered asset manifest version is required", ErrManifestInvalid) + } + if manifest.GeneratedAt.IsZero() { + return fmt.Errorf("%w: layered asset manifest generated_at is required", ErrManifestInvalid) + } + if manifest.ExpiresAt.IsZero() || !manifest.GeneratedAt.Before(manifest.ExpiresAt) { + return fmt.Errorf("%w: layered asset manifest validity window is invalid", ErrManifestInvalid) + } + if strings.TrimSpace(manifest.SigningKeyID) == "" { + return fmt.Errorf("%w: layered asset manifest signing key id is required", ErrManifestInvalid) + } + if len(manifest.Assets) == 0 { + return fmt.Errorf("%w: layered asset manifest has no assets", ErrManifestInvalid) + } + + seenIDs := make(map[string]struct{}, len(manifest.Assets)) + corePlatforms := make(map[string]struct{}) + for _, asset := range manifest.Assets { + if err := validateLayeredAsset(asset); err != nil { + return err + } + if _, exists := seenIDs[asset.ID]; exists { + return fmt.Errorf("%w: duplicate layered asset id %q", ErrManifestInvalid, asset.ID) + } + seenIDs[asset.ID] = struct{}{} + if asset.Kind == layeredAssetKindCoreRuntime { + if _, exists := corePlatforms[asset.Platform]; exists { + return fmt.Errorf("%w: duplicate core runtime for platform %q", ErrManifestInvalid, asset.Platform) + } + corePlatforms[asset.Platform] = struct{}{} + } + } + if len(corePlatforms) == 0 { + return fmt.Errorf("%w: layered asset manifest requires a core runtime", ErrManifestInvalid) + } + return nil +} + +func validateLayeredAsset(asset LayeredAsset) error { + if strings.TrimSpace(asset.ID) == "" || strings.TrimSpace(asset.Platform) == "" { + return fmt.Errorf("%w: layered asset identity is required", ErrManifestInvalid) + } + if !validLayeredAssetKind(asset.Kind) { + return fmt.Errorf("%w: unsupported layered asset kind %q", ErrManifestInvalid, asset.Kind) + } + if !validRelativeAssetPath(asset.RelativePath) { + return fmt.Errorf("%w: invalid layered asset relative path", ErrManifestInvalid) + } + if !validLayeredAssetSHA256(asset.SHA256) { + return fmt.Errorf("%w: invalid layered asset sha256", ErrManifestInvalid) + } + if asset.SizeBytes <= 0 { + return fmt.Errorf("%w: layered asset size must be positive", ErrManifestInvalid) + } + return nil +} + +func cloneLayeredAssetManifest(manifest LayeredAssetManifest) LayeredAssetManifest { + cloned := manifest + cloned.Assets = make([]LayeredAsset, len(manifest.Assets)) + for index, asset := range manifest.Assets { + cloned.Assets[index] = cloneLayeredAsset(asset) + } + return cloned +} + +func cloneLayeredAsset(asset LayeredAsset) LayeredAsset { + cloned := asset + cloned.Capabilities = append([]string(nil), asset.Capabilities...) + return cloned +} + +func validLayeredAssetKind(kind string) bool { + return kind == layeredAssetKindCoreRuntime || kind == layeredAssetKindOptionalHelper +} + +func validLayeredAssetSHA256(value string) bool { + const prefix = "sha256:" + if !strings.HasPrefix(value, prefix) || len(value) != len(prefix)+64 || value != strings.ToLower(value) { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, prefix)) + return err == nil +} + +func hasLayeredAssetCapabilities(asset LayeredAsset, required []string) bool { + available := make(map[string]struct{}, len(asset.Capabilities)) + for _, capability := range asset.Capabilities { + available[capability] = struct{}{} + } + for _, capability := range required { + if _, ok := available[capability]; !ok { + return false + } + } + return true +} diff --git a/internal/release/layered_assets_codec.go b/internal/release/layered_assets_codec.go new file mode 100644 index 0000000..941c842 --- /dev/null +++ b/internal/release/layered_assets_codec.go @@ -0,0 +1,20 @@ +//go:build !rdev_bootstrap_focused + +package release + +import ( + "encoding/json" + "sort" +) + +func canonicalUnsignedLayeredAssetManifest(manifest LayeredAssetManifest) ([]byte, error) { + canonical := cloneLayeredAssetManifest(manifest) + canonical.Signature = "" + sort.Slice(canonical.Assets, func(i, j int) bool { + return canonical.Assets[i].ID < canonical.Assets[j].ID + }) + for index := range canonical.Assets { + sort.Strings(canonical.Assets[index].Capabilities) + } + return json.Marshal(canonical) +} diff --git a/internal/release/layered_assets_codec_focused.go b/internal/release/layered_assets_codec_focused.go new file mode 100644 index 0000000..215455e --- /dev/null +++ b/internal/release/layered_assets_codec_focused.go @@ -0,0 +1,568 @@ +//go:build rdev_bootstrap_focused + +package release + +import ( + "fmt" + "strconv" + "time" + "unicode/utf16" + "unicode/utf8" +) + +const ( + maxFocusedLayeredAssets = 64 + maxFocusedLayeredCapabilities = 64 +) + +func DecodeLayeredAssetManifest(content []byte) (LayeredAssetManifest, error) { + parser := focusedJSONParser{content: content} + manifest, err := parser.manifest() + if err != nil { + return LayeredAssetManifest{}, fmt.Errorf("decode layered asset manifest: %w", err) + } + parser.space() + if parser.offset != len(parser.content) { + return LayeredAssetManifest{}, fmt.Errorf("layered asset manifest contains trailing JSON") + } + return manifest, nil +} + +func canonicalUnsignedLayeredAssetManifest(manifest LayeredAssetManifest) ([]byte, error) { + canonical := cloneLayeredAssetManifest(manifest) + canonical.Signature = "" + for index := 1; index < len(canonical.Assets); index++ { + for current := index; current > 0 && canonical.Assets[current].ID < canonical.Assets[current-1].ID; current-- { + canonical.Assets[current], canonical.Assets[current-1] = canonical.Assets[current-1], canonical.Assets[current] + } + } + for index := range canonical.Assets { + values := canonical.Assets[index].Capabilities + for item := 1; item < len(values); item++ { + for current := item; current > 0 && values[current] < values[current-1]; current-- { + values[current], values[current-1] = values[current-1], values[current] + } + } + } + + encoded := make([]byte, 0, 512) + encoded = append(encoded, '{') + encoded = appendJSONField(encoded, "schema_version", canonical.SchemaVersion, false) + encoded = appendJSONField(encoded, "version", canonical.Version, true) + encoded = append(encoded, `,"generated_at":`...) + var err error + encoded, err = appendCanonicalUTC(encoded, canonical.GeneratedAt) + if err != nil { + return nil, err + } + encoded = append(encoded, `,"expires_at":`...) + encoded, err = appendCanonicalUTC(encoded, canonical.ExpiresAt) + if err != nil { + return nil, err + } + encoded = appendJSONField(encoded, "signing_key_id", canonical.SigningKeyID, true) + encoded = append(encoded, `,"assets":[`...) + for index, asset := range canonical.Assets { + if index > 0 { + encoded = append(encoded, ',') + } + encoded = append(encoded, '{') + encoded = appendJSONField(encoded, "id", asset.ID, false) + encoded = appendJSONField(encoded, "platform", asset.Platform, true) + encoded = appendJSONField(encoded, "kind", asset.Kind, true) + encoded = appendJSONField(encoded, "relative_path", asset.RelativePath, true) + encoded = appendJSONField(encoded, "sha256", asset.SHA256, true) + encoded = append(encoded, `,"size_bytes":`...) + encoded = strconv.AppendInt(encoded, asset.SizeBytes, 10) + if len(asset.Capabilities) > 0 { + encoded = append(encoded, `,"capabilities":[`...) + for capabilityIndex, capability := range asset.Capabilities { + if capabilityIndex > 0 { + encoded = append(encoded, ',') + } + encoded = appendJSONString(encoded, capability) + } + encoded = append(encoded, ']') + } + encoded = append(encoded, '}') + } + encoded = append(encoded, ']') + encoded = appendJSONField(encoded, "signature", "", true) + encoded = append(encoded, '}') + return encoded, nil +} + +func appendJSONField(destination []byte, name, value string, comma bool) []byte { + if comma { + destination = append(destination, ',') + } + destination = appendJSONString(destination, name) + destination = append(destination, ':') + return appendJSONString(destination, value) +} + +func appendJSONString(destination []byte, value string) []byte { + const hex = "0123456789abcdef" + destination = append(destination, '"') + for _, character := range value { + switch character { + case '\\', '"': + destination = append(destination, '\\', byte(character)) + case '\b': + destination = append(destination, `\b`...) + case '\f': + destination = append(destination, `\f`...) + case '\n': + destination = append(destination, `\n`...) + case '\r': + destination = append(destination, `\r`...) + case '\t': + destination = append(destination, `\t`...) + default: + if character < 0x20 || character == '<' || character == '>' || character == '&' || character == '\u2028' || character == '\u2029' { + destination = append(destination, `\u`...) + destination = append(destination, hex[(character>>12)&15], hex[(character>>8)&15], hex[(character>>4)&15], hex[character&15]) + } else { + destination = utf8.AppendRune(destination, character) + } + } + } + return append(destination, '"') +} + +func appendCanonicalUTC(destination []byte, value time.Time) ([]byte, error) { + value = value.UTC() + year, month, day := value.Date() + if year < 0 || year > 9999 { + return nil, fmt.Errorf("layered manifest timestamp is outside RFC3339 range") + } + hour, minute, second := value.Clock() + destination = append(destination, '"') + destination = appendFixedDecimal(destination, year, 4) + destination = append(destination, '-') + destination = appendFixedDecimal(destination, int(month), 2) + destination = append(destination, '-') + destination = appendFixedDecimal(destination, day, 2) + destination = append(destination, 'T') + destination = appendFixedDecimal(destination, hour, 2) + destination = append(destination, ':') + destination = appendFixedDecimal(destination, minute, 2) + destination = append(destination, ':') + destination = appendFixedDecimal(destination, second, 2) + if nanosecond := value.Nanosecond(); nanosecond != 0 { + fraction := [9]byte{} + for index := len(fraction) - 1; index >= 0; index-- { + fraction[index] = byte(nanosecond%10) + '0' + nanosecond /= 10 + } + end := len(fraction) + for fraction[end-1] == '0' { + end-- + } + destination = append(destination, '.') + destination = append(destination, fraction[:end]...) + } + return append(destination, 'Z', '"'), nil +} + +func appendFixedDecimal(destination []byte, value, width int) []byte { + start := len(destination) + destination = append(destination, make([]byte, width)...) + for index := start + width - 1; index >= start; index-- { + destination[index] = byte(value%10) + '0' + value /= 10 + } + return destination +} + +type focusedJSONParser struct { + content []byte + offset int +} + +func (parser *focusedJSONParser) manifest() (LayeredAssetManifest, error) { + if err := parser.byte('{'); err != nil { + return LayeredAssetManifest{}, err + } + var manifest LayeredAssetManifest + var seen uint8 + for { + parser.space() + if parser.take('}') { + break + } + name, err := parser.string() + if err != nil || parser.byte(':') != nil { + return LayeredAssetManifest{}, fmt.Errorf("invalid manifest field") + } + bit, err := parser.manifestField(name, &manifest) + if err != nil || seen&bit != 0 { + return LayeredAssetManifest{}, fmt.Errorf("invalid or duplicate manifest field %q", name) + } + seen |= bit + parser.space() + if parser.take('}') { + break + } + if err := parser.byte(','); err != nil { + return LayeredAssetManifest{}, err + } + } + if seen != 0x7f { + return LayeredAssetManifest{}, fmt.Errorf("missing manifest field") + } + return manifest, nil +} + +func (parser *focusedJSONParser) manifestField(name string, manifest *LayeredAssetManifest) (uint8, error) { + switch name { + case "schema_version": + return 1 << 0, parser.assignString(&manifest.SchemaVersion) + case "version": + return 1 << 1, parser.assignString(&manifest.Version) + case "generated_at": + return 1 << 2, parser.assignTime(&manifest.GeneratedAt) + case "expires_at": + return 1 << 3, parser.assignTime(&manifest.ExpiresAt) + case "signing_key_id": + return 1 << 4, parser.assignString(&manifest.SigningKeyID) + case "assets": + assets, err := parser.assets() + manifest.Assets = assets + return 1 << 5, err + case "signature": + return 1 << 6, parser.assignString(&manifest.Signature) + default: + return 0, fmt.Errorf("unknown manifest field") + } +} + +func (parser *focusedJSONParser) assets() ([]LayeredAsset, error) { + if err := parser.byte('['); err != nil { + return nil, err + } + var assets []LayeredAsset + for { + parser.space() + if parser.take(']') { + return assets, nil + } + if len(assets) >= maxFocusedLayeredAssets { + return nil, fmt.Errorf("layered asset manifest has too many assets") + } + asset, err := parser.asset() + if err != nil { + return nil, err + } + assets = append(assets, asset) + parser.space() + if parser.take(']') { + return assets, nil + } + if err := parser.byte(','); err != nil { + return nil, err + } + } +} + +func (parser *focusedJSONParser) asset() (LayeredAsset, error) { + if err := parser.byte('{'); err != nil { + return LayeredAsset{}, err + } + var asset LayeredAsset + var seen uint8 + for { + name, err := parser.string() + if err != nil || parser.byte(':') != nil { + return LayeredAsset{}, fmt.Errorf("invalid asset field") + } + bit, err := parser.assetField(name, &asset) + if err != nil || seen&bit != 0 { + return LayeredAsset{}, fmt.Errorf("invalid or duplicate asset field %q", name) + } + seen |= bit + parser.space() + if parser.take('}') { + break + } + if err := parser.byte(','); err != nil { + return LayeredAsset{}, err + } + } + if seen&0x3f != 0x3f { + return LayeredAsset{}, fmt.Errorf("missing asset field") + } + return asset, nil +} + +func (parser *focusedJSONParser) assetField(name string, asset *LayeredAsset) (uint8, error) { + switch name { + case "id": + return 1 << 0, parser.assignString(&asset.ID) + case "platform": + return 1 << 1, parser.assignString(&asset.Platform) + case "kind": + return 1 << 2, parser.assignString(&asset.Kind) + case "relative_path": + return 1 << 3, parser.assignString(&asset.RelativePath) + case "sha256": + return 1 << 4, parser.assignString(&asset.SHA256) + case "size_bytes": + value, err := parser.integer() + asset.SizeBytes = value + return 1 << 5, err + case "capabilities": + values, err := parser.strings() + asset.Capabilities = values + return 1 << 6, err + default: + return 0, fmt.Errorf("unknown asset field") + } +} + +func (parser *focusedJSONParser) strings() ([]string, error) { + if err := parser.byte('['); err != nil { + return nil, err + } + var values []string + for { + parser.space() + if parser.take(']') { + return values, nil + } + if len(values) >= maxFocusedLayeredCapabilities { + return nil, fmt.Errorf("layered asset has too many capabilities") + } + value, err := parser.string() + if err != nil { + return nil, err + } + values = append(values, value) + parser.space() + if parser.take(']') { + return values, nil + } + if err := parser.byte(','); err != nil { + return nil, err + } + } +} + +func (parser *focusedJSONParser) string() (string, error) { + parser.space() + if !parser.take('"') { + return "", fmt.Errorf("expected JSON string") + } + value := make([]byte, 0, 32) + for parser.offset < len(parser.content) { + character := parser.content[parser.offset] + parser.offset++ + if character == '"' { + if !utf8.Valid(value) { + return "", fmt.Errorf("invalid UTF-8 in JSON string") + } + return string(value), nil + } + if character < 0x20 { + return "", fmt.Errorf("unescaped control in JSON string") + } + if character != '\\' { + value = append(value, character) + continue + } + escaped, err := parser.escape() + if err != nil { + return "", err + } + value = utf8.AppendRune(value, escaped) + } + return "", fmt.Errorf("unterminated JSON string") +} + +func (parser *focusedJSONParser) escape() (rune, error) { + if parser.offset >= len(parser.content) { + return 0, fmt.Errorf("unterminated JSON escape") + } + character := parser.content[parser.offset] + parser.offset++ + switch character { + case '"', '\\', '/': + return rune(character), nil + case 'b': + return '\b', nil + case 'f': + return '\f', nil + case 'n': + return '\n', nil + case 'r': + return '\r', nil + case 't': + return '\t', nil + case 'u': + first, err := parser.hexRune() + if err != nil || first < 0xd800 || first > 0xdfff { + return first, err + } + if first > 0xdbff || parser.offset+2 > len(parser.content) || parser.content[parser.offset] != '\\' || parser.content[parser.offset+1] != 'u' { + return 0, fmt.Errorf("invalid JSON surrogate pair") + } + parser.offset += 2 + second, err := parser.hexRune() + if err != nil || second < 0xdc00 || second > 0xdfff { + return 0, fmt.Errorf("invalid JSON surrogate pair") + } + return utf16.DecodeRune(first, second), nil + default: + return 0, fmt.Errorf("invalid JSON escape") + } +} + +func (parser *focusedJSONParser) hexRune() (rune, error) { + if parser.offset+4 > len(parser.content) { + return 0, fmt.Errorf("short JSON Unicode escape") + } + var value rune + for end := parser.offset + 4; parser.offset < end; parser.offset++ { + character := parser.content[parser.offset] + value <<= 4 + switch { + case character >= '0' && character <= '9': + value += rune(character - '0') + case character >= 'a' && character <= 'f': + value += rune(character-'a') + 10 + case character >= 'A' && character <= 'F': + value += rune(character-'A') + 10 + default: + return 0, fmt.Errorf("invalid JSON Unicode escape") + } + } + return value, nil +} + +func (parser *focusedJSONParser) integer() (int64, error) { + parser.space() + start := parser.offset + if parser.take('-') && parser.offset >= len(parser.content) { + return 0, fmt.Errorf("invalid JSON integer") + } + if parser.offset >= len(parser.content) || parser.content[parser.offset] < '0' || parser.content[parser.offset] > '9' { + return 0, fmt.Errorf("invalid JSON integer") + } + if parser.content[parser.offset] == '0' { + parser.offset++ + } else { + for parser.offset < len(parser.content) && parser.content[parser.offset] >= '0' && parser.content[parser.offset] <= '9' { + parser.offset++ + } + } + if parser.offset < len(parser.content) && (parser.content[parser.offset] == '.' || parser.content[parser.offset] == 'e' || parser.content[parser.offset] == 'E') { + return 0, fmt.Errorf("asset size must be a JSON integer") + } + value, err := strconv.ParseInt(string(parser.content[start:parser.offset]), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid JSON integer") + } + return value, nil +} + +func (parser *focusedJSONParser) assignString(destination *string) error { + value, err := parser.string() + if err == nil { + *destination = value + } + return err +} + +func (parser *focusedJSONParser) assignTime(destination *time.Time) error { + value, err := parser.string() + if err != nil { + return err + } + parsed, err := parseCanonicalUTC(value) + if err == nil { + *destination = parsed + } + return err +} + +func parseCanonicalUTC(value string) (time.Time, error) { + if len(value) < 20 || len(value) > 30 || value[4] != '-' || value[7] != '-' || value[10] != 'T' || value[13] != ':' || value[16] != ':' || value[len(value)-1] != 'Z' { + return time.Time{}, fmt.Errorf("timestamp must use canonical UTC RFC3339") + } + year, ok := fixedDecimal(value[0:4]) + month, monthOK := fixedDecimal(value[5:7]) + day, dayOK := fixedDecimal(value[8:10]) + hour, hourOK := fixedDecimal(value[11:13]) + minute, minuteOK := fixedDecimal(value[14:16]) + second, secondOK := fixedDecimal(value[17:19]) + if !ok || !monthOK || !dayOK || !hourOK || !minuteOK || !secondOK { + return time.Time{}, fmt.Errorf("timestamp contains a non-decimal component") + } + nanosecond := 0 + if len(value) > 20 { + fraction := value[19 : len(value)-1] + if len(fraction) < 2 || fraction[0] != '.' || len(fraction) > 10 || fraction[len(fraction)-1] == '0' { + return time.Time{}, fmt.Errorf("timestamp fraction is not canonical") + } + fractionValue, valid := fixedDecimal(fraction[1:]) + if !valid { + return time.Time{}, fmt.Errorf("timestamp fraction is invalid") + } + nanosecond = fractionValue + for digits := len(fraction) - 1; digits < 9; digits++ { + nanosecond *= 10 + } + } + parsed := time.Date(year, time.Month(month), day, hour, minute, second, nanosecond, time.UTC) + parsedYear, parsedMonth, parsedDay := parsed.Date() + parsedHour, parsedMinute, parsedSecond := parsed.Clock() + if parsedYear != year || int(parsedMonth) != month || parsedDay != day || parsedHour != hour || parsedMinute != minute || parsedSecond != second || parsed.Nanosecond() != nanosecond { + return time.Time{}, fmt.Errorf("timestamp component is outside range") + } + return parsed, nil +} + +// IsCanonicalUTCTimestamp reports whether value is the canonical UTC form used +// by signed layered manifests and local layered-attempt state. +func IsCanonicalUTCTimestamp(value string) bool { + _, err := parseCanonicalUTC(value) + return err == nil +} + +func fixedDecimal(value string) (int, bool) { + result := 0 + for index := 0; index < len(value); index++ { + if value[index] < '0' || value[index] > '9' { + return 0, false + } + result = result*10 + int(value[index]-'0') + } + return result, true +} + +func (parser *focusedJSONParser) byte(expected byte) error { + parser.space() + if !parser.take(expected) { + return fmt.Errorf("expected JSON byte %q", expected) + } + return nil +} + +func (parser *focusedJSONParser) take(expected byte) bool { + if parser.offset < len(parser.content) && parser.content[parser.offset] == expected { + parser.offset++ + return true + } + return false +} + +func (parser *focusedJSONParser) space() { + for parser.offset < len(parser.content) { + switch parser.content[parser.offset] { + case ' ', '\t', '\r', '\n': + parser.offset++ + default: + return + } + } +} diff --git a/internal/release/layered_assets_decode.go b/internal/release/layered_assets_decode.go new file mode 100644 index 0000000..4e79a33 --- /dev/null +++ b/internal/release/layered_assets_decode.go @@ -0,0 +1,153 @@ +//go:build !rdev_bootstrap_focused + +package release + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +var layeredManifestFields = []string{ + "schema_version", + "version", + "generated_at", + "expires_at", + "signing_key_id", + "assets", + "signature", +} + +var layeredAssetFields = []string{ + "id", + "platform", + "kind", + "relative_path", + "sha256", + "size_bytes", +} + +func DecodeLayeredAssetManifest(content []byte) (LayeredAssetManifest, error) { + if err := rejectDuplicateLayeredJSONKeys(content); err != nil { + return LayeredAssetManifest{}, fmt.Errorf("invalid layered asset manifest JSON: %w", err) + } + if err := validateLayeredManifestJSONFields(content); err != nil { + return LayeredAssetManifest{}, err + } + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.DisallowUnknownFields() + var manifest LayeredAssetManifest + if err := decoder.Decode(&manifest); err != nil { + return LayeredAssetManifest{}, fmt.Errorf("decode layered asset manifest: %w", err) + } + if err := requireLayeredJSONEOF(decoder); err != nil { + return LayeredAssetManifest{}, err + } + return manifest, nil +} + +func validateLayeredManifestJSONFields(content []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(content, &fields); err != nil || fields == nil { + return fmt.Errorf("layered asset manifest must be a JSON object") + } + if err := requireLayeredJSONFields(fields, layeredManifestFields, nil, "layered asset manifest"); err != nil { + return err + } + var assets []map[string]json.RawMessage + if err := json.Unmarshal(fields["assets"], &assets); err != nil { + return fmt.Errorf("layered asset manifest assets must be an array") + } + for _, asset := range assets { + if asset == nil { + return fmt.Errorf("layered asset must be a JSON object") + } + if err := requireLayeredJSONFields(asset, layeredAssetFields, []string{"capabilities"}, "layered asset"); err != nil { + return err + } + } + return nil +} + +func requireLayeredJSONFields(fields map[string]json.RawMessage, required, optional []string, context string) error { + allowed := make(map[string]bool, len(required)+len(optional)) + for _, name := range required { + allowed[name] = true + if _, ok := fields[name]; !ok { + return fmt.Errorf("missing field in %s", context) + } + } + for _, name := range optional { + allowed[name] = true + } + for name := range fields { + if !allowed[name] { + return fmt.Errorf("unknown field in %s", context) + } + } + return nil +} + +func rejectDuplicateLayeredJSONKeys(content []byte) error { + decoder := json.NewDecoder(bytes.NewReader(content)) + if err := walkUniqueLayeredJSONValue(decoder); err != nil { + return err + } + return requireLayeredJSONEOF(decoder) +} + +func walkUniqueLayeredJSONValue(decoder *json.Decoder) error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := map[string]bool{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("object key is not a string") + } + if seen[key] { + return fmt.Errorf("duplicate JSON object key") + } + seen[key] = true + if err := walkUniqueLayeredJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + case '[': + for decoder.More() { + if err := walkUniqueLayeredJSONValue(decoder); err != nil { + return err + } + } + _, err = decoder.Token() + return err + default: + return fmt.Errorf("unexpected JSON delimiter") + } +} + +func requireLayeredJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("layered asset manifest contains trailing JSON") + } + return fmt.Errorf("decode trailing layered asset manifest content: %w", err) + } + return nil +} diff --git a/internal/release/layered_assets_focused_test.go b/internal/release/layered_assets_focused_test.go new file mode 100644 index 0000000..4587ccd --- /dev/null +++ b/internal/release/layered_assets_focused_test.go @@ -0,0 +1,97 @@ +//go:build rdev_bootstrap_focused + +package release + +import ( + "crypto/ed25519" + "encoding/base64" + "strings" + "testing" + "time" +) + +const focusedManifestJSON = `{"schema_version":"rdev.layered-assets.v1","version":"v2-test","generated_at":"2026-07-17T08:00:00Z","expires_at":"2026-07-17T09:00:00Z","signing_key_id":"release-root","assets":[{"id":"windows-core","platform":"windows/amd64","kind":"core-runtime","relative_path":"rdev-core.exe","sha256":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":4}],"signature":"AA"}` + +func TestFocusedLayeredManifestDecodeIsStrict(t *testing.T) { + manifest, err := DecodeLayeredAssetManifest([]byte(focusedManifestJSON)) + if err != nil { + t.Fatal(err) + } + if manifest.Version != "v2-test" || len(manifest.Assets) != 1 || manifest.Assets[0].SizeBytes != 4 { + t.Fatalf("unexpected focused manifest: %#v", manifest) + } + for _, content := range []string{ + strings.Replace(focusedManifestJSON, `"version":"v2-test"`, `"version":"v2-test","version":"duplicate"`, 1), + strings.Replace(focusedManifestJSON, `"signature":"AA"`, `"unknown":true,"signature":"AA"`, 1), + focusedManifestJSON + ` {}`, + } { + if _, err := DecodeLayeredAssetManifest([]byte(content)); err == nil { + t.Fatalf("focused decoder accepted malformed manifest: %s", content) + } + } +} + +func TestFocusedLayeredManifestRejectsNonCanonicalTime(t *testing.T) { + content := strings.Replace(focusedManifestJSON, "2026-07-17T08:00:00Z", "2026-07-17T09:00:00+01:00", 1) + if _, err := DecodeLayeredAssetManifest([]byte(content)); err == nil { + t.Fatal("focused decoder accepted a non-canonical UTC timestamp") + } +} + +func TestFocusedLayeredManifestBoundsAssetCount(t *testing.T) { + assetStart := strings.Index(focusedManifestJSON, `{"id":`) + assetEnd := strings.Index(focusedManifestJSON[assetStart:], `}],"signature"`) + if assetStart < 0 || assetEnd < 0 { + t.Fatal("focused fixture asset not found") + } + asset := focusedManifestJSON[assetStart : assetStart+assetEnd+1] + assets := strings.TrimSuffix(strings.Repeat(asset+",", maxFocusedLayeredAssets+1), ",") + content := focusedManifestJSON[:assetStart] + assets + focusedManifestJSON[assetStart+assetEnd+1:] + if _, err := DecodeLayeredAssetManifest([]byte(content)); err == nil { + t.Fatal("focused decoder accepted an unbounded asset list") + } +} + +func TestFocusedLayeredManifestCanonicalEncoding(t *testing.T) { + manifest, err := DecodeLayeredAssetManifest([]byte(focusedManifestJSON)) + if err != nil { + t.Fatal(err) + } + manifest.Signature = "ignored" + encoded, err := canonicalUnsignedLayeredAssetManifest(manifest) + if err != nil { + t.Fatal(err) + } + want := strings.Replace(focusedManifestJSON, `"signature":"AA"`, `"signature":""`, 1) + if string(encoded) != want { + t.Fatalf("focused canonical encoding mismatch:\n got %s\nwant %s", encoded, want) + } + if manifest.GeneratedAt != time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC) { + t.Fatalf("unexpected focused generated_at: %s", manifest.GeneratedAt) + } +} + +func TestFocusedLayeredManifestRootVerification(t *testing.T) { + manifest, err := DecodeLayeredAssetManifest([]byte(focusedManifestJSON)) + if err != nil { + t.Fatal(err) + } + seed := make([]byte, ed25519.SeedSize) + for index := range seed { + seed[index] = byte(index + 1) + } + privateKey := ed25519.NewKeyFromSeed(seed) + publicKey := privateKey.Public().(ed25519.PublicKey) + canonical, err := canonicalUnsignedLayeredAssetManifest(manifest) + if err != nil { + t.Fatal(err) + } + manifest.Signature = base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) + root, err := ParseLayeredTrustRoot("release-root:" + base64.RawURLEncoding.EncodeToString(publicKey)) + if err != nil { + t.Fatal(err) + } + if err := VerifyLayeredAssetManifestRoot(manifest, root, time.Date(2026, 7, 17, 8, 30, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } +} diff --git a/internal/release/layered_assets_sign.go b/internal/release/layered_assets_sign.go new file mode 100644 index 0000000..d39408d --- /dev/null +++ b/internal/release/layered_assets_sign.go @@ -0,0 +1,42 @@ +//go:build !rdev_bootstrap_focused + +package release + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "strings" + + "github.com/EitanWong/remote-dev-skillkit/internal/signing" +) + +func SignLayeredAssetManifest(manifest LayeredAssetManifest, key signing.Key) (LayeredAssetManifest, error) { + if err := validateLayeredAssetSigningKey(key); err != nil { + return LayeredAssetManifest{}, err + } + + signed := cloneLayeredAssetManifest(manifest) + signed.SigningKeyID = key.ID + signed.Signature = "" + if err := validateLayeredAssetManifest(signed); err != nil { + return LayeredAssetManifest{}, err + } + message, err := canonicalUnsignedLayeredAssetManifest(signed) + if err != nil { + return LayeredAssetManifest{}, err + } + signed.Signature = base64.RawURLEncoding.EncodeToString(ed25519.Sign(key.PrivateKey, message)) + return signed, nil +} + +func validateLayeredAssetSigningKey(key signing.Key) error { + if strings.TrimSpace(key.ID) == "" || len(key.PublicKey) != ed25519.PublicKeySize || len(key.PrivateKey) != ed25519.PrivateKeySize { + return fmt.Errorf("valid Ed25519 release signing key is required") + } + derived, ok := key.PrivateKey.Public().(ed25519.PublicKey) + if !ok || !derived.Equal(key.PublicKey) { + return fmt.Errorf("valid Ed25519 release signing key is required") + } + return nil +} diff --git a/internal/release/layered_assets_test.go b/internal/release/layered_assets_test.go new file mode 100644 index 0000000..8389718 --- /dev/null +++ b/internal/release/layered_assets_test.go @@ -0,0 +1,352 @@ +//go:build !rdev_bootstrap_focused + +package release + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/model" + "github.com/EitanWong/remote-dev-skillkit/internal/signing" +) + +func TestLayeredAssetManifestSignsVerifiesAndSelectsWindowsCore(t *testing.T) { + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + + manifest := LayeredAssetManifest{ + SchemaVersion: LayeredAssetManifestSchemaVersion, + Version: "v0.2.0", + GeneratedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + Assets: []LayeredAsset{ + { + ID: "rdev-host-windows-amd64", + Platform: "windows-amd64", + Kind: "core-runtime", + RelativePath: "windows/amd64/rdev-host.exe", + SHA256: "sha256:" + strings.Repeat("a", 64), + SizeBytes: 4096, + Capabilities: []string{"shell", "file-transfer"}, + }, + { + ID: "rdev-verify-windows-amd64", + Platform: "windows-amd64", + Kind: "optional-helper", + RelativePath: "windows/amd64/rdev-verify.exe", + SHA256: "sha256:" + strings.Repeat("b", 64), + SizeBytes: 2048, + Capabilities: []string{"verify"}, + }, + }, + } + original := cloneLayeredAssetManifestForTest(manifest) + + signed, err := SignLayeredAssetManifest(manifest, key) + if err != nil { + t.Fatal(err) + } + if signed.SigningKeyID != key.ID { + t.Fatalf("unexpected signing key id %q", signed.SigningKeyID) + } + if signed.Signature == "" { + t.Fatal("signature should be present") + } + if !reflect.DeepEqual(manifest, original) { + t.Fatal("signing mutated the input manifest") + } + + root := model.NewTrustBundle(key.ID, key.PublicKey) + signedBeforeVerify := cloneLayeredAssetManifestForTest(signed) + if err := VerifyLayeredAssetManifest(signed, root, now); err != nil { + t.Fatalf("expected manifest to verify: %v", err) + } + if !reflect.DeepEqual(signed, signedBeforeVerify) { + t.Fatal("verification mutated the signed manifest") + } + + selected, err := SelectLayeredAsset(signed, "windows-amd64", "core-runtime", []string{"file-transfer"}) + if err != nil { + t.Fatal(err) + } + if selected.ID != "rdev-host-windows-amd64" { + t.Fatalf("unexpected selected asset %q", selected.ID) + } + selected.Capabilities[0] = "mutated" + if signed.Assets[0].Capabilities[0] == "mutated" { + t.Fatal("selection returned capabilities aliased to the manifest") + } + + reordered := cloneLayeredAssetManifestForTest(manifest) + reordered.Assets[0], reordered.Assets[1] = reordered.Assets[1], reordered.Assets[0] + reordered.Assets[1].Capabilities[0], reordered.Assets[1].Capabilities[1] = + reordered.Assets[1].Capabilities[1], reordered.Assets[1].Capabilities[0] + reorderedSigned, err := SignLayeredAssetManifest(reordered, key) + if err != nil { + t.Fatal(err) + } + if reorderedSigned.Signature != signed.Signature { + t.Fatal("canonical signature changed with asset or capability order") + } + + tests := []struct { + name string + mutate func(*LayeredAssetManifest) + invalidSigOnly bool + }{ + { + name: "zero size", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].SizeBytes = 0 + }, + }, + { + name: "non-sha256 digest", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].SHA256 = strings.Repeat("a", 64) + }, + }, + { + name: "non-canonical uppercase digest", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].SHA256 = "sha256:" + strings.Repeat("A", 64) + }, + }, + { + name: "duplicate id", + mutate: func(candidate *LayeredAssetManifest) { + duplicate := candidate.Assets[0] + duplicate.Platform = "linux-amd64" + duplicate.RelativePath = "linux/amd64/rdev-host" + candidate.Assets = append(candidate.Assets, duplicate) + }, + }, + { + name: "unknown kind", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].Kind = "bootstrapper" + }, + }, + { + name: "empty version", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Version = "" + }, + }, + { + name: "missing expiry", + mutate: func(candidate *LayeredAssetManifest) { + candidate.ExpiresAt = time.Time{} + }, + }, + { + name: "expiry before generation", + mutate: func(candidate *LayeredAssetManifest) { + candidate.ExpiresAt = candidate.GeneratedAt + }, + }, + { + name: "absolute path", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "/tmp/rdev-host.exe" + }, + }, + { + name: "encoded leading slash", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "%2Fwindows/amd64/rdev-host.exe" + }, + }, + { + name: "parent traversal", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "../rdev-host.exe" + }, + }, + { + name: "bare parent path", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = ".." + }, + }, + { + name: "percent-encoded traversal", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "windows/%2e%2e/rdev-host.exe" + }, + }, + { + name: "backslash", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = `windows\amd64/rdev-host.exe` + }, + }, + { + name: "query", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "windows/amd64/rdev-host.exe?download=1" + }, + }, + { + name: "empty query delimiter", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "windows/amd64/rdev-host.exe?" + }, + }, + { + name: "fragment", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "windows/amd64/rdev-host.exe#payload" + }, + }, + { + name: "empty fragment delimiter", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Assets[0].RelativePath = "windows/amd64/rdev-host.exe#" + }, + }, + { + name: "invalid signature", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Signature = strings.Repeat("A", len(candidate.Signature)) + }, + invalidSigOnly: true, + }, + { + name: "padded signature", + mutate: func(candidate *LayeredAssetManifest) { + candidate.Signature += "=" + }, + invalidSigOnly: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.invalidSigOnly { + unsigned := cloneLayeredAssetManifestForTest(manifest) + tt.mutate(&unsigned) + if _, err := SignLayeredAssetManifest(unsigned, key); err == nil { + t.Fatal("expected signing to reject invalid manifest") + } + } + + candidate := cloneLayeredAssetManifestForTest(signed) + tt.mutate(&candidate) + if err := VerifyLayeredAssetManifest(candidate, root, now); err == nil { + t.Fatal("expected verification to reject invalid manifest") + } + }) + } + + ambiguous := cloneLayeredAssetManifestForTest(signed) + secondCore := ambiguous.Assets[0] + secondCore.ID = "rdev-host-windows-amd64-alt" + secondCore.RelativePath = "windows/amd64/rdev-host-alt.exe" + ambiguous.Assets = append(ambiguous.Assets, secondCore) + if _, err := SelectLayeredAsset(ambiguous, "windows-amd64", "core-runtime", []string{"file-transfer"}); err == nil { + t.Fatal("expected ambiguous core-runtime selection to fail") + } + + future := cloneLayeredAssetManifestForTest(manifest) + future.GeneratedAt = now.Add(time.Second) + futureSigned, err := SignLayeredAssetManifest(future, key) + if err != nil { + t.Fatal(err) + } + if err := VerifyLayeredAssetManifest(futureSigned, root, now); err == nil { + t.Fatal("expected future generated_at to fail verification") + } + expired := cloneLayeredAssetManifestForTest(manifest) + expired.GeneratedAt = now.Add(-2 * time.Hour) + expired.ExpiresAt = now.Add(-time.Hour) + expiredSigned, err := SignLayeredAssetManifest(expired, key) + if err != nil { + t.Fatal(err) + } + if err := VerifyLayeredAssetManifest(expiredSigned, root, now); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expected expired signed manifest to fail verification, got %v", err) + } + expiresNow := cloneLayeredAssetManifestForTest(manifest) + expiresNow.GeneratedAt = now.Add(-time.Hour) + expiresNow.ExpiresAt = now + expiresNowSigned, err := SignLayeredAssetManifest(expiresNow, key) + if err != nil { + t.Fatal(err) + } + if err := VerifyLayeredAssetManifest(expiresNowSigned, root, now); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expected manifest to expire at expires_at, got %v", err) + } + if err := VerifyLayeredAssetManifest(signed, model.NewTrustBundle("other-root", key.PublicKey), now); err == nil { + t.Fatal("expected trust root key id mismatch to fail verification") + } + + invalidKey := key + invalidKey.PrivateKey = invalidKey.PrivateKey[:len(invalidKey.PrivateKey)-1] + if _, err := SignLayeredAssetManifest(manifest, invalidKey); err == nil { + t.Fatal("expected invalid Ed25519 private key length to fail signing") + } + invalidKey = key + invalidKey.PublicKey = invalidKey.PublicKey[:len(invalidKey.PublicKey)-1] + if _, err := SignLayeredAssetManifest(manifest, invalidKey); err == nil { + t.Fatal("expected invalid Ed25519 public key length to fail signing") + } +} + +func TestDecodeLayeredAssetManifestRejectsUnsignedJSONAmbiguity(t *testing.T) { + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + manifest, err := SignLayeredAssetManifest(LayeredAssetManifest{ + SchemaVersion: LayeredAssetManifestSchemaVersion, + Version: "v0.2.0", + GeneratedAt: now, + ExpiresAt: now.Add(time.Hour), + Assets: []LayeredAsset{{ + ID: "rdev-host-windows-amd64", + Platform: "windows/amd64", + Kind: "core-runtime", + RelativePath: "assets/rdev-host-windows-amd64.exe", + SHA256: "sha256:" + strings.Repeat("a", 64), + SizeBytes: 4096, + }}, + }, key) + if err != nil { + t.Fatal(err) + } + content, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + tests := map[string][]byte{ + "unknown field": append(append([]byte(nil), content[:len(content)-1]...), []byte(`,"unsigned_field":true}`)...), + "unknown asset field": []byte(strings.Replace(string(content), `"size_bytes":4096`, `"size_bytes":4096,"unsigned_asset_field":true`, 1)), + "case variant field": []byte(strings.Replace(string(content), `"schema_version":`, `"Schema_Version":`, 1)), + "duplicate key": []byte(strings.Replace(string(content), `{"schema_version":`, `{"version":"v0.2.0","schema_version":`, 1)), + } + for name, ambiguous := range tests { + t.Run(name, func(t *testing.T) { + if _, err := DecodeLayeredAssetManifest(ambiguous); err == nil { + t.Fatal("expected strict layered manifest decoding to fail") + } + }) + } +} + +func cloneLayeredAssetManifestForTest(manifest LayeredAssetManifest) LayeredAssetManifest { + cloned := manifest + cloned.Assets = make([]LayeredAsset, len(manifest.Assets)) + for index, asset := range manifest.Assets { + cloned.Assets[index] = asset + cloned.Assets[index].Capabilities = append([]string(nil), asset.Capabilities...) + } + return cloned +} diff --git a/internal/release/layered_assets_verify.go b/internal/release/layered_assets_verify.go new file mode 100644 index 0000000..9e67444 --- /dev/null +++ b/internal/release/layered_assets_verify.go @@ -0,0 +1,24 @@ +//go:build !rdev_bootstrap_focused + +package release + +import ( + "fmt" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/model" +) + +func VerifyLayeredAssetManifest(manifest LayeredAssetManifest, root model.TrustBundle, now time.Time) error { + if err := validateLayeredAssetManifest(manifest); err != nil { + return err + } + publicKey, err := root.Ed25519PublicKey() + if err != nil { + return fmt.Errorf("%w: layered asset manifest trust root: %v", ErrManifestInvalid, err) + } + return verifyLayeredAssetManifest(manifest, LayeredTrustRoot{ + SigningKeyID: root.SigningKeyID, + PublicKey: publicKey, + }, now) +} diff --git a/internal/release/manifest.go b/internal/release/manifest.go index fe434f6..97ea76f 100644 --- a/internal/release/manifest.go +++ b/internal/release/manifest.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package release import ( @@ -28,6 +30,8 @@ type Manifest struct { ArtifactName string `json:"artifact_name"` ArtifactSHA256 string `json:"artifact_sha256"` ArtifactSize int64 `json:"artifact_size"` + ReleaseVersion string `json:"release_version,omitempty"` + TargetPlatform string `json:"target_platform,omitempty"` IssuedAt time.Time `json:"issued_at"` SigningAlg string `json:"signing_alg"` SigningKeyID string `json:"signing_key_id"` @@ -35,6 +39,10 @@ type Manifest struct { } func SignArtifact(path string, key signing.Key, now time.Time) (Manifest, error) { + return SignArtifactForRelease(path, key, now, "", "") +} + +func SignArtifactForRelease(path string, key signing.Key, now time.Time, releaseVersion, targetPlatform string) (Manifest, error) { digest, size, err := fileDigest(path) if err != nil { return Manifest{}, err @@ -44,6 +52,8 @@ func SignArtifact(path string, key signing.Key, now time.Time) (Manifest, error) ArtifactName: filepath.Base(path), ArtifactSHA256: digest, ArtifactSize: size, + ReleaseVersion: releaseVersion, + TargetPlatform: targetPlatform, IssuedAt: now.UTC(), SigningAlg: model.SigningAlgEd25519, SigningKeyID: key.ID, diff --git a/internal/release/manifest_test.go b/internal/release/manifest_test.go index 72e12a3..0ed0cc8 100644 --- a/internal/release/manifest_test.go +++ b/internal/release/manifest_test.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package release import ( @@ -84,3 +86,38 @@ func TestVerifyRejectsWrongRoot(t *testing.T) { t.Fatalf("expected signature error, got %v", err) } } + +func TestSignArtifactForReleaseBindsVersionAndTargetPlatform(t *testing.T) { + dir := t.TempDir() + artifactPath := filepath.Join(dir, "rdev-host.exe") + if err := os.WriteFile(artifactPath, []byte("host-binary"), 0o644); err != nil { + t.Fatal(err) + } + key, err := signing.Generate("release-root") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + manifest, err := SignArtifactForRelease(artifactPath, key, now, "v0.2.0", "windows/amd64") + if err != nil { + t.Fatal(err) + } + if manifest.ReleaseVersion != "v0.2.0" || manifest.TargetPlatform != "windows/amd64" { + t.Fatalf("release metadata was not bound into artifact manifest: %#v", manifest) + } + root := model.NewTrustBundle(key.ID, key.PublicKey) + if err := manifest.VerifyArtifact(artifactPath, root); err != nil { + t.Fatalf("release-bound artifact should verify: %v", err) + } + + for _, mutate := range []func(*Manifest){ + func(value *Manifest) { value.ReleaseVersion = "v0.1.0" }, + func(value *Manifest) { value.TargetPlatform = "linux/amd64" }, + } { + tampered := manifest + mutate(&tampered) + if err := tampered.VerifyArtifact(artifactPath, root); !errors.Is(err, ErrManifestSignature) { + t.Fatalf("tampered release metadata should invalidate signature, got %v", err) + } + } +} diff --git a/internal/release/provenance.go b/internal/release/provenance.go index e8d95a1..9e3066b 100644 --- a/internal/release/provenance.go +++ b/internal/release/provenance.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package release import ( diff --git a/internal/release/sbom.go b/internal/release/sbom.go index 258eb1b..fdf11b2 100644 --- a/internal/release/sbom.go +++ b/internal/release/sbom.go @@ -1,3 +1,5 @@ +//go:build !rdev_bootstrap_focused + package release import ( diff --git a/internal/supportsession/plan.go b/internal/supportsession/plan.go index 2edb0d2..85492df 100644 --- a/internal/supportsession/plan.go +++ b/internal/supportsession/plan.go @@ -346,7 +346,7 @@ func BuildConnectFromHandoff(handoff map[string]any) map[string]any { payload["foreground_start_command"] = handoff["foreground_start_command"] payload["prepare_command"] = handoff["prepare_command"] payload["agent_connection_runbook"] = handoff["agent_connection_runbook"] - payload["agent_next_step"] = "run cli_start_now_command in a visible terminal; it starts the gateway, builds verified helper assets, prints the target command, writes ready_file.path and status_file.path, and waits for the target; then send only the started payload's target_handoff_envelope.full_text and wait for connected=true" + payload["agent_next_step"] = "run cli_start_now_command in a visible terminal; it starts the gateway, builds verified bootstrap assets, prints the target command, writes ready_file.path and status_file.path, and waits for the target; then send only the started payload's target_handoff_envelope.full_text and wait for connected=true" payload["human_surface_rule"] = "do not send this connect payload to the target human; run the returned cli_start_now_command first and then send the started payload's top-level target_handoff_envelope.full_text" return payload } @@ -491,7 +491,7 @@ func BuildStarted(opts StartedOptions) map[string]any { readiness.ReadyToSend = readiness.ReadyToSend && assetsReady blockedReason := readiness.DegradedReason if !assetsReady { - blockedReason = "helper assets are not ready; do not send target_handoff_envelope.full_text until rdev support-session connect --start or prepare --build-assets reports asset_report.all_ready=true" + blockedReason = "bootstrap assets are not ready; do not send target_handoff_envelope.full_text until rdev support-session connect --start or prepare --build-assets reports asset_report.all_ready=true" } userHandoff := userHandoffWithReadiness(session["user_handoff"], readinessWithReason(readiness, blockedReason)) envelope := targetHandoffEnvelopeWithReadiness(session["target_handoff_envelope"], readinessWithReason(readiness, blockedReason)) @@ -668,14 +668,14 @@ func Prepare(ctx context.Context, opts PrepareOptions) (map[string]any, error) { gitPath, _ := exec.LookPath("git") rdevPath, _ := exec.LookPath("rdev") currentExecutable, _ := os.Executable() - repoValid := pathExists(filepath.Join(repoRoot, "go.mod")) && pathExists(filepath.Join(repoRoot, "cmd", "rdev-host", "main.go")) + repoValid := pathExists(filepath.Join(repoRoot, "go.mod")) && pathExists(filepath.Join(repoRoot, "cmd", "rdev-bootstrap", "main.go")) assets := supportSessionAssetSpecs(binDir) missingInputs := []string{} if strings.TrimSpace(goPath) == "" { - missingInputs = append(missingInputs, "go binary is required to build missing helper assets from source") + missingInputs = append(missingInputs, "go binary is required to build missing bootstrap assets from source") } if !repoValid { - missingInputs = append(missingInputs, "valid remote-dev-skillkit checkout with go.mod and cmd/rdev-host/main.go") + missingInputs = append(missingInputs, "valid remote-dev-skillkit checkout with go.mod and cmd/rdev-bootstrap/main.go") } assetReports := make([]map[string]any, 0, len(assets)) allAssetsReady := true @@ -690,9 +690,9 @@ func Prepare(ctx context.Context, opts PrepareOptions) (map[string]any, error) { "gzip_asset_url": supportSessionAssetURL(gatewayURL, asset.Name, ".gz"), "sha256_url": supportSessionAssetURL(gatewayURL, asset.Name, ".sha256"), "build_status": "not-requested", - "asset_role": "full-helper", + "asset_role": "bootstrap", "used_by_default_first_connect": true, - "native_bootstrap_asset": false, + "native_bootstrap_asset": true, } if fileExists(asset.Path) { sum, err := fileSHA256Hex(asset.Path) @@ -814,7 +814,7 @@ func Prepare(ctx context.Context, opts PrepareOptions) (map[string]any, error) { "requires_human_decision_first": []string{"company or owner authorization when the target is not clearly operator-owned"}, } recoveryActions := []string{ - "prefer rdev support-session connect --start when no gateway is running; it creates the ticket, prints one target command, prepares helper assets, and watches through support-session status", + "prefer rdev support-session connect --start when no gateway is running; it creates the ticket, prints one target command, prepares bootstrap assets, and watches through support-session status", "if local_rdev_usable is false, run go install ./cmd/rdev from a valid checkout or use go run ./cmd/rdev bootstrap agent-plan --repo-root . as a temporary planner", "if target_bootstrap_self_repair is false, rerun rdev support-session prepare --build-assets from a valid checkout before giving commands to targets that may not have rdev installed", "do not write custom PowerShell, relay, activation polling, ticket substitution, or bootstrap glue", @@ -849,10 +849,10 @@ func Prepare(ctx context.Context, opts PrepareOptions) (map[string]any, error) { "first_connect_size_strategy": assetDownloadSummary["first_connect_size_strategy"], "default_first_connect_surface": assetDownloadSummary["default_first_connect_surface"], "default_runner_download_kind": assetDownloadSummary["default_runner_download_kind"], - "first_task_requires_full_helper": assetDownloadSummary["first_task_requires_full_helper"], + "first_task_requires_verified_core": assetDownloadSummary["first_task_requires_verified_core"], "publishes_native_first_connect_asset": assetDownloadSummary["publishes_native_first_connect_asset"], - "default_full_helper_gzip_estimated_max_bytes": assetDownloadSummary["default_full_helper_gzip_estimated_max_bytes"], - "default_full_helper_meets_bootstrap_target": assetDownloadSummary["default_full_helper_meets_bootstrap_target"], + "default_bootstrap_gzip_estimated_max_bytes": assetDownloadSummary["default_bootstrap_gzip_estimated_max_bytes"], + "default_bootstrap_meets_first_connect_target": assetDownloadSummary["default_bootstrap_meets_first_connect_target"], "native_first_connect_asset": assetDownloadSummary["native_first_connect_asset"], "default_first_connect_agent_interpretation": assetDownloadSummary["default_first_connect_agent_interpretation"], "native_first_connect_asset_publication_policy": assetDownloadSummary["native_first_connect_asset_publication_policy"], @@ -882,7 +882,13 @@ func Prepare(ctx context.Context, opts PrepareOptions) (map[string]any, error) { } func supportSessionRdevBuildArgs(outputPath string) []string { - return []string{"build", "-trimpath", "-ldflags=-s -w", "-o", outputPath, "./cmd/rdev-host"} + args := []string{"build", "-trimpath"} + if filepath.Base(outputPath) == "rdev-bootstrap-windows-amd64.exe" { + args = append(args, "-gcflags=all=-l", "-tags=rdev_bootstrap_focused", "-ldflags=-s -w -buildid= -funcalign=1") + } else { + args = append(args, "-ldflags=-s -w") + } + return append(args, "-o", outputPath, "./cmd/rdev-bootstrap") } func connectivityStrategy(gatewayURL, target string, gatewayCandidates []GatewayURLCandidate) map[string]any { @@ -905,9 +911,9 @@ func connectivityStrategy(gatewayURL, target string, gatewayCandidates []Gateway "operator-provided-hosted-gateway", }, "automatic_downgrade": []string{ - "if WSS is blocked, rdev host serve --transport auto falls back to HTTPS long-poll", - "if long-poll is blocked or unstable, rdev host serve --transport auto falls back to short polling", - "after registration, rdev host serve --transport auto can switch to another signed join-manifest gateway candidate if the current gateway fails before processing session tasks", + "if WSS is blocked, the bootstrap-started core falls back to HTTPS long-poll", + "if long-poll is blocked or unstable, the same core falls back to short polling", + "after registration, the same core can switch to another signed join-manifest gateway candidate without duplicate registration", "if a direct gateway health check fails, try LAN/private gateway candidates and configured proxy variables before helper paths", "if a configured helper path fails, report manual_action_required instead of guessing credentials or mutating network policy", }, @@ -1124,7 +1130,7 @@ func agentConnectionRunbook(opts agentConnectionRunbookOptions) map[string]any { "fallback_entry_tool": map[string]any{ "mcp_tool": "rdev.support_session.prepare", "cli_command": []string{rdevCommand, "support-session", "prepare", "--build-assets"}, - "rule": "use only to repair missing local helper assets or inspect gateway candidates before creating a fresh support-session entry", + "rule": "use only to repair missing local bootstrap assets or inspect gateway candidates before creating a fresh support-session entry", }, "fresh_agent_failure_prevention": freshAgentFailurePrevention(), "auto_activate": map[string]any{ @@ -1157,7 +1163,7 @@ func agentConnectionRunbook(opts agentConnectionRunbookOptions) map[string]any { "rdev.connection_entry.plan", "rdev connection-entry plan", }, - "reason": "low-level invite and package materialization surfaces are for reviewed packaging or advanced workflows; fresh Agents should use rdev.sessions.connect so helper assets, auto-activation, foreground feedback, and status watching are generated together", + "reason": "low-level invite and package materialization surfaces are for reviewed packaging or advanced workflows; fresh Agents should use rdev.sessions.connect so bootstrap assets, auto-activation, foreground feedback, and status watching are generated together", "allowed_when": []string{ "an operator explicitly asks for package materialization", "managed owned-host service planning has been explicitly authorized", @@ -1316,7 +1322,7 @@ func freshAgentFailurePrevention() map[string]any { "schema_version": FreshAgentFailurePreventionSchemaVersion, "purpose": "keep fresh Agents on the standardized connect/start/watch/recover path instead of recreating fragile gateway, invite, bootstrap, and authorization glue", "known_failure_pattern": []string{ - "manual rdev gateway serve plus rdev invite create can omit verified helper assets and produce target bootstraps that fail with rdev is required", + "manual rdev gateway serve plus rdev invite create can omit verified bootstrap assets and produce an incomplete signed layered handoff", "background or ad hoc gateway process management can disappear before the target joins and leaves the Agent without a ready/status file", "hand-written Windows PowerShell or shell bootstrap code causes model-dependent behavior and can weaken the security contract", "asking humans to choose ticket, root key, gateway URL, transport, release root, checksum, or platform command creates avoidable multi-turn setup failures", @@ -1334,7 +1340,7 @@ func freshAgentFailurePrevention() map[string]any { }, "standard_recovery": []string{ "if rdev is missing, recover from the checkout with go install ./cmd/rdev or go run ./cmd/rdev bootstrap agent-plan --repo-root .", - "if helper assets are missing, run rdev support-session connect --start or rdev support-session prepare --build-assets from a valid checkout", + "if bootstrap assets are missing, run rdev support-session connect --start or rdev support-session prepare --build-assets from a valid checkout", "if a LAN-only path times out or will not survive network changes, configure a standard hosted/relay/mesh/VPN/SSH gateway candidate and create a fresh Connection Entry", "ask one short question only for authorization, persistence authorization, privileged network changes, paid/cloud resources, credentials, or unclear ownership", "if the target reports a PowerShell variable error on the bootstrap command, they are running from an existing PowerShell session; tell them to use: irm 'URL' -UseBasicParsing | iex (the join page always shows this simpler form)", @@ -1425,9 +1431,9 @@ func standardRecoveryActions(actions []string) []string { return actions } return []string{ - "run rdev support-session prepare to inspect local rdev, Go, Git, repository, helper assets, gateway URL, and target command readiness", + "run rdev support-session prepare to inspect local rdev, Go, Git, repository, bootstrap assets, gateway URL, and target command readiness", "if rdev is missing, build it from the checkout with go install ./cmd/rdev or go run ./cmd/rdev bootstrap agent-plan --repo-root .", - "if helper assets are missing, rerun rdev support-session connect --start from a valid checkout so target bootstraps can download verified helpers", + "if bootstrap assets are missing, rerun rdev support-session connect --start from a valid checkout so target launchers can download a verified rdev-bootstrap", "ask one concise question only when authorization, persistence authorization, privileged network changes, or a real gateway/relay credential is required", } } @@ -1685,7 +1691,6 @@ func connectionEntryRunnerRecommendation(opts CreatedOptions, gatewayURL, joinUR if rdevCommand == "" { rdevCommand = "rdev" } - targetRdevCommand := "rdev" transport := "auto" invite, err := agentinvite.New(agentinvite.Options{ GatewayURL: gatewayURL, @@ -1698,7 +1703,6 @@ func connectionEntryRunnerRecommendation(opts CreatedOptions, gatewayURL, joinUR AuthorityProfile: "standard", Once: false, RequireHostActivation: !opts.AutoActivate, - RdevCommand: targetRdevCommand, }) targetOS := connectionEntryRunnerTargetOS(target) mcpPlanArguments := map[string]any{ @@ -1724,7 +1728,7 @@ func connectionEntryRunnerRecommendation(opts CreatedOptions, gatewayURL, joinUR "the target is operator-owned and expected to support recurring Agent work", "gateway_candidate_preflight reports only LAN, loopback, or explicit candidates and durable connectivity is required", "connectivity_helper_preflight reports configured SSH, relay, mesh, or VPN helper metadata", - "the Agent needs a package that probes direct, proxy, relay, mesh, VPN, or SSH-assisted paths before starting rdev host serve", + "the Agent needs a package that probes direct, proxy, relay, mesh, VPN, or SSH-assisted paths before invoking rdev-bootstrap", }, "default_human_surface": "keep using target_handoff_envelope.full_text for the simplest attended temporary session; user_handoff remains a compatibility fallback; use the runner package when durable or restrictive-network connectivity is needed", "standard_tool": "rdev.connection_entry.plan", @@ -2276,7 +2280,7 @@ func BuildCreated(opts CreatedOptions) map[string]any { "read connection_continuity_policy to decide whether this session survives LAN changes or needs a configured hosted/relay/mesh/VPN/SSH path", "if the gateway was not started by rdev support-session start, verify target_bootstrap_requirements before sending a Windows/macOS/Linux command", "watch connection status with watch_connection_status or rdev.support_session.status", - "read rdev_bootstrap_connector before interpreting target_preconnects; preconnect means the target command started before the full helper finished downloading, not that session tasks can run yet", + "read rdev_bootstrap_connector before interpreting target_preconnects; bootstrap activity is not a connected core and session tasks wait for connected=true", "when connected=true, proactively report that the connection is established", "do not ask the human to assemble ticket, gateway, manifest root, transport, or helper flags", } @@ -2376,36 +2380,32 @@ func rdevBootstrapConnectorContract() map[string]any { return map[string]any{ "schema_version": BootstrapConnectorSchemaVersion, "first_connect_target_bytes": supportSessionBootstrapTargetBytes, - "bootstrap_surface": "script-preconnect", - "default_first_connect_surface": "script-preconnect", - "publishes_native_first_connect_asset": false, - "preconnect_endpoint": "/v1/support-session/preconnect", - "preconnect_phase_before_full_helper": "downloading-helper", - "source": "rdev-bootstrap-preconnect", + "bootstrap_surface": "signed-native-bootstrap", + "default_first_connect_surface": "signed-native-bootstrap", + "publishes_native_first_connect_asset": true, + "source": "rdev-bootstrap", "native_connector": map[string]any{ "schema_version": "rdev.bootstrap-native-connector.v1", "source": "rdev-bootstrap-native", - "availability": "optional-if-rdev-bootstrap-is-already-installed-or-published", - "published_by_support_session_assets": false, - "default_first_connect_surface": "script-preconnect", - "command": []string{"rdev-bootstrap", "upgrade"}, - "capabilities": []string{"preconnect to gateway", "download verified full helper", "verify SHA-256", "exec verified full helper"}, + "availability": "published-first-connect-asset", + "published_by_support_session_assets": true, + "default_first_connect_surface": "signed-native-bootstrap", + "command": []string{"rdev-bootstrap", "layered-run"}, + "capabilities": []string{"verify signed layered manifest", "register once", "download verified core", "start one core"}, "can_run_session_tasks_before_full_runner": false, "requires_full_runner_before_session_tasks": true, - "standard_no_exec_probe": []string{"rdev-bootstrap", "upgrade", "--no-exec", "--gateway-url", "", "--ticket-code", "", "--asset", "", "--out", ""}, - "asset_budget_rule": "do not publish native rdev-bootstrap as the default first-connect asset until its compressed release artifact is proven under first_connect_target_bytes", - "agent_rule": "use rdev-bootstrap upgrade only when rdev-bootstrap is already installed or explicitly published by the release assets; otherwise use script-preconnect and do not treat rdev-bootstrap itself as a session task runner", + "asset_budget_rule": "the published bootstrap handoff must stay within first_connect_target_bytes", + "agent_rule": "use the published rdev-bootstrap layered-run path for every generated connection", }, - "cdn_download_optimizer": cdnDownloadOptimizerContract(), - "grants_host_access": false, - "can_run_session_tasks": false, - "full_runner_phase": "download-verified-rdev-host", - "upgrade_required_for": []string{"shell tasks", "filesystem tasks", "process tasks", "desktop operations", "coding tasks"}, - "status_fields": []string{"target_preconnects", "target_preconnect_count"}, - "agent_rule": "treat target_preconnects as evidence that the target-side bootstrap command started and reached the gateway, not as a connected executable host; wait for connected=true before submitting session tasks", - "operator_explanation": "bootstrap preconnect is a low-byte first-contact signal that narrows download/network diagnosis before the full verified helper is available", - "must_not_be_used_for_authorization": true, - "must_not_skip_full_helper_verification": true, + "cdn_download_optimizer": cdnDownloadOptimizerContract(), + "grants_host_access": false, + "can_run_session_tasks": false, + "full_runner_phase": "download-signed-core-after-registration", + "status_fields": []string{"connected", "target_preconnects", "target_preconnect_count"}, + "agent_rule": "wait for connected=true before submitting session tasks; bootstrap completion alone is not host authorization", + "operator_explanation": "the small bootstrap verifies signed release metadata and transfers control to one verified core", + "must_not_be_used_for_authorization": true, + "must_not_skip_core_verification": true, } } @@ -2896,19 +2896,19 @@ func bootstrapRequirements(target string) map[string]any { requirements := map[string]any{ "schema_version": "rdev.support-session-target-bootstrap-requirements.v1", "target": target, - "agent_rule": "support-session connect --start prepares these helper assets automatically; if using an existing gateway, verify the relevant /assets endpoints before sending a platform command", + "agent_rule": "support-session connect --start prepares these bootstrap assets automatically; if using an existing gateway, verify the relevant /assets endpoints before sending a platform command", "standard_fix": []string{"rdev support-session prepare --build-assets", "rdev support-session connect --start"}, "forbidden": []string{"telling the target user to install rdev manually as the first recovery step", "writing an ad hoc bootstrap downloader", "using ExecutionPolicy Bypass"}, } switch target { case "windows": - requirements["required_assets"] = []string{"rdev-windows-amd64.exe", "rdev-windows-amd64.exe.sha256"} - requirements["verification"] = []string{"GET /assets/rdev-windows-amd64.exe.sha256 must return 200", "GET /assets/rdev-windows-amd64.exe must return 200"} + requirements["required_assets"] = []string{"rdev-bootstrap-windows-amd64.exe", "rdev-bootstrap-windows-amd64.exe.sha256"} + requirements["verification"] = []string{"GET /assets/rdev-bootstrap-windows-amd64.exe.sha256 must return 200", "GET /assets/rdev-bootstrap-windows-amd64.exe must return 200"} case "macos", "linux": - requirements["required_assets"] = []string{"matching rdev-- helper", "matching .sha256"} - requirements["verification"] = []string{"GET /assets/.sha256 must return 200", "GET /assets/ must return 200"} + requirements["required_assets"] = []string{"matching rdev-bootstrap--", "matching .sha256"} + requirements["verification"] = []string{"GET /assets/.sha256 must return 200", "GET /assets/ must return 200"} default: - requirements["required_assets"] = []string{"matching Windows/macOS/Linux rdev helper", "matching .sha256"} + requirements["required_assets"] = []string{"matching Windows/macOS/Linux rdev-bootstrap", "matching .sha256"} requirements["verification"] = []string{"send target_handoff_envelope.full_text verbatim; it includes Windows/macOS/Linux commands plus browser fallback", "verify platform assets before sending a platform-specific terminal command from an existing gateway"} } return requirements @@ -3007,11 +3007,11 @@ func BuildPlan(ctx context.Context, opts Options) map[string]any { ttl = 7200 } rdevPath := filepath.Join(workDir, "bin", exeName("rdev", runtime.GOOS)) - windowsRdevPath := filepath.Join(workDir, "bin", "rdev-windows-amd64.exe") - linuxRdevPath := filepath.Join(workDir, "bin", "rdev-linux-amd64") - linuxArmRdevPath := filepath.Join(workDir, "bin", "rdev-linux-arm64") - darwinArmRdevPath := filepath.Join(workDir, "bin", "rdev-darwin-arm64") - darwinAmdRdevPath := filepath.Join(workDir, "bin", "rdev-darwin-amd64") + windowsRdevPath := filepath.Join(workDir, "bin", "rdev-bootstrap-windows-amd64.exe") + linuxRdevPath := filepath.Join(workDir, "bin", "rdev-bootstrap-linux-amd64") + linuxArmRdevPath := filepath.Join(workDir, "bin", "rdev-bootstrap-linux-arm64") + darwinArmRdevPath := filepath.Join(workDir, "bin", "rdev-bootstrap-darwin-arm64") + darwinAmdRdevPath := filepath.Join(workDir, "bin", "rdev-bootstrap-darwin-amd64") createInviteCommand := []string{ rdevPath, "invite", "create", "--gateway", gatewayURL, @@ -3054,11 +3054,11 @@ func BuildPlan(ctx context.Context, opts Options) map[string]any { "commands": map[string]any{ "prepare_dirs": []string{"mkdir", "-p", filepath.Join(workDir, "bin"), filepath.Join(workDir, ".rdev", "keys"), filepath.Join(workDir, ".rdev", "gateway"), filepath.Join(workDir, ".rdev", "audit")}, "build_local_rdev": []string{"go", "build", "-o", rdevPath, "./cmd/rdev"}, - "build_windows_rdev": []string{"env", "GOOS=windows", "GOARCH=amd64", "CGO_ENABLED=0", "go", "build", "-o", windowsRdevPath, "./cmd/rdev"}, - "build_linux_rdev": []string{"env", "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0", "go", "build", "-o", linuxRdevPath, "./cmd/rdev"}, - "build_linux_arm64_rdev": []string{"env", "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "go", "build", "-o", linuxArmRdevPath, "./cmd/rdev"}, - "build_macos_arm64_rdev": []string{"env", "GOOS=darwin", "GOARCH=arm64", "CGO_ENABLED=0", "go", "build", "-o", darwinArmRdevPath, "./cmd/rdev"}, - "build_macos_amd64_rdev": []string{"env", "GOOS=darwin", "GOARCH=amd64", "CGO_ENABLED=0", "go", "build", "-o", darwinAmdRdevPath, "./cmd/rdev"}, + "build_windows_rdev": []string{"env", "GOOS=windows", "GOARCH=amd64", "CGO_ENABLED=0", "go", "build", "-o", windowsRdevPath, "./cmd/rdev-bootstrap"}, + "build_linux_rdev": []string{"env", "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0", "go", "build", "-o", linuxRdevPath, "./cmd/rdev-bootstrap"}, + "build_linux_arm64_rdev": []string{"env", "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "go", "build", "-o", linuxArmRdevPath, "./cmd/rdev-bootstrap"}, + "build_macos_arm64_rdev": []string{"env", "GOOS=darwin", "GOARCH=arm64", "CGO_ENABLED=0", "go", "build", "-o", darwinArmRdevPath, "./cmd/rdev-bootstrap"}, + "build_macos_amd64_rdev": []string{"env", "GOOS=darwin", "GOARCH=amd64", "CGO_ENABLED=0", "go", "build", "-o", darwinAmdRdevPath, "./cmd/rdev-bootstrap"}, "start_gateway": []string{ rdevPath, "gateway", "serve", "--dev", "--addr", addr, @@ -3066,11 +3066,11 @@ func BuildPlan(ctx context.Context, opts Options) map[string]any { "--state", filepath.Join(workDir, ".rdev", "gateway", "state.json"), "--signing-key", filepath.Join(workDir, ".rdev", "keys", "gateway-signing-key.json"), "--manifest-signing-key", filepath.Join(workDir, ".rdev", "keys", "manifest-root-key.json"), - "--rdev-windows-amd64", windowsRdevPath, - "--rdev-linux-amd64", linuxRdevPath, - "--rdev-linux-arm64", linuxArmRdevPath, - "--rdev-darwin-arm64", darwinArmRdevPath, - "--rdev-darwin-amd64", darwinAmdRdevPath, + "--rdev-bootstrap-windows-amd64", windowsRdevPath, + "--rdev-bootstrap-linux-amd64", linuxRdevPath, + "--rdev-bootstrap-linux-arm64", linuxArmRdevPath, + "--rdev-bootstrap-darwin-arm64", darwinArmRdevPath, + "--rdev-bootstrap-darwin-amd64", darwinAmdRdevPath, }, "create_invite_http": []string{ "curl", "-fsS", "-X", "POST", gatewayURL + "/v1/tickets", @@ -3403,11 +3403,11 @@ func targetPreconnectStatus(phase string) string { func targetPreconnectAgentInterpretation(status string) string { switch status { case "target-downloading": - return "The target command reached the gateway and is downloading the helper; this is not disconnected or user inaction." + return "The target command reached the gateway and is downloading the verified core runtime through rdev-bootstrap; this is not disconnected or user inaction." case "target-verifying": - return "The target command reached the gateway and is verifying a downloaded helper; this is not disconnected or user inaction." + return "The target command reached the gateway and is verifying the downloaded core runtime; this is not disconnected or user inaction." case "target-starting": - return "The target command reached the gateway and is starting or registering the helper; this is not disconnected or user inaction." + return "The target command reached the gateway and is starting or registering the verified core runtime; this is not disconnected or user inaction." default: return "The target command reached the gateway before host registration; this is not disconnected or user inaction." } @@ -3556,22 +3556,22 @@ func BuildConnectionRecovery(opts ConnectionRecoveryOptions) map[string]any { } case "target-downloading": agentActions = []string{ - "treat this as target-side helper download progress or a stalled weak-network transfer, not as user inaction", + "treat this as target-side verified core download progress or a stalled weak-network transfer, not as user inaction", "inspect target_preconnect_summary phase, last_seen_at, asset, source, and seen_count before asking the target-side human for output", "prefer standard asset mirror, retry/backoff, or rdev-bootstrap follow-up paths instead of writing ad hoc download scripts", } humanChecks = []string{ - "keep the visible target-side command running while the helper download continues", + "keep the visible target-side command running while the verified core download continues", "copy the visible download error only if the standard command reports a failure", } case "target-verifying": agentActions = []string{ - "treat this as target-side helper verification progress, not as user inaction", + "treat this as target-side core verification progress, not as user inaction", "inspect target_preconnect_summary and asset checksum evidence before asking the target-side human to retry", "prefer standard asset/checksum recovery instead of writing ad hoc verification scripts", } humanChecks = []string{ - "keep the visible target-side command running while helper verification continues", + "keep the visible target-side command running while core verification continues", "copy the visible checksum or verification error only if the standard command reports a failure", } case "target-starting", "target-preconnect": @@ -3697,9 +3697,9 @@ func localizedTimedOutStatusNextAction(status, locale string) string { default: switch status { case "target-downloading": - return "The helper download is still in progress or stalled on a weak network; keep waiting, inspect target_preconnect_summary timestamps, asset, and mirror strategy, and do not treat this as the target command not running." + return "The verified core download is still in progress or stalled on a weak network; keep waiting, inspect target_preconnect_summary timestamps, asset, and mirror strategy, and do not treat this as the target command not running." case "target-verifying": - return "Helper verification is still in progress or stalled; inspect target_preconnect_summary, checksums, and asset mirrors instead of asking the user to reassemble commands." + return "Core verification is still in progress or stalled; inspect target_preconnect_summary, checksums, and asset mirrors instead of asking the user to reassemble commands." case "target-starting", "target-preconnect": return "The target bootstrap reached the gateway but has not completed registration; keep waiting and inspect target_preconnect_summary instead of writing ad hoc polling scripts." } @@ -3753,11 +3753,11 @@ func localizedStatusFeedback(status, locale string) string { case "pending-activation": return "目标主机已经出现,正在等待审批或自动批准完成。" case "target-downloading": - return "目标端命令已经触达网关,正在下载 rdev helper;这通常是弱网或大包体导致的等待。" + return "目标端命令已经触达网关,rdev-bootstrap 正在下载已签名 core runtime;这通常是弱网导致的等待。" case "target-verifying": - return "目标端命令已经触达网关,正在校验下载的 rdev helper。" + return "目标端命令已经触达网关,正在校验下载的 core runtime。" case "target-starting": - return "目标端命令已经触达网关,正在启动或注册 rdev helper。" + return "目标端命令已经触达网关,正在启动或注册已验证的 core runtime。" case "target-preconnect": return "目标端命令已经触达网关,但还没有完成主机注册。" case "revoked": @@ -3772,11 +3772,11 @@ func localizedStatusFeedback(status, locale string) string { case "pending-activation": return "The target host has appeared and is waiting for standard auto-activation to complete." case "target-downloading": - return "The target command reached the gateway and is downloading the rdev helper; this is usually weak-network or large-asset wait time." + return "The target command reached the gateway and rdev-bootstrap is downloading the verified core runtime; this is usually weak-network wait time." case "target-verifying": - return "The target command reached the gateway and is verifying the downloaded rdev helper." + return "The target command reached the gateway and is verifying the downloaded core runtime." case "target-starting": - return "The target command reached the gateway and is starting or registering the rdev helper." + return "The target command reached the gateway and is starting or registering the verified core runtime." case "target-preconnect": return "The target command reached the gateway but has not completed host registration yet." case "revoked": @@ -3798,7 +3798,7 @@ func localizedStatusNextAction(status, locale string) string { case "target-downloading": return "继续等待下载完成;不要误判为目标端未执行。必要时检查 target_preconnect_summary 和网络/镜像策略。" case "target-verifying": - return "继续等待校验完成;如果长时间停留,检查 helper 校验和与资产镜像。" + return "继续等待校验完成;如果长时间停留,检查 core 校验和与资产镜像。" case "target-starting": return "继续等待主机注册完成;如果长时间停留,检查目标端可见窗口输出。" case "target-preconnect": @@ -3817,7 +3817,7 @@ func localizedStatusNextAction(status, locale string) string { case "target-downloading": return "Keep waiting for the download to finish; do not misdiagnose this as the target command not running. Inspect target_preconnect_summary and network or mirror strategy if it stalls." case "target-verifying": - return "Keep waiting for helper verification; if it stalls, inspect checksums and asset mirrors." + return "Keep waiting for core verification; if it stalls, inspect checksums and asset mirrors." case "target-starting": return "Keep waiting for host registration; if it stalls, inspect the visible target-side output." case "target-preconnect": @@ -3864,11 +3864,12 @@ type supportSessionAssetSpec struct { func supportSessionAssetSpecs(binDir string) []supportSessionAssetSpec { return []supportSessionAssetSpec{ - {ID: "windows-amd64", Name: "rdev-windows-amd64.exe", GOOS: "windows", GOARCH: "amd64", Path: filepath.Join(binDir, "rdev-windows-amd64.exe")}, - {ID: "darwin-arm64", Name: "rdev-darwin-arm64", GOOS: "darwin", GOARCH: "arm64", Path: filepath.Join(binDir, "rdev-darwin-arm64")}, - {ID: "darwin-amd64", Name: "rdev-darwin-amd64", GOOS: "darwin", GOARCH: "amd64", Path: filepath.Join(binDir, "rdev-darwin-amd64")}, - {ID: "linux-amd64", Name: "rdev-linux-amd64", GOOS: "linux", GOARCH: "amd64", Path: filepath.Join(binDir, "rdev-linux-amd64")}, - {ID: "linux-arm64", Name: "rdev-linux-arm64", GOOS: "linux", GOARCH: "arm64", Path: filepath.Join(binDir, "rdev-linux-arm64")}, + {ID: "windows-amd64", Name: "rdev-bootstrap-windows-amd64.exe", GOOS: "windows", GOARCH: "amd64", Path: filepath.Join(binDir, "rdev-bootstrap-windows-amd64.exe")}, + {ID: "windows-arm64", Name: "rdev-bootstrap-windows-arm64.exe", GOOS: "windows", GOARCH: "arm64", Path: filepath.Join(binDir, "rdev-bootstrap-windows-arm64.exe")}, + {ID: "darwin-arm64", Name: "rdev-bootstrap-darwin-arm64", GOOS: "darwin", GOARCH: "arm64", Path: filepath.Join(binDir, "rdev-bootstrap-darwin-arm64")}, + {ID: "darwin-amd64", Name: "rdev-bootstrap-darwin-amd64", GOOS: "darwin", GOARCH: "amd64", Path: filepath.Join(binDir, "rdev-bootstrap-darwin-amd64")}, + {ID: "linux-amd64", Name: "rdev-bootstrap-linux-amd64", GOOS: "linux", GOARCH: "amd64", Path: filepath.Join(binDir, "rdev-bootstrap-linux-amd64")}, + {ID: "linux-arm64", Name: "rdev-bootstrap-linux-arm64", GOOS: "linux", GOARCH: "arm64", Path: filepath.Join(binDir, "rdev-bootstrap-linux-arm64")}, } } @@ -3920,31 +3921,32 @@ func supportSessionAssetDownloadSummary(assetReports []map[string]any) map[strin } } } + bootstrapTargetMet := hasGzipEvidence && maxGzipBytes <= supportSessionBootstrapTargetBytes return map[string]any{ "all_gzip_within_budget": allGzipWithinBudget, "bootstrap_connector_recommended": bootstrapConnectorRecommended, - "first_connect_size_strategy": "serve gzip-compressed full helper assets now; use rdev-bootstrap as the next connector architecture when compressed helpers exceed the 1 MB first-connect target", - "default_first_connect_surface": "script-preconnect", - "default_runner_download_kind": "gzip-full-helper", - "first_task_requires_full_helper": true, - "publishes_native_first_connect_asset": false, - "default_full_helper_gzip_estimated_max_bytes": maxGzipBytes, - "default_full_helper_meets_bootstrap_target": hasGzipEvidence && maxGzipBytes <= supportSessionBootstrapTargetBytes, - "native_first_connect_asset": nativeFirstConnectAssetReport(), - "default_first_connect_agent_interpretation": "the generated bootstrap script reaches preconnect first, then downloads a gzip-compressed full helper before any session tasks can run", - "native_first_connect_asset_publication_policy": "publish rdev-bootstrap as a default first-connect asset only after its compressed release artifact is measured under first_connect_target_bytes", + "first_connect_size_strategy": "publish the platform rdev-bootstrap and enforce the 1 MiB first-connect gate", + "default_first_connect_surface": "signed-native-bootstrap", + "default_runner_download_kind": "signed-layered-core", + "first_task_requires_verified_core": true, + "publishes_native_first_connect_asset": bootstrapTargetMet, + "default_bootstrap_gzip_estimated_max_bytes": maxGzipBytes, + "default_bootstrap_meets_first_connect_target": bootstrapTargetMet, + "native_first_connect_asset": nativeFirstConnectAssetReport(bootstrapTargetMet), + "default_first_connect_agent_interpretation": "the generated entry verifies rdev-bootstrap, then rdev-bootstrap verifies and starts one signed core", + "native_first_connect_asset_publication_policy": "publish rdev-bootstrap only when its final compressed handoff meets first_connect_target_bytes", } } -func nativeFirstConnectAssetReport() map[string]any { +func nativeFirstConnectAssetReport(targetMet bool) map[string]any { return map[string]any{ "schema_version": "rdev.support-session-native-first-connect-asset.v1", "name": "rdev-bootstrap", - "published": false, - "measured": false, + "published": targetMet, + "measured": true, "target_bytes": supportSessionBootstrapTargetBytes, - "target_met": false, - "reason": "rdev-bootstrap is not published by support-session assets, so the default first-connect path must be treated as script preconnect plus full-helper download", + "target_met": targetMet, + "reason": "support-session publishes rdev-bootstrap only when the measured first-connect gate passes", } } @@ -3955,7 +3957,7 @@ func recommendedSupportSessionNextStep(localRdevUsable, allAssetsReady bool) str case allAssetsReady: return "use the current rdev executable or checkout command to run support-session connect --start; target-side self-repair assets are ready" default: - return "prepare helper assets from a valid checkout before relying on one-command targets without preinstalled rdev" + return "prepare bootstrap assets from a valid checkout before relying on one-command targets" } } diff --git a/internal/supportsession/plan_test.go b/internal/supportsession/plan_test.go index 152a675..f28940d 100644 --- a/internal/supportsession/plan_test.go +++ b/internal/supportsession/plan_test.go @@ -50,8 +50,32 @@ func TestSupportSessionRdevBuildArgsStripDebugInfo(t *testing.T) { if !slices.Contains(args, "-trimpath") || !slices.Contains(args, "-ldflags=-s -w") || !strings.Contains(joined, "-o /tmp/rdev") || - args[len(args)-1] != "./cmd/rdev-host" { - t.Fatalf("expected stripped reproducible rdev build args, got %#v", args) + args[len(args)-1] != "./cmd/rdev-bootstrap" { + t.Fatalf("expected stripped reproducible bootstrap build args, got %#v", args) + } +} + +func TestSupportSessionAssetSpecsCoverBootstrapTargets(t *testing.T) { + binDir := t.TempDir() + specs := supportSessionAssetSpecs(binDir) + got := make(map[string]supportSessionAssetSpec, len(specs)) + for _, spec := range specs { + got[spec.GOOS+"/"+spec.GOARCH] = spec + } + for _, platform := range []string{ + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64", + } { + if _, ok := got[platform]; !ok { + t.Fatalf("support-session assets omitted bootstrap target %q: %#v", platform, specs) + } + } + if len(got) != 6 { + t.Fatalf("support-session assets contain unexpected bootstrap targets: %#v", specs) } } @@ -82,13 +106,14 @@ func TestPrepareReportsCompressedAssetBudgetEvidence(t *testing.T) { int64FromAny(t, report["bootstrap_target_bytes"]) != supportSessionBootstrapTargetBytes || report["all_gzip_within_budget"] != true || report["bootstrap_connector_recommended"] != false || - !strings.Contains(report["first_connect_size_strategy"].(string), "gzip") || - !strings.Contains(report["first_connect_size_strategy"].(string), "rdev-bootstrap") { + !strings.Contains(report["first_connect_size_strategy"].(string), "1 MiB") || + !strings.Contains(report["first_connect_size_strategy"].(string), "rdev-bootstrap") || + report["publishes_native_first_connect_asset"] != true { t.Fatalf("expected aggregate compressed download budget evidence, got %#v", report) } assets := report["assets"].([]map[string]any) - if len(assets) != 5 { - t.Fatalf("expected five assets, got %#v", assets) + if len(assets) != 6 { + t.Fatalf("expected six assets, got %#v", assets) } for _, asset := range assets { gzipURL, ok := asset["gzip_asset_url"].(string) @@ -132,33 +157,33 @@ func TestPrepareReportsDefaultFirstConnectAssetReality(t *testing.T) { t.Fatal(err) } report := prepare["asset_report"].(map[string]any) - if report["default_first_connect_surface"] != "script-preconnect" || - report["default_runner_download_kind"] != "gzip-full-helper" || - report["first_task_requires_full_helper"] != true || + if report["default_first_connect_surface"] != "signed-native-bootstrap" || + report["default_runner_download_kind"] != "signed-layered-core" || + report["first_task_requires_verified_core"] != true || report["publishes_native_first_connect_asset"] != false { t.Fatalf("expected asset report to state default first-connect reality, got %#v", report) } - if maxBytes := int64FromAny(t, report["default_full_helper_gzip_estimated_max_bytes"]); maxBytes <= supportSessionBootstrapTargetBytes { - t.Fatalf("expected default full helper gzip max to exceed bootstrap target, got %d", maxBytes) + if maxBytes := int64FromAny(t, report["default_bootstrap_gzip_estimated_max_bytes"]); maxBytes <= supportSessionBootstrapTargetBytes { + t.Fatalf("expected oversized bootstrap gzip max to exceed target, got %d", maxBytes) } - if report["default_full_helper_meets_bootstrap_target"] != false { - t.Fatalf("expected default full helper path not to be reported as bootstrap-target compliant, got %#v", report) + if report["default_bootstrap_meets_first_connect_target"] != false { + t.Fatalf("expected oversized bootstrap not to pass the first-connect gate, got %#v", report) } nativeBootstrap := report["native_first_connect_asset"].(map[string]any) if nativeBootstrap["name"] != "rdev-bootstrap" || nativeBootstrap["published"] != false || - nativeBootstrap["measured"] != false || + nativeBootstrap["measured"] != true || int64FromAny(t, nativeBootstrap["target_bytes"]) != supportSessionBootstrapTargetBytes || nativeBootstrap["target_met"] != false || - !strings.Contains(nativeBootstrap["reason"].(string), "not published") { + !strings.Contains(nativeBootstrap["reason"].(string), "gate passes") { t.Fatalf("expected native bootstrap publication status, got %#v", nativeBootstrap) } assets := report["assets"].([]map[string]any) for _, asset := range assets { - if asset["asset_role"] != "full-helper" || + if asset["asset_role"] != "bootstrap" || asset["used_by_default_first_connect"] != true || - asset["native_bootstrap_asset"] != false { - t.Fatalf("expected full-helper asset role evidence, got %#v", asset) + asset["native_bootstrap_asset"] != true { + t.Fatalf("expected bootstrap asset role evidence, got %#v", asset) } } } @@ -320,10 +345,10 @@ func TestBuildPlanStandardizesVisibleSupportSession(t *testing.T) { } commands := plan["commands"].(map[string]any) startGateway := strings.Join(anyStrings(commands["start_gateway"].([]string)), "\x00") - if !strings.Contains(startGateway, "--rdev-windows-amd64") || - !strings.Contains(startGateway, "--rdev-darwin-amd64") || - !strings.Contains(startGateway, "--rdev-linux-arm64") { - t.Fatalf("expected all helper asset flags, got %s", startGateway) + if !strings.Contains(startGateway, "--rdev-bootstrap-windows-amd64") || + !strings.Contains(startGateway, "--rdev-bootstrap-darwin-amd64") || + !strings.Contains(startGateway, "--rdev-bootstrap-linux-arm64") { + t.Fatalf("expected all bootstrap asset flags, got %s", startGateway) } createInvite := strings.Join(anyStrings(commands["create_invite_cli"].([]string)), "\x00") if !strings.Contains(createInvite, "--auto-activate") { @@ -665,7 +690,7 @@ func TestBuildStartedRequiresReadyAssetsToSend(t *testing.T) { blockedEnvelope := blocked["target_handoff_envelope"].(map[string]any) if blocked["ready_to_send"] != false || blocked["ready_to_send_to_human"] != false || blockedReadiness.ReadyToSend || blockedEnvelope["ready_to_forward"] != false || - !strings.Contains(strings.ToLower(blocked["handoff_blocked_reason"].(string)), "helper assets") { + !strings.Contains(strings.ToLower(blocked["handoff_blocked_reason"].(string)), "bootstrap assets") { t.Fatalf("missing asset report must block sending: %#v", blocked) } @@ -794,32 +819,30 @@ func TestBuildCreatedReturnsReadyCommandsWithoutPlaceholders(t *testing.T) { } if bootstrap["schema_version"] != "rdev.support-session-bootstrap-connector.v1" || int64FromAny(t, bootstrap["first_connect_target_bytes"]) != supportSessionBootstrapTargetBytes || - bootstrap["default_first_connect_surface"] != "script-preconnect" || - bootstrap["publishes_native_first_connect_asset"] != false || - bootstrap["preconnect_endpoint"] != "/v1/support-session/preconnect" || - bootstrap["preconnect_phase_before_full_helper"] != "downloading-helper" || - bootstrap["source"] != "rdev-bootstrap-preconnect" || + bootstrap["default_first_connect_surface"] != "signed-native-bootstrap" || + bootstrap["publishes_native_first_connect_asset"] != true || + bootstrap["source"] != "rdev-bootstrap" || bootstrap["grants_host_access"] != false || bootstrap["can_run_session_tasks"] != false || - bootstrap["full_runner_phase"] != "download-verified-rdev-host" || + bootstrap["full_runner_phase"] != "download-signed-core-after-registration" || + bootstrap["must_not_skip_core_verification"] != true || !slices.Contains(bootstrap["status_fields"].([]string), "target_preconnects") || !slices.Contains(bootstrap["status_fields"].([]string), "target_preconnect_count") || - !strings.Contains(strings.Join(bootstrap["upgrade_required_for"].([]string), "\n"), "desktop") || - !strings.Contains(bootstrap["agent_rule"].(string), "not as a connected executable host") { + !strings.Contains(bootstrap["agent_rule"].(string), "connected=true") { t.Fatalf("expected bounded bootstrap connector contract, got %#v", bootstrap) } nativeBootstrap := bootstrap["native_connector"].(map[string]any) nativeCommand := nativeBootstrap["command"].([]string) if nativeBootstrap["schema_version"] != "rdev.bootstrap-native-connector.v1" || nativeBootstrap["source"] != "rdev-bootstrap-native" || - nativeBootstrap["availability"] != "optional-if-rdev-bootstrap-is-already-installed-or-published" || - nativeBootstrap["published_by_support_session_assets"] != false || - nativeBootstrap["default_first_connect_surface"] != "script-preconnect" || + nativeBootstrap["availability"] != "published-first-connect-asset" || + nativeBootstrap["published_by_support_session_assets"] != true || + nativeBootstrap["default_first_connect_surface"] != "signed-native-bootstrap" || !slices.Contains(nativeCommand, "rdev-bootstrap") || - !slices.Contains(nativeCommand, "upgrade") || - !strings.Contains(strings.Join(nativeBootstrap["capabilities"].([]string), "\n"), "download verified full helper") || + !slices.Contains(nativeCommand, "layered-run") || + !strings.Contains(strings.Join(nativeBootstrap["capabilities"].([]string), "\n"), "download verified core") || nativeBootstrap["can_run_session_tasks_before_full_runner"] != false { - t.Fatalf("expected native rdev-bootstrap staged-upgrade contract, got %#v", nativeBootstrap) + t.Fatalf("expected native rdev-bootstrap layered contract, got %#v", nativeBootstrap) } optimizer := bootstrap["cdn_download_optimizer"].(map[string]any) if optimizer["schema_version"] != "rdev.cdn-optimizer-plan.v1" || @@ -904,7 +927,7 @@ func TestBuildCreatedReturnsReadyCommandsWithoutPlaceholders(t *testing.T) { t.Fatalf("expected Agent connection runbook, got %#v", runbook) } if failurePrevention["schema_version"] != FreshAgentFailurePreventionSchemaVersion || - !strings.Contains(strings.Join(failurePrevention["known_failure_pattern"].([]string), "\n"), "rdev is required") || + !strings.Contains(strings.Join(failurePrevention["known_failure_pattern"].([]string), "\n"), "bootstrap assets") || !strings.Contains(strings.Join(failurePrevention["required_standard_path"].([]string), "\n"), "cli_start_now_command") || !strings.Contains(strings.Join(failurePrevention["forbidden_agent_generated_workarounds"].([]string), "\n"), "ExecutionPolicy Bypass") { t.Fatalf("expected fresh-Agent failure-prevention contract, got %#v", failurePrevention) @@ -990,8 +1013,8 @@ func TestBuildCreatedKeepsTargetInvitePortableWhenAgentRdevCommandIsAbsolute(t * t.Fatalf("expected valid invite JSON: %v", err) } hostCommand, _ := invite["host_command"].(string) - if !strings.HasPrefix(hostCommand, "rdev host serve ") { - t.Fatalf("expected portable target-side host command, got %q", hostCommand) + if !strings.Contains(hostCommand, "/bootstrap.sh") || !strings.Contains(hostCommand, "rdev-bootstrap") || strings.Contains(hostCommand, "/Users/example") { + t.Fatalf("expected portable bootstrap-only target command, got %q", hostCommand) } } @@ -1176,15 +1199,15 @@ func TestBuildStartedWrapsForegroundGatewayAndSession(t *testing.T) { } } -func TestPrepareReportsHelperAssetsAndRecovery(t *testing.T) { +func TestPrepareReportsBootstrapAssetsAndRecovery(t *testing.T) { repoRoot := filepath.Join(t.TempDir(), "repo") - if err := os.MkdirAll(filepath.Join(repoRoot, "cmd", "rdev-host"), 0o700); err != nil { + if err := os.MkdirAll(filepath.Join(repoRoot, "cmd", "rdev-bootstrap"), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repoRoot, "go.mod"), []byte("module example.com/rdevtest\n\ngo 1.22\n"), 0o600); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(repoRoot, "cmd", "rdev-host", "main.go"), []byte("package main\nfunc main() {}\n"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(repoRoot, "cmd", "rdev-bootstrap", "main.go"), []byte("package main\nfunc main() {}\n"), 0o600); err != nil { t.Fatal(err) } workDir := filepath.Join(t.TempDir(), "support") @@ -1243,17 +1266,30 @@ func TestPrepareReportsHelperAssetsAndRecovery(t *testing.T) { } assets := prepare["asset_report"].(map[string]any) if assets["all_ready"] != true || assets["build_assets"] != true { - t.Fatalf("expected built helper assets, got %#v", assets) + t.Fatalf("expected built bootstrap assets, got %#v", assets) } for _, asset := range assets["assets"].([]map[string]any) { if asset["present"] != true || asset["sha256"] == "" { - t.Fatalf("expected present hashed helper asset, got %#v", asset) + t.Fatalf("expected present hashed bootstrap asset, got %#v", asset) } } recovery := strings.Join(prepare["standard_recovery"].([]string), "\n") if !strings.Contains(recovery, "do not write custom PowerShell") { t.Fatalf("expected standard recovery guardrail, got %s", recovery) } + encoded, err := json.Marshal(prepare) + if err != nil { + t.Fatal(err) + } + text := string(encoded) + if !strings.Contains(text, "bootstrap assets") { + t.Fatalf("prepare report omitted bootstrap asset language: %s", text) + } + for _, forbidden := range []string{"helper assets", "platform rdev helper", "rdev is required", "rdev host serve"} { + if strings.Contains(text, forbidden) { + t.Fatalf("prepare report contains legacy connection language %q: %s", forbidden, text) + } + } } func TestPrepareDoesNotPromoteAutoLANToExplicitGatewayCommand(t *testing.T) { @@ -1533,12 +1569,12 @@ func TestBuildStatusReportsTargetDownloadingFromPreconnect(t *testing.T) { { ID: "pre_download", TicketCode: "WAIT-1234", - Phase: "downloading-helper", + Phase: "downloading-core", OS: "windows", Arch: "amd64", - Asset: "rdev-windows-amd64.exe.gz", + Asset: "rdev-host-windows-amd64.exe", Source: "bootstrap.ps1", - Message: "downloading 18MB helper", + Message: "downloading verified core runtime", CreatedAt: now.Add(-1 * time.Minute), LastSeenAt: now, SeenCount: 3, @@ -1550,6 +1586,8 @@ func TestBuildStatusReportsTargetDownloadingFromPreconnect(t *testing.T) { status["waiting"] != false || status["connected"] != false || !strings.Contains(status["feedback"].(string), "正在下载") || + !strings.Contains(status["feedback"].(string), "core runtime") || + strings.Contains(strings.ToLower(status["feedback"].(string)), "helper") || !strings.Contains(status["next_action"].(string), "继续等待") { t.Fatalf("expected target downloading status, got %#v", status) } @@ -1557,10 +1595,10 @@ func TestBuildStatusReportsTargetDownloadingFromPreconnect(t *testing.T) { latest := summary["latest"].(model.SupportSessionPreconnect) countByPhase := summary["count_by_phase"].(map[string]int) if summary["status"] != "target-downloading" || - summary["phase"] != "downloading-helper" || + summary["phase"] != "downloading-core" || latest.ID != "pre_download" || countByPhase["started"] != 1 || - countByPhase["downloading-helper"] != 1 || + countByPhase["downloading-core"] != 1 || !strings.Contains(summary["agent_interpretation"].(string), "not disconnected") { t.Fatalf("expected target preconnect summary, got %#v", summary) } diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go index c58ef17..591ebd2 100644 --- a/internal/tunnel/manager.go +++ b/internal/tunnel/manager.go @@ -346,6 +346,10 @@ func (r *Runtime) Stop(ctx context.Context) error { if r.cancel != nil { r.cancel() } + // scheduleReplacement adds workers while holding mu. Cross the same lock + // after publishing stopRequested so cleanup cannot Wait alongside a late Add. + r.mu.Lock() + r.mu.Unlock() r.cleanupOnce.Do(func() { go r.cleanup() }) select { case <-r.cleanupDone: diff --git a/scripts/bootstrap/windows-temporary.ps1 b/scripts/bootstrap/windows-temporary.ps1 deleted file mode 100644 index 52b4883..0000000 --- a/scripts/bootstrap/windows-temporary.ps1 +++ /dev/null @@ -1,265 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$GatewayUrl, - - [Parameter(Mandatory = $true)] - [string]$TicketCode, - - [Parameter(Mandatory = $true)] - [string]$DownloadUrl, - - [Parameter(Mandatory = $true)] - [string]$ExpectedSha256, - - [string]$ManifestUrl = "", - - [string]$ManifestRootPublicKey = "", - - [string]$ReleaseManifestUrl = "", - - [string]$ReleaseBundleUrl = "", - - [string]$ReleaseBundleRequiredArtifacts = "rdev-host.exe,rdev-verify.exe", - - [string]$ReleaseRootPublicKey = "", - - [string]$VerifierDownloadUrl = "", - - [string]$VerifierExpectedSha256 = "", - - [string]$TrustPin = "", - - [string]$HostName = $env:COMPUTERNAME, - - # Maximum number of times to re-register after an unexpected exit. - # Set to 0 to disable the retry loop. - [int]$MaxRetries = 5, - - # Seconds to wait between retry attempts. - [int]$RetryDelaySeconds = 5 -) - -$ErrorActionPreference = "Stop" - -function Write-Step { - param([string]$Message) - Write-Host "[rdev] $Message" -} - -function Resolve-TempPath { - $root = Join-Path ([System.IO.Path]::GetTempPath()) "rdev-host" - New-Item -ItemType Directory -Force -Path $root | Out-Null - return $root -} - -function Invoke-Download { - param( - [string]$Url, - [string]$OutFile - ) - Write-Step "Downloading $Url to $OutFile" - Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing -} - -function Assert-Required { - param( - [string]$Name, - [string]$Value - ) - if ([string]::IsNullOrWhiteSpace($Value)) { - throw "$Name is required" - } -} - -function Assert-Sha256 { - param( - [string]$Path, - [string]$Expected - ) - $actual = (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLowerInvariant() - $expectedLower = $Expected.ToLowerInvariant() - if ($actual -ne $expectedLower) { - throw "SHA256 mismatch. expected=$expectedLower actual=$actual" - } - Write-Step "SHA256 verified: $actual" -} - -function Assert-ReleaseSignature { - param( - [string]$VerifierExe, - [string]$ArtifactPath, - [string]$ManifestPath, - [string]$RootPublicKey - ) - Write-Step "Verifying signed release manifest" - $verifyOutput = & $VerifierExe --artifact $ArtifactPath --manifest $ManifestPath --root-public-key $RootPublicKey - $verifyExit = $LASTEXITCODE - if ($verifyOutput) { - $verifyOutput | ForEach-Object { Write-Host $_ } - } - if ($verifyExit -ne 0) { - throw "release signature verification failed with exit code $verifyExit" - } -} - -function Assert-BundleRelativePath { - param( - [string]$Root, - [string]$Path - ) - if ([string]::IsNullOrWhiteSpace($Path)) { - throw "bundle path is required" - } - if ($Path.Contains("://") -or $Path.StartsWith("/") -or $Path.StartsWith('\')) { - throw "bundle path must be relative: $Path" - } - if ([System.IO.Path]::IsPathRooted($Path)) { - throw "bundle path must be relative: $Path" - } - $rootFull = [System.IO.Path]::GetFullPath($Root) - $targetFull = [System.IO.Path]::GetFullPath((Join-Path $rootFull $Path)) - $rootPrefix = $rootFull.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar - if (-not $targetFull.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "bundle path escapes temp directory: $Path" - } - return $targetFull -} - -function Resolve-BundleRelativeUrl { - param( - [string]$BaseUrl, - [string]$RelativePath - ) - $baseUri = [System.Uri]::new($BaseUrl) - return ([System.Uri]::new($baseUri, $RelativePath)).AbsoluteUri -} - -function Invoke-DownloadBundleManifests { - param( - [string]$BundlePath, - [string]$BundleUrl, - [string]$OutDir - ) - $bundle = Get-Content -LiteralPath $BundlePath -Raw | ConvertFrom-Json - if ($null -eq $bundle.artifacts) { - throw "release bundle missing artifacts" - } - foreach ($artifact in $bundle.artifacts) { - $manifestRel = [string]$artifact.manifest_path - $manifestPath = Assert-BundleRelativePath -Root $OutDir -Path $manifestRel - $manifestDir = Split-Path -Parent $manifestPath - New-Item -ItemType Directory -Force -Path $manifestDir | Out-Null - $manifestUrl = Resolve-BundleRelativeUrl -BaseUrl $BundleUrl -RelativePath $manifestRel - Invoke-Download -Url $manifestUrl -OutFile $manifestPath - } -} - -function Assert-ReleaseBundle { - param( - [string]$VerifierExe, - [string]$BundlePath, - [string]$RootPublicKey, - [string]$RequiredArtifacts - ) - Write-Step "Verifying signed release bundle" - $args = @("--bundle", $BundlePath, "--root-public-key", $RootPublicKey) - if (-not [string]::IsNullOrWhiteSpace($RequiredArtifacts)) { - $args += @("--require-artifacts", $RequiredArtifacts) - } - $verifyOutput = & $VerifierExe @args - $verifyExit = $LASTEXITCODE - if ($verifyOutput) { - $verifyOutput | ForEach-Object { Write-Host $_ } - } - if ($verifyExit -ne 0) { - throw "release bundle verification failed with exit code $verifyExit" - } -} - -Write-Host "" -Write-Host "Remote Dev Skillkit temporary support session" -Write-Host "Gateway: $GatewayUrl" -Write-Host "Ticket: $TicketCode" -if ($ManifestUrl -ne "") { - Write-Host "Manifest: $ManifestUrl" -} -if ($ManifestRootPublicKey -ne "") { - Write-Host "Manifest root: configured" -} -if ($ReleaseManifestUrl -ne "") { - Write-Host "Release manifest: $ReleaseManifestUrl" -} -if ($ReleaseBundleUrl -ne "") { - Write-Host "Release bundle: $ReleaseBundleUrl" -} -Write-Host "Mode: attended temporary foreground" -Write-Host "Retries: $MaxRetries" -Write-Host "" -Write-Host "This script does not install a Windows Service and does not create hidden persistence." -Write-Host "Close this window to stop the foreground host process." -Write-Host "" - -$tempDir = Resolve-TempPath -$hostExe = Join-Path $tempDir "rdev-host.exe" - -if (($ReleaseManifestUrl -ne "") -or ($ReleaseBundleUrl -ne "")) { - Assert-Required -Name "ReleaseRootPublicKey" -Value $ReleaseRootPublicKey - Assert-Required -Name "VerifierDownloadUrl" -Value $VerifierDownloadUrl - Assert-Required -Name "VerifierExpectedSha256" -Value $VerifierExpectedSha256 -} - -Invoke-Download -Url $DownloadUrl -OutFile $hostExe -Assert-Sha256 -Path $hostExe -Expected $ExpectedSha256 - -if (($ReleaseManifestUrl -ne "") -or ($ReleaseBundleUrl -ne "")) { - $verifierExe = Join-Path $tempDir "rdev-verify.exe" - Invoke-Download -Url $VerifierDownloadUrl -OutFile $verifierExe - Assert-Sha256 -Path $verifierExe -Expected $VerifierExpectedSha256 -} - -if ($ReleaseManifestUrl -ne "") { - $releaseManifest = Join-Path $tempDir "rdev-host.exe.rdev-release.json" - Invoke-Download -Url $ReleaseManifestUrl -OutFile $releaseManifest - Assert-ReleaseSignature -VerifierExe $verifierExe -ArtifactPath $hostExe -ManifestPath $releaseManifest -RootPublicKey $ReleaseRootPublicKey -} - -if ($ReleaseBundleUrl -ne "") { - $releaseBundle = Join-Path $tempDir "release-bundle.json" - Invoke-Download -Url $ReleaseBundleUrl -OutFile $releaseBundle - Invoke-DownloadBundleManifests -BundlePath $releaseBundle -BundleUrl $ReleaseBundleUrl -OutDir $tempDir - Assert-ReleaseBundle -VerifierExe $verifierExe -BundlePath $releaseBundle -RootPublicKey $ReleaseRootPublicKey -RequiredArtifacts $ReleaseBundleRequiredArtifacts -} - -Write-Step "Starting foreground temporary host" -$hostArgs = @( - "host", "serve", - "--mode", "temporary", - "--name", $HostName, - "--once=false" -) -if ($ManifestUrl -ne "") { - $hostArgs += @("--manifest-url", $ManifestUrl) -} else { - $hostArgs += @("--gateway", $GatewayUrl, "--ticket-code", $TicketCode) -} -if ($ManifestRootPublicKey -ne "") { - $hostArgs += @("--manifest-root-public-key", $ManifestRootPublicKey) -} -if ($TrustPin -ne "") { - $hostArgs += @("--trust-pin", $TrustPin) -} - -# Retry loop: re-register if host exits due to a transient error. -$attempt = 0 -do { - if ($attempt -gt 0) { - Write-Step "Retrying host registration (attempt $($attempt + 1) of $($MaxRetries + 1)) after ${RetryDelaySeconds}s..." - Start-Sleep -Seconds $RetryDelaySeconds - } - & $hostExe @hostArgs - $exitCode = $LASTEXITCODE - $attempt++ - Write-Step "rdev-host exited with code $exitCode" -} while ($exitCode -ne 0 -and $attempt -le $MaxRetries) - -exit $exitCode diff --git a/scripts/bootstrap/windows_temporary_test.go b/scripts/bootstrap/windows_temporary_test.go deleted file mode 100644 index 2b0ae60..0000000 --- a/scripts/bootstrap/windows_temporary_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package bootstrap_test - -import ( - "os" - "strings" - "testing" -) - -func TestWindowsTemporaryBootstrapWiresReleaseVerifier(t *testing.T) { - content, err := os.ReadFile("windows-temporary.ps1") - if err != nil { - t.Fatal(err) - } - script := string(content) - required := []string{ - "$ReleaseManifestUrl", - "$ReleaseBundleUrl", - "$ReleaseBundleRequiredArtifacts", - "$ReleaseRootPublicKey", - "$VerifierDownloadUrl", - "$VerifierExpectedSha256", - "Assert-ReleaseSignature", - "Assert-ReleaseBundle", - "--root-public-key", - "--bundle", - "rdev-verify.exe", - } - for _, needle := range required { - if !strings.Contains(script, needle) { - t.Fatalf("expected bootstrap script to contain %q", needle) - } - } - verifierHash := strings.Index(script, "Assert-Sha256 -Path $verifierExe") - releaseVerify := strings.Index(script, "Assert-ReleaseSignature -VerifierExe $verifierExe") - if verifierHash < 0 || releaseVerify < 0 { - t.Fatal("expected verifier hash and release signature steps") - } - if verifierHash > releaseVerify { - t.Fatal("verifier must be SHA256-pinned before it verifies the host artifact") - } - bundleVerify := strings.Index(script, "Assert-ReleaseBundle -VerifierExe $verifierExe") - if bundleVerify < 0 { - t.Fatal("expected release bundle verification step") - } - if verifierHash > bundleVerify { - t.Fatal("verifier must be SHA256-pinned before it verifies the release bundle") - } -} diff --git a/scripts/ci/release-smoke.sh b/scripts/ci/release-smoke.sh index 5412d82..9a8a112 100755 --- a/scripts/ci/release-smoke.sh +++ b/scripts/ci/release-smoke.sh @@ -278,6 +278,9 @@ go run ./cmd/rdev connection-entry plan \ --out "$relay_acceptance_input/connection-entry" \ --target-os linux \ --ownership third-party \ + --layered-assets-manifest-url https://assets.example.invalid/layered-assets.json \ + --layered-release-version v0.0.0-smoke \ + --release-root-public-key release-root:ddddddddddddddddddddddddddddddddddddddddddd \ > "$relay_acceptance_input/connection-entry-plan.json" fake_bin="$relay_acceptance_input/fake-bin" mkdir -p "$fake_bin" @@ -287,14 +290,14 @@ echo "fake wg helper started" >&2 sleep 1 SH chmod +x "$fake_bin/wg" -cat > "$fake_bin/rdev-host-smoke" <<'SH' +cat > "$fake_bin/rdev-bootstrap" <<'SH' #!/bin/sh if [ -n "${RDEV_FAKE_RDEV_TRANSCRIPT:-}" ]; then - printf 'fake rdev host serve %s\n' "$*" >> "$RDEV_FAKE_RDEV_TRANSCRIPT" + printf 'fake rdev-bootstrap %s\n' "$*" >> "$RDEV_FAKE_RDEV_TRANSCRIPT" fi exit 0 SH -chmod +x "$fake_bin/rdev-host-smoke" +chmod +x "$fake_bin/rdev-bootstrap" PATH="$fake_bin:$PATH" \ HTTP_PROXY= \ HTTPS_PROXY= \ @@ -309,7 +312,7 @@ RDEV_VPN_START_ARGV_JSON='["wg","show"]' \ RDEV_FAKE_RDEV_TRANSCRIPT="$relay_acceptance_input/fake-rdev-transcript.txt" \ go run ./cmd/rdev connection-entry run \ --runner-manifest "$relay_acceptance_input/connection-entry/connection-entry-runner/connection-entry-runner.json" \ - --rdev-command "$fake_bin/rdev-host-smoke" \ + --bootstrap-command "$fake_bin/rdev-bootstrap" \ --probe-timeout 1s \ --evidence-dir "$relay_acceptance_input" \ > "$relay_acceptance_input/runner-output.json" @@ -360,7 +363,7 @@ scripts/release/build-artifacts.sh \ --out "$work_dir/artifacts" \ --version "$version" \ --targets linux/amd64,windows/amd64 \ - --commands rdev,rdev-host,rdev-verify \ + --commands rdev,rdev-host,rdev-verify,rdev-bootstrap \ > "$work_dir/build.json" platform_candidates_dir="$work_dir/platform-candidates" @@ -653,7 +656,12 @@ assert build["ok"] is True, build build_manifest = json.loads(pathlib.Path(build["manifest"]).read_text()) assert build_manifest["schema_version"] == "rdev.build-artifacts.v1", build_manifest assert build_manifest["out_dir"] == ".", build_manifest -assert len(build_manifest["artifacts"]) == 6, build_manifest["artifacts"] +assert len(build_manifest["artifacts"]) == 8, build_manifest["artifacts"] +assert { + artifact["target"] + for artifact in build_manifest["artifacts"] + if artifact["command"] == "rdev-bootstrap" +} == {"linux/amd64", "windows/amd64"}, build_manifest["artifacts"] assert all(artifact["size_bytes"] > 0 for artifact in build_manifest["artifacts"]), build_manifest["artifacts"] assert all(artifact["cgo_enabled"] is False for artifact in build_manifest["artifacts"]), build_manifest["artifacts"] build_sbom_path = pathlib.Path(build["sbom"]) @@ -684,12 +692,19 @@ assert all(candidate["candidate_schema"] == "rdev.release-candidate.v1" for cand assert all(candidate["verification_schema"] == "rdev.release-candidate-verification.v1" for candidate in platform_manifest["candidates"]), platform_manifest["candidates"] for candidate in platform_manifest["candidates"]: candidate_json = json.loads((pathlib.Path(candidate["candidate_dir"]) / "release-candidate.json").read_text()) + assert candidate_json["target_platform"] == candidate["target"], candidate_json assert candidate_json["out_dir"] == ".", candidate_json assert candidate_json["provenance_path"] == "provenance.json", candidate_json assert candidate_json["connection_entry_path"] == "connection-entry-release.zip", candidate_json assert any(file["path"] == "provenance.json" and file["kind"] == "provenance" for file in candidate_json["files"]), candidate_json["files"] assert any(file["path"] == "sbom.spdx.json" and file["kind"] == "sbom" for file in candidate_json["files"]), candidate_json["files"] assert any(file["path"] == "connection-entry-release.zip" and file["kind"] == "connection-entry-release-archive" for file in candidate_json["files"]), candidate_json["files"] + if candidate["target"] == "windows/amd64": + assert candidate_json["layered_asset_manifest_path"] == "layered-assets.json", candidate_json + assert any(file["path"] == "assets/rdev-host-windows-amd64.exe" and file["kind"] == "layered-asset" for file in candidate_json["files"]), candidate_json["files"] + assert any(artifact["name"] == "rdev-bootstrap.exe" for artifact in candidate_json["artifacts"]), candidate_json["artifacts"] + else: + assert not candidate_json.get("layered_asset_manifest_path"), candidate_json entry_archive_path = pathlib.Path(candidate["candidate_dir"]) / "connection-entry-release.zip" assert entry_archive_path.is_file(), candidate_json with zipfile.ZipFile(entry_archive_path) as archive: @@ -699,7 +714,7 @@ for candidate in platform_manifest["candidates"]: "connection-entry-release.json", "connection-entry-runner.template.json", "connection-entry-checksums.txt", - "release/release-bundle.json", + "bin/release-bundle.json", "release/sbom.spdx.json", "release/provenance.json", } @@ -713,16 +728,21 @@ for candidate in platform_manifest["candidates"]: assert entry_manifest["schema_version"] == "rdev.connection-entry-release-package.v1", entry_manifest assert entry_manifest["no_private_parameters"] is True, entry_manifest assert entry_manifest["execution_mode"] == "runtime-invite-required", entry_manifest - required_artifacts = ",".join(artifact["name"] for artifact in candidate_json["artifacts"]) - assert entry_manifest["required_release_artifacts"] == [artifact["name"] for artifact in candidate_json["artifacts"]], entry_manifest + entry_artifacts = [ + artifact + for artifact in candidate_json["artifacts"] + if artifact["name"].removesuffix(".exe") in {"rdev-bootstrap", "rdev-verify"} + ] + required_artifacts = ",".join(entry_manifest["required_release_artifacts"]) + assert set(entry_manifest["required_release_artifacts"]) == {artifact["name"] for artifact in entry_artifacts}, entry_manifest assert len(entry_manifest["launchers"]) >= 1, entry_manifest launcher_paths = [path for path in names if path.startswith("launchers/")] launcher_text = "\n".join(archive.read(path).decode() for path in launcher_paths) assert "rdev-verify" in launcher_text, launcher_text - assert "--bundle" in launcher_text and ("release/release-bundle.json" in launcher_text or "release\\release-bundle.json" in launcher_text), launcher_text + assert "--bundle" in launcher_text and ("bin/release-bundle.json" in launcher_text or "bin\\release-bundle.json" in launcher_text), launcher_text assert "--root-public-key" in launcher_text and candidate_json["root_public_key"] in launcher_text, launcher_text assert "--require-artifacts" in launcher_text and required_artifacts in launcher_text, launcher_text - for artifact in candidate_json["artifacts"]: + for artifact in entry_artifacts: assert "bin/" + pathlib.Path(artifact["artifact_path"]).name in names, names assert "bin/" + pathlib.Path(artifact["manifest_path"]).name in names, names sbom = json.loads((pathlib.Path(candidate["candidate_dir"]) / "sbom.spdx.json").read_text()) @@ -1022,8 +1042,8 @@ assert fresh_agent_output["schema"] == "rdev.acceptance.fresh-agent-support-sess fresh_agent_checks = {check["name"]: check for check in fresh_agent_output["checks"]} bootstrap_self_repair_checks = [ "bootstrap_self_repair_join_page_available", - "bootstrap_self_repair_windows_downloads_verified_helper", - "bootstrap_self_repair_shell_downloads_verified_helper", + "bootstrap_self_repair_windows_downloads_verified_bootstrap", + "bootstrap_self_repair_shell_downloads_verified_bootstrap", "bootstrap_self_repair_pins_manifest_root", "bootstrap_self_repair_starts_visible_host", "bootstrap_self_repair_assets_have_hashes", diff --git a/scripts/github/bootstrap-project.sh b/scripts/github/bootstrap-project.sh index 6c3182b..77d5b97 100755 --- a/scripts/github/bootstrap-project.sh +++ b/scripts/github/bootstrap-project.sh @@ -149,7 +149,7 @@ Verification: create_issue_if_missing "Build Windows foreground temporary host no-persistence acceptance" "v0.2 Temporary Windows Host" "area:bootstrap,area:host,area:evidence,kind:test,priority:p1" \ "Context: - docs/project/ACCEPTANCE_TESTS.md Gate A -- scripts/bootstrap/windows-temporary.ps1 +- Windows-ConnectionEntry.zip from the signed layered handoff materializer - docs/security/RELEASE_KEY_LIFECYCLE.md Acceptance: diff --git a/scripts/release/build-artifacts.sh b/scripts/release/build-artifacts.sh index 91f5dc1..0a6ecef 100755 --- a/scripts/release/build-artifacts.sh +++ b/scripts/release/build-artifacts.sh @@ -4,7 +4,7 @@ set -euo pipefail out_dir="dist/artifacts" version="${RDEV_BUILD_VERSION:-0.0.1-dev}" targets="${RDEV_BUILD_TARGETS:-$(go env GOOS)/$(go env GOARCH)}" -commands="rdev,rdev-host,rdev-gateway,rdev-mcp,rdev-verify" +commands="rdev,rdev-host,rdev-bootstrap,rdev-gateway,rdev-mcp,rdev-verify" clean=false usage() { @@ -17,7 +17,7 @@ Options: --out DIR Output directory. Default: dist/artifacts --version VERSION Version embedded in binaries. Default: RDEV_BUILD_VERSION or 0.0.1-dev --targets LIST Comma-separated GOOS/GOARCH list. Default: current Go target - --commands LIST Comma-separated command list. Default: rdev,rdev-host,rdev-gateway,rdev-mcp,rdev-verify + --commands LIST Comma-separated command list. Default: rdev,rdev-host,rdev-bootstrap,rdev-gateway,rdev-mcp,rdev-verify. rdev-bootstrap is emitted for every requested target. --clean Remove output directory before building -h, --help Show this help @@ -110,10 +110,25 @@ for target in "${target_list[@]}"; do fi artifact="$target_dir/$command$suffix" echo "building $command for $goos/$goarch cgo=$cgo_enabled -> $artifact" >&2 - CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" go build \ - -trimpath \ - -ldflags "-s -w -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Name=$command -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Version=$version -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Commit=$source_commit -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.BuildTime=$generated_at" \ - -o "$artifact" "$package" + build_gcflags=() + build_tags=() + ldflags="-s -w -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Name=$command -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Version=$version -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Commit=$source_commit -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.BuildTime=$generated_at" + if [[ "$command" == "rdev-bootstrap" && "$target" == "windows/amd64" ]]; then + build_gcflags=(-gcflags=all=-l) + build_tags=(-tags=rdev_bootstrap_focused) + # Focused bootstrap delivery is size-gated; reduced function padding does + # not change the ABI or runtime behavior and keeps the closed ZIP bounded. + ldflags="-s -w -buildid= -funcalign=1 -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Name=$command -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Version=$version -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Commit=$source_commit -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.BuildTime=$generated_at" + fi + build_args=(-trimpath) + if (( ${#build_gcflags[@]} > 0 )); then + build_args+=("${build_gcflags[@]}") + fi + if (( ${#build_tags[@]} > 0 )); then + build_args+=("${build_tags[@]}") + fi + build_args+=(-ldflags "$ldflags" -o "$artifact" "$package") + CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" go build "${build_args[@]}" sha="$(shasum -a 256 "$artifact" | awk '{print $1}')" size="$(wc -c < "$artifact" | tr -d ' ')" rel="${artifact#"$out_dir"/}" diff --git a/scripts/release/prepare-platform-candidates.sh b/scripts/release/prepare-platform-candidates.sh index 1f95725..36e30c9 100755 --- a/scripts/release/prepare-platform-candidates.sh +++ b/scripts/release/prepare-platform-candidates.sh @@ -241,6 +241,7 @@ PY --source-root "$source_root" \ --out "$candidate_dir" \ --version "$selected_version" \ + --target-platform "$target" \ --gateway-url "$gateway_url" \ --artifacts "$artifacts_csv" \ --require-artifacts "$required_csv" \ diff --git a/scripts/release_contract_test.go b/scripts/release_contract_test.go new file mode 100644 index 0000000..2f92798 --- /dev/null +++ b/scripts/release_contract_test.go @@ -0,0 +1,291 @@ +package scripts_test + +import ( + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/agentinvite" + "github.com/EitanWong/remote-dev-skillkit/internal/connectionentry" + "github.com/EitanWong/remote-dev-skillkit/internal/model" + "github.com/EitanWong/remote-dev-skillkit/internal/release" + "github.com/EitanWong/remote-dev-skillkit/internal/signing" +) + +const maxReleaseWindowsLayeredHandoffBytes int64 = 1 << 20 + +func TestReleaseBuildDefaultsIncludeBootstrap(t *testing.T) { + script := readReleaseScriptForTest(t, "release/build-artifacts.sh") + defaultCommands := shellAssignmentForTest(t, script, "commands") + for _, command := range strings.Split(defaultCommands, ",") { + if strings.TrimSpace(command) == "rdev-bootstrap" { + return + } + } + t.Fatalf("default release commands omit rdev-bootstrap: %q", defaultCommands) +} + +func TestReleaseBootstrapUsesOnlyDocumentedSizeFlags(t *testing.T) { + script := readReleaseScriptForTest(t, "release/build-artifacts.sh") + for _, required := range []string{ + "-trimpath", + "-gcflags=all=-l", + "-buildid=", + "-funcalign=1", + "-tags=rdev_bootstrap_focused", + } { + if !strings.Contains(script, required) { + t.Errorf("focused bootstrap build is missing documented flag %q", required) + } + } + for _, undocumented := range []string{"rdev_bootstrap_focused,purego"} { + if strings.Contains(script, undocumented) { + t.Errorf("focused bootstrap build uses undocumented tuning %q", undocumented) + } + } +} + +func TestReleaseWindowsBootstrapFocusedDependencyBoundary(t *testing.T) { + repoRoot, err := filepath.Abs("..") + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("go", "list", "-deps", "-tags=rdev_bootstrap_focused", "./cmd/rdev-bootstrap") + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH=amd64", "CGO_ENABLED=0") + output, err := cmd.Output() + if err != nil { + t.Fatalf("list focused Windows bootstrap dependencies: %v", err) + } + dependencies := make(map[string]bool) + for _, dependency := range strings.Fields(string(output)) { + dependencies[dependency] = true + } + for _, forbidden := range []string{ + "net/http", + "crypto/tls", + "encoding/json", + "golang.org/x/sys/windows", + "github.com/EitanWong/remote-dev-skillkit/internal/bootstrapcmd", + "github.com/EitanWong/remote-dev-skillkit/internal/model", + "github.com/EitanWong/remote-dev-skillkit/internal/trustref", + } { + if dependencies[forbidden] { + t.Errorf("focused Windows bootstrap linked forbidden dependency %q", forbidden) + } + } +} + +func TestReleaseBuildEmitsBootstrapForRequestedTargets(t *testing.T) { + repoRoot, err := filepath.Abs("..") + if err != nil { + t.Fatal(err) + } + fakeBin := t.TempDir() + fakeGo := `#!/bin/sh +set -eu +if [ "$1" = "env" ]; then + case "$2" in + GOOS) printf '%s\n' darwin ;; + GOARCH) printf '%s\n' amd64 ;; + *) exit 2 ;; + esac + exit 0 +fi +if [ "$1" = "build" ]; then + shift + while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + printf '%s' fake-binary > "$2" + exit 0 + fi + shift + done +fi +exit 2 +` + if err := os.WriteFile(filepath.Join(fakeBin, "go"), []byte(fakeGo), 0o755); err != nil { + t.Fatal(err) + } + outDir := t.TempDir() + cmd := exec.Command("bash", "scripts/release/build-artifacts.sh", + "--out", outDir, + "--targets", "linux/amd64,windows/amd64", + "--commands", "rdev-bootstrap", + ) + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build-artifacts failed: %v\n%s", err, output) + } + content, err := os.ReadFile(filepath.Join(outDir, "build-artifacts.json")) + if err != nil { + t.Fatal(err) + } + var manifest struct { + Artifacts []struct { + Command string `json:"command"` + Target string `json:"target"` + } `json:"artifacts"` + } + if err := json.Unmarshal(content, &manifest); err != nil { + t.Fatal(err) + } + if len(manifest.Artifacts) != 2 { + t.Fatalf("rdev-bootstrap must be emitted for every requested target: %+v", manifest.Artifacts) + } + targets := map[string]bool{} + for _, artifact := range manifest.Artifacts { + if artifact.Command != "rdev-bootstrap" { + t.Fatalf("unexpected command in bootstrap-only build: %+v", manifest.Artifacts) + } + targets[artifact.Target] = true + } + if !targets["linux/amd64"] || !targets["windows/amd64"] { + t.Fatalf("rdev-bootstrap targets = %+v", targets) + } +} + +func TestReleaseBuildEmitsBootstrapForEveryRequestedTarget(t *testing.T) { + script := readReleaseScriptForTest(t, "release/build-artifacts.sh") + if strings.Contains(script, "command\" == \"rdev-bootstrap\" && \"$target\" != \"windows/amd64\"") { + t.Fatal("release script must not restrict rdev-bootstrap to Windows amd64") + } + if !strings.Contains(script, "rdev-bootstrap") || !strings.Contains(script, "bootstrap") { + t.Fatal("release script must declare bootstrap artifact handling") + } +} + +func TestReleaseWindowsLayeredHandoffSize(t *testing.T) { + repoRoot, err := filepath.Abs("..") + if err != nil { + t.Fatal(err) + } + outDir := t.TempDir() + cmd := exec.Command("bash", "scripts/release/build-artifacts.sh", + "--out", outDir, + "--version", "v2.0.0-size-test", + "--targets", "windows/amd64", + "--commands", "rdev-bootstrap", + "--clean", + ) + cmd.Dir = repoRoot + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-build focused Windows bootstrap: %v\n%s", err, output) + } + bootstrapPath := filepath.Join(outDir, "windows-amd64", "rdev-bootstrap.exe") + bootstrapInfo, err := os.Stat(bootstrapPath) + if err != nil { + t.Fatal(err) + } + key, err := signing.Generate("release-root-size-test") + if err != nil { + t.Fatal(err) + } + generatedAt := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + manifest, err := release.SignArtifactForRelease(bootstrapPath, key, generatedAt, "v2.0.0-size-test", "windows/amd64") + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(outDir, "rdev-bootstrap.exe.rdev-release.json") + if err := release.WriteManifest(manifestPath, manifest); err != nil { + t.Fatal(err) + } + ticket := model.Ticket{ + ID: "tkt_size", Code: "SIZE-TEST", Mode: model.HostModeAttendedTemporary, + Status: model.TicketStatusActive, TTLSeconds: 600, Capabilities: []string{"shell.user"}, + Reason: "release size gate", CreatedAt: generatedAt, ExpiresAt: generatedAt.Add(10 * time.Minute), + } + invite, err := agentinvite.New(agentinvite.Options{ + GatewayURL: "https://gateway.example.test/v1", + JoinURL: "https://gateway.example.test/join/SIZE-TEST", + ManifestURL: "https://gateway.example.test/v1/tickets/SIZE-TEST/manifest", + ManifestRootPublicKey: "manifest-root:" + strings.Repeat("d", 43), + Ticket: ticket, + Transport: "auto", + NetworkScope: "auto", + AuthorityProfile: "max-control", + CreatedAt: generatedAt, + }) + if err != nil { + t.Fatal(err) + } + inviteJSON, err := json.Marshal(invite) + if err != nil { + t.Fatal(err) + } + entryDir := filepath.Join(outDir, "entry") + rootPublicKey := key.ID + ":" + base64.RawURLEncoding.EncodeToString(key.PublicKey) + plan, err := connectionentry.FromInvite(connectionentry.Options{ + InviteJSON: string(inviteJSON), + OutDir: entryDir, + TargetOS: "windows", + TargetArch: "amd64", + Ownership: "third-party", + SessionMode: string(model.HostModeAttendedTemporary), + WindowsBootstrapBinaryPath: bootstrapPath, + WindowsBootstrapReleaseManifestPath: manifestPath, + LayeredAssetsManifestURL: "https://downloads.example.test/layered-assets.json", + LayeredReleaseVersion: "v2.0.0-size-test", + ReleaseRootPublicKey: rootPublicKey, + Now: generatedAt, + }) + if err != nil { + t.Fatal(err) + } + archivePath := filepath.Join(entryDir, "Windows-ConnectionEntry.zip") + archiveInfo, err := os.Stat(archivePath) + if err != nil { + t.Fatal(err) + } + closedSize := archiveInfo.Size() + t.Logf("Windows focused bootstrap PE size_bytes=%d; closed representative ZIP size_bytes=%d sha256=%s", bootstrapInfo.Size(), closedSize, plan.EntryPackagePlan.ArchiveSHA256) + if closedSize > maxReleaseWindowsLayeredHandoffBytes { + t.Fatalf("closed Windows-ConnectionEntry.zip size %d exceeds %d bytes (PE %d bytes)", closedSize, maxReleaseWindowsLayeredHandoffBytes, bootstrapInfo.Size()) + } + if plan.EntryPackagePlan == nil || plan.EntryPackagePlan.ArchiveSizeBytes != closedSize { + t.Fatalf("materialized archive report does not match closed file: %#v", plan.EntryPackagePlan) + } +} + +func TestPreparePlatformCandidatesForwardsTargetPlatform(t *testing.T) { + script := readReleaseScriptForTest(t, "release/prepare-platform-candidates.sh") + start := strings.Index(script, "go run ./cmd/rdev release prepare-candidate") + if start < 0 { + t.Fatal("prepare-platform-candidates script does not invoke release prepare-candidate") + } + invocation := script[start:] + if end := strings.Index(invocation, ") > \"$prepare_json\""); end >= 0 { + invocation = invocation[:end] + } + if !strings.Contains(invocation, "--target-platform \"$target\"") { + t.Fatalf("release prepare-candidate invocation does not forward the selected target platform:\n%s", invocation) + } +} + +func readReleaseScriptForTest(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +func shellAssignmentForTest(t *testing.T, script, name string) string { + t.Helper() + prefix := name + "=" + for _, line := range strings.Split(script, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.Trim(strings.TrimPrefix(line, prefix), "\"'") + } + } + t.Fatalf("shell assignment %q not found", name) + return "" +} diff --git a/skills/remote-vibe-coding/SKILL.md b/skills/remote-vibe-coding/SKILL.md index e97dfc3..bccac19 100644 --- a/skills/remote-vibe-coding/SKILL.md +++ b/skills/remote-vibe-coding/SKILL.md @@ -63,7 +63,7 @@ are CLI-only and must use their corresponding `rdev ...` command. `CLI-only: rdev support-session plan` only when the connect/handoff output, operator, or debug workflow explicitly asks for review-level planning. - When a fresh Agent session is asked to connect a machine and local `rdev`, - gateway state, or target helper assets are unclear, call + gateway state, or target bootstrap assets are unclear, call `CLI-only: rdev support-session prepare` or run `rdev support-session prepare --build-assets` from a checkout. Follow its `standard_recovery`, `asset_report`, and `connection_readiness` fields @@ -125,8 +125,8 @@ are CLI-only and must use their corresponding `rdev ...` command. explicitly asks. - Read `agent_connection_runbook.fresh_agent_failure_prevention` before writing any setup code. It captures known bad fresh-Agent failure patterns such as - manual `gateway serve` plus `invite create`, missing helper assets that make - Windows say `rdev is required`, background gateway glue, custom interrupt + manual `gateway serve` plus `invite create`, missing bootstrap assets or + signed layered metadata, background gateway glue, custom interrupt polling, and Agent-written PowerShell/shell bootstraps. If you are about to write one of those workarounds, stop and use the returned `cli_start_now_command`, `ready_file.path`, `status_file.path`, @@ -217,12 +217,12 @@ are CLI-only and must use their corresponding `rdev ...` command. `target_bootstrap_readiness` before sending a platform terminal command from an existing gateway. If readiness is false, recover with `rdev support-session connect --start` or `rdev support-session prepare --build-assets` - instead of asking the target-side human to install `rdev` manually or writing - a custom downloader. + instead of asking the target-side human to install a runtime manually or + writing a custom downloader. - Do not manually combine `rdev gateway serve` plus `rdev invite create` for ordinary support sessions. That low-level path can omit verified bootstrap - helper assets. If a dev gateway must be started by hand, configure - `--rdev-assets-dir` or platform-specific helper asset flags first. + assets or signed layered metadata. If a dev gateway must be started by hand, + configure `--rdev-assets-dir` and the signed layered release inputs first. - When `rdev.sessions.status` or `rdev support-session status --wait` returns `waiting`, `pending-activation`, `revoked`, or `timed_out=true`, read `connection_recovery` and follow its `agent_next_actions`, @@ -282,7 +282,7 @@ are CLI-only and must use their corresponding `rdev ...` command. machine-readable install/connect plan. 3. If the user wants to connect a target machine, call `CLI-only: rdev support-session prepare` or run `rdev support-session prepare` to - verify one-command support-session readiness. If helper assets are missing + verify one-command support-session readiness. If bootstrap assets are missing and a checkout plus Go are available, use `--build-assets`; use the returned `gateway_url_candidates` only as diagnostic metadata carried by `rdev`-generated handoffs; do not write custom PowerShell, ticket @@ -322,11 +322,12 @@ are CLI-only and must use their corresponding `rdev ...` command. `runner_plan` is available. Dry-run the runner with `rdev connection-entry run --runner-manifest ... --dry-run` when network reliability is uncertain; it probes direct gateway, proxy, LAN, relay, mesh, - VPN, and SSH-assisted paths before starting `rdev host serve`. When the plan + VPN, and SSH-assisted paths before invoking `rdev-bootstrap`. When the plan includes authorized `RDEV_*_INSTALL_ACTION_JSON` metadata, let the runner install and verify user/workspace helper binaries before helper startup. Use - the visible script fallback when release package assets or release inputs are - missing. Present only the selected + only a standard visible bootstrap script that acquires and verifies + `rdev-bootstrap`; fail closed when signed layered release inputs are missing. + Present only the selected `connection_entry.entry_url`, visible launcher, visible script, or signed package to the target-side human, and treat `host_command`, ticket, gateway, root, release, checksum, relay, mesh, VPN, SSH, and transport values as Agent/package diff --git a/skills/safe-remote-support/SKILL.md b/skills/safe-remote-support/SKILL.md index 269a3be..16aa199 100644 --- a/skills/safe-remote-support/SKILL.md +++ b/skills/safe-remote-support/SKILL.md @@ -116,7 +116,7 @@ If `rdev` is not found in PATH: ### Rule 5 — Temporary gateway process MUST run in foreground For attended-temporary sessions, `rdev support-session connect --start` is a -**foreground process**. It manages the public tunnel, serves helper assets, and +**foreground process**. It manages the public tunnel, serves bootstrap assets, and holds session state in memory. If it exits, the tunnel drops and the target machine loses connectivity. @@ -153,7 +153,7 @@ Omitting `gateway_url` can hit the wrong empty gateway and produce misleading "n Do NOT use `find` to locate executables. Use: - `command -v rdev` or `which rdev` (shell) -- Known install paths: `~/go/bin/rdev`, `./work/rdev-support-session/bin/rdev--` +- Known operator install paths such as `~/go/bin/rdev` - `Get-Command rdev -ErrorAction SilentlyContinue` (PowerShell) `find` scans the entire filesystem and hangs on cache directories. It is never appropriate for locating a known executable. @@ -302,7 +302,7 @@ rdev support-session connect --start Do not add `--public-tunnel`; that option no longer exists. Do not start cloudflared, tunn3l, or an SSH tunnel in a separate terminal. Do not run this command with `&`, `nohup`, or any background terminal. The CLI owns provider -selection, process lifetime, fallback, helper assets, and in-memory session +selection, process lifetime, fallback, bootstrap assets, and in-memory session state. Keep this foreground process alive for the whole session. ### Step 3 — Send ONE thing to the user